pywebexec 1.2.9__py3-none-any.whl → 1.3.0__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/pywebexec.py CHANGED
@@ -13,9 +13,12 @@ from datetime import datetime, timezone, timedelta
13
13
  import shlex
14
14
  from gunicorn.app.base import Application
15
15
  import ipaddress
16
- from socket import gethostname, gethostbyname_ex
16
+ from socket import gethostname, gethostbyname_ex, gethostbyaddr, inet_aton, inet_ntoa
17
17
  import ssl
18
18
  import re
19
+ import pwd
20
+ from secrets import token_urlsafe
21
+
19
22
  if os.environ.get('PYWEBEXEC_LDAP_SERVER'):
20
23
  from ldap3 import Server, Connection, ALL, SIMPLE, SUBTREE, Tls
21
24
 
@@ -48,11 +51,38 @@ def generate_random_password(length=12):
48
51
 
49
52
 
50
53
  def resolve_hostname(host):
51
- """try get fqdn from DNS"""
54
+ """try get fqdn from DNS/hosts"""
55
+ try:
56
+ hostinfo = gethostbyname_ex(host)
57
+ return (hostinfo[0].rstrip('.'), hostinfo[2][0])
58
+ except OSError:
59
+ return (host, host)
60
+
61
+
62
+ def resolve_ip(ip):
63
+ """try resolve hostname by reverse dns query on ip addr"""
64
+ ip = inet_ntoa(inet_aton(ip))
52
65
  try:
53
- return gethostbyname_ex(host)[0]
66
+ ipinfo = gethostbyaddr(ip)
67
+ return (ipinfo[0].rstrip('.'), ipinfo[2][0])
54
68
  except OSError:
55
- return host
69
+ return (ip, ip)
70
+
71
+
72
+ def is_ip(host):
73
+ """determine if host is valid ip"""
74
+ try:
75
+ inet_aton(host)
76
+ return True
77
+ except OSError:
78
+ return False
79
+
80
+
81
+ def resolve(host_or_ip):
82
+ """resolve hostname from ip / hostname"""
83
+ if is_ip(host_or_ip):
84
+ return resolve_ip(host_or_ip)
85
+ return resolve_hostname(host_or_ip)
56
86
 
57
87
 
58
88
  def generate_selfsigned_cert(hostname, ip_addresses=None, key=None):
@@ -118,7 +148,7 @@ def generate_selfsigned_cert(hostname, ip_addresses=None, key=None):
118
148
 
119
149
 
120
150
 
121
- class StandaloneApplication(Application):
151
+ class PyWebExec(Application):
122
152
 
123
153
  def __init__(self, app, options=None):
124
154
  self.options = options or {}
@@ -180,11 +210,14 @@ def get_last_non_empty_line_of_file(file_path):
180
210
  return last_line(f)
181
211
 
182
212
 
183
- def start_gunicorn(daemon=False, baselog=None):
184
- if daemon:
213
+ def start_gunicorn(daemonized=False, baselog=None):
214
+ if daemonized:
185
215
  errorlog = f"{baselog}.log"
186
216
  accesslog = None # f"{baselog}.access.log"
187
217
  pidfile = f"{baselog}.pid"
218
+ if daemon_d('status', pidfilepath=baselog, silent=True):
219
+ print(f"Error: pywebexec already running on {args.listen}:{args.port}", file=sys.stderr)
220
+ sys.exit(1)
188
221
  else:
189
222
  errorlog = "-"
190
223
  accesslog = "-"
@@ -195,14 +228,14 @@ def start_gunicorn(daemon=False, baselog=None):
195
228
  'timeout': 600,
196
229
  'certfile': args.cert,
197
230
  'keyfile': args.key,
198
- 'daemon': daemon,
231
+ 'daemon': daemonized,
199
232
  'errorlog': errorlog,
200
233
  'accesslog': accesslog,
201
234
  'pidfile': pidfile,
202
235
  }
203
- StandaloneApplication(app, options=options).run()
236
+ PyWebExec(app, options=options).run()
204
237
 
205
- def daemon_d(action, pidfilepath, hostname=None, args=None):
238
+ def daemon_d(action, pidfilepath, silent=False, hostname=None, args=None):
206
239
  """start/stop daemon"""
207
240
  import signal
208
241
  import daemon, daemon.pidfile
@@ -222,10 +255,14 @@ def daemon_d(action, pidfilepath, hostname=None, args=None):
222
255
  if status:
223
256
  print(f"pywebexec running pid {pidfile.read_pid()}")
224
257
  return True
225
- print("pywebexec not running")
258
+ if not silent:
259
+ print("pywebexec not running")
226
260
  return False
227
261
  elif action == "start":
228
- print(f"Starting server")
262
+ status = pidfile.is_locked()
263
+ if status:
264
+ print(f"pywebexc already running pid {pidfile.read_pid()}", file=sys.stderr)
265
+ sys.exit(1)
229
266
  log = open(pidfilepath + ".log", "ab+")
230
267
  daemon_context = daemon.DaemonContext(
231
268
  stderr=log,
@@ -240,7 +277,8 @@ def daemon_d(action, pidfilepath, hostname=None, args=None):
240
277
  print(e)
241
278
 
242
279
  def parseargs():
243
- global app, args
280
+ global app, args, COMMAND_STATUS_DIR
281
+
244
282
  parser = argparse.ArgumentParser(description='Run the command execution server.')
245
283
  parser.add_argument('-u', '--user', help='Username for basic auth')
246
284
  parser.add_argument('-P', '--password', help='Password for basic auth')
@@ -263,9 +301,11 @@ def parseargs():
263
301
  parser.add_argument("-c", "--cert", type=str, help="Path to https certificate")
264
302
  parser.add_argument("-k", "--key", type=str, help="Path to https certificate key")
265
303
  parser.add_argument("-g", "--gencert", action="store_true", help="https server self signed cert")
266
- parser.add_argument("action", nargs="?", help="daemon action start/stop/restart/status", choices=["start","stop","restart","status"])
304
+ parser.add_argument("-T", "--tokenurl", action="store_true", help="generate safe url to access")
305
+ parser.add_argument("action", nargs="?", help="daemon action start/stop/restart/status/term", choices=["start","stop","restart","status","term"])
267
306
 
268
307
  args = parser.parse_args()
308
+ cwd = os.getcwd()
269
309
  if os.path.isdir(args.dir):
270
310
  try:
271
311
  os.chdir(args.dir)
@@ -279,8 +319,27 @@ def parseargs():
279
319
  os.makedirs(COMMAND_STATUS_DIR)
280
320
  if not os.path.exists(CONFDIR):
281
321
  os.mkdir(CONFDIR, mode=0o700)
322
+ if args.action == "term":
323
+ COMMAND_STATUS_DIR = f"{os.getcwd()}/{COMMAND_STATUS_DIR}"
324
+ os.chdir(cwd)
325
+ command_id = str(uuid.uuid4())
326
+ start_time = datetime.now().isoformat()
327
+ user = pwd.getpwuid(os.getuid())[0]
328
+ update_command_status(command_id, 'running', command="term", params=[user,os.ttyname(sys.stdout.fileno())], start_time=start_time, user=user)
329
+ output_file_path = get_output_file_path(command_id)
330
+ res = os.system(f"script -f {output_file_path}")
331
+ end_time = datetime.now().isoformat()
332
+ update_command_status(command_id, status="success", end_time=end_time, exit_code=res)
333
+ sys.exit(res)
334
+ (hostname, ip) = resolve(gethostname()) if args.listen == '0.0.0.0' else resolve(args.listen)
335
+ url_params = ""
336
+
337
+ if args.tokenurl:
338
+ token = token_urlsafe()
339
+ app.config["TOKEN_URL"] = token
340
+ url_params = f"?token={token}"
341
+
282
342
  if args.gencert:
283
- hostname = resolve_hostname(gethostname())
284
343
  args.cert = args.cert or f"{CONFDIR}/pywebexec.crt"
285
344
  args.key = args.key or f"{CONFDIR}/pywebexec.key"
286
345
  if not os.path.exists(args.cert):
@@ -301,9 +360,13 @@ def parseargs():
301
360
  app.config['USER'] = None
302
361
  app.config['PASSWORD'] = None
303
362
 
304
- return args
363
+ if args.action != 'stop':
364
+ print("Starting server:")
365
+ protocol = 'https' if args.cert else 'http'
366
+ print(f"{protocol}://{hostname}:{args.port}{url_params}")
367
+ print(f"{protocol}://{ip}:{args.port}{url_params}")
305
368
 
306
- parseargs()
369
+ return args
307
370
 
308
371
  def get_status_file_path(command_id):
309
372
  return os.path.join(COMMAND_STATUS_DIR, f'{command_id}.json')
@@ -392,17 +455,28 @@ def run_command(command, params, command_id, user):
392
455
  with open(get_output_file_path(command_id), 'a') as output_file:
393
456
  output_file.write(str(e))
394
457
 
458
+
459
+ parseargs()
460
+
461
+
395
462
  @app.before_request
396
463
  def check_authentication():
464
+ # Check for token in URL if TOKEN_URL is set
465
+ token = app.config.get('TOKEN_URL')
466
+ if token and request.endpoint not in ['login', 'static']:
467
+ if request.args.get('token') == token:
468
+ return
469
+ return jsonify({'error': 'Forbidden'}), 403
470
+
397
471
  if not app.config['USER'] and not app.config['LDAP_SERVER']:
398
472
  return
473
+
399
474
  if 'username' not in session and request.endpoint not in ['login', 'static']:
400
475
  return auth.login_required(lambda: None)()
401
476
 
402
477
  @auth.verify_password
403
478
  def verify_password(username, password):
404
479
  if not username:
405
- session['username'] = '-'
406
480
  return False
407
481
  if app.config['USER']:
408
482
  if username == app.config['USER'] and password == app.config['PASSWORD']:
@@ -565,11 +639,13 @@ def get_command_output(command_id):
565
639
  output = output_file.read().decode('utf-8', errors='replace')
566
640
  new_offset = output_file.tell()
567
641
  status_data = read_command_status(command_id) or {}
642
+ token = app.config.get("TOKEN_URL")
643
+ token_param = f"&token={token}" if token else ""
568
644
  response = {
569
645
  'output': output,
570
646
  'status': status_data.get("status"),
571
647
  'links': {
572
- 'next': f'{request.url_root}command_output/{command_id}?offset={new_offset}'
648
+ 'next': f'{request.url_root}command_output/{command_id}?offset={new_offset}{token_param}'
573
649
  }
574
650
  }
575
651
  if request.headers.get('Accept') == 'text/plain':
@@ -586,7 +662,7 @@ def list_executables():
586
662
  def main():
587
663
  basef = f"{CONFDIR}/pywebexec_{args.listen}:{args.port}"
588
664
  if args.action == "start":
589
- return start_gunicorn(daemon=True, baselog=basef)
665
+ return start_gunicorn(daemonized=True, baselog=basef)
590
666
  if args.action:
591
667
  return daemon_d(args.action, pidfilepath=basef)
592
668
  return start_gunicorn()
@@ -195,7 +195,7 @@ window.addEventListener('load', () => {
195
195
 
196
196
  async function fetchExecutables() {
197
197
  try {
198
- const response = await fetch('/executables');
198
+ const response = await fetch(`/executables${urlToken}`);
199
199
  if (!response.ok) {
200
200
  throw new Error('Failed to fetch command status');
201
201
  }
@@ -210,6 +210,8 @@ async function fetchExecutables() {
210
210
  } catch (error) {
211
211
  alert("Failed to fetch executables");
212
212
  }
213
- commandListSelect.size = Math.min(20, commandListSelect.options.length)
213
+ commandListSelect.size = Math.min(20, commandListSelect.options.length);
214
+ if (commandListSelect.options.length == 0)
215
+ document.getElementById('launchForm').style.display = 'none';
214
216
 
215
217
  }
@@ -8,7 +8,7 @@ const terminal = new Terminal({
8
8
  disableStdin: true,
9
9
  convertEol: true,
10
10
  fontFamily: 'Consolas NF, monospace, courier-new, courier',
11
- scrollBack: 999999,
11
+ scrollback: 999999,
12
12
  theme: {
13
13
  background: '#111412',
14
14
  black: '#111412',
@@ -27,6 +27,12 @@ const fitAddon = new FitAddon.FitAddon();
27
27
  terminal.loadAddon(fitAddon);
28
28
  terminal.open(document.getElementById('output'));
29
29
  fitAddon.fit();
30
+ function getTokenParam() {
31
+ const urlParams = new URLSearchParams(window.location.search);
32
+ return urlParams.get('token') ? `?token=${urlParams.get('token')}` : '';
33
+ }
34
+ const urlToken = getTokenParam();
35
+
30
36
 
31
37
  terminal.onSelectionChange(() => {
32
38
  const selectionText = terminal.getSelection();
@@ -37,12 +43,13 @@ terminal.onSelectionChange(() => {
37
43
  }
38
44
  });
39
45
 
46
+
40
47
  document.getElementById('launchForm').addEventListener('submit', async (event) => {
41
48
  event.preventDefault();
42
49
  const commandName = document.getElementById('commandName').value;
43
50
  const params = document.getElementById('params').value.split(' ');
44
51
  try {
45
- const response = await fetch('/run_command', {
52
+ const response = await fetch(`/run_command${urlToken}`, {
46
53
  method: 'POST',
47
54
  headers: {
48
55
  'Content-Type': 'application/json'
@@ -65,7 +72,7 @@ document.getElementById('launchForm').addEventListener('submit', async (event) =
65
72
 
66
73
  async function fetchCommands() {
67
74
  try {
68
- const response = await fetch('/commands');
75
+ const response = await fetch(`/commands${urlToken}`);
69
76
  if (!response.ok) {
70
77
  document.getElementById('dimmer').style.display = 'block';
71
78
  return;
@@ -91,7 +98,7 @@ async function fetchCommands() {
91
98
  <td>${command.command.replace(/^\.\//, '')}</td>
92
99
  <td><span class="status-icon status-${command.status}"></span>${command.status}${command.status === 'failed' ? ` (${command.exit_code})` : ''}</td>
93
100
  <td>
94
- ${command.status === 'running' ? `<button onclick="stopCommand('${command.command_id}', event)">Stop</button>` : `<button onclick="relaunchCommand('${command.command_id}', event)">Run</button>`}
101
+ ${command.command.startsWith('term') ? '' : command.status === 'running' ? `<button onclick="stopCommand('${command.command_id}', event)">Stop</button>` : `<button onclick="relaunchCommand('${command.command_id}', event)">Run</button>`}
95
102
  </td>
96
103
  <td class="monospace outcol">${command.last_output_line || ''}</td>
97
104
  `;
@@ -129,11 +136,11 @@ async function fetchOutput(url) {
129
136
  async function viewOutput(command_id) {
130
137
  adjustOutputHeight();
131
138
  currentCommandId = command_id;
132
- nextOutputLink = `/command_output/${command_id}`;
139
+ nextOutputLink = `/command_output/${command_id}${urlToken}`;
133
140
  clearInterval(outputInterval);
134
141
  terminal.clear();
135
142
  try {
136
- const response = await fetch(`/command_status/${command_id}`);
143
+ const response = await fetch(`/command_status/${command_id}${urlToken}`);
137
144
  if (!response.ok) {
138
145
  return;
139
146
  }
@@ -154,7 +161,7 @@ async function relaunchCommand(command_id, event) {
154
161
  event.stopPropagation();
155
162
  event.stopImmediatePropagation();
156
163
  try {
157
- const response = await fetch(`/command_status/${command_id}`);
164
+ const response = await fetch(`/command_status/${command_id}${urlToken}`);
158
165
  if (!response.ok) {
159
166
  throw new Error('Failed to fetch command status');
160
167
  }
@@ -163,7 +170,7 @@ async function relaunchCommand(command_id, event) {
163
170
  alert(data.error);
164
171
  return;
165
172
  }
166
- const relaunchResponse = await fetch('/run_command', {
173
+ const relaunchResponse = await fetch(`/run_command${urlToken}`, {
167
174
  method: 'POST',
168
175
  headers: {
169
176
  'Content-Type': 'application/json'
@@ -189,7 +196,7 @@ async function stopCommand(command_id, event) {
189
196
  event.stopPropagation();
190
197
  event.stopImmediatePropagation();
191
198
  try {
192
- const response = await fetch(`/stop_command/${command_id}`, {
199
+ const response = await fetch(`/stop_command/${command_id}${urlToken}`, {
193
200
  method: 'POST'
194
201
  });
195
202
  if (!response.ok) {
@@ -9,7 +9,7 @@
9
9
  </head>
10
10
  <body>
11
11
  <div id="dimmer" class="dimmer">
12
- <div class="dimmer-text">Server not reachable</div>
12
+ <div class="dimmer-text">Server not available</div>
13
13
  </div>
14
14
  <h2><span class="status-icon title-icon"></span>{{ title }}</h2>
15
15
  <form id="launchForm" class="form-inline">
pywebexec/version.py CHANGED
@@ -12,5 +12,5 @@ __version__: str
12
12
  __version_tuple__: VERSION_TUPLE
13
13
  version_tuple: VERSION_TUPLE
14
14
 
15
- __version__ = version = '1.2.9'
16
- __version_tuple__ = version_tuple = (1, 2, 9)
15
+ __version__ = version = '1.3.0'
16
+ __version_tuple__ = version_tuple = (1, 3, 0)
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.2
2
2
  Name: pywebexec
3
- Version: 1.2.9
3
+ Version: 1.3.0
4
4
  Summary: Simple Python HTTP Exec Server
5
5
  Home-page: https://github.com/joknarf/pywebexec
6
6
  Author: Franck Jouvanceau
@@ -67,7 +67,7 @@ Requires-Dist: ldap3>=2.9.1
67
67
  [![Python versions](https://img.shields.io/badge/python-3.6+-blue.svg)](https://shields.io/)
68
68
 
69
69
  # pywebexec
70
- Simple Python HTTP(S) API/Web Command Launcher
70
+ Simple Python HTTP(S) API/Web Command Launcher and Terminal sharing
71
71
 
72
72
  ## Install
73
73
  ```
@@ -79,10 +79,12 @@ $ pip install pywebexec
79
79
  * put in a directory the scripts/commands/links to commands you want to expose
80
80
  * start http server serving current directory executables listening on 0.0.0.0 port 8080
81
81
  ```shell
82
- $ pywebexec
82
+ $ pywebexec -d <dir>
83
83
  ```
84
84
 
85
85
  * Launch commands with params/view live output/Status using browser
86
+ * Share your terminal output using `pywebexec -d <dir> term`
87
+
86
88
  ![pywebexecnew6](https://github.com/user-attachments/assets/11415e1f-9f5f-409e-a04c-51eb062a9780)
87
89
 
88
90
  all commands output / statuses are available in the executables directory in subdirectory `.web_status`
@@ -98,6 +100,7 @@ all commands output / statuses are available in the executables directory in sub
98
100
  * HTTPS self-signed certificate generator
99
101
  * Basic Auth
100
102
  * LDAP(S)
103
+ * safe url token
101
104
  * Can be started as a daemon (POSIX)
102
105
  * Uses gunicorn to serve http/https
103
106
  * Linux/MacOS compatible
@@ -108,7 +111,18 @@ $ pywebexec --dir ~/myscripts --listen 0.0.0.0 --port 8080 --title myscripts
108
111
  $ pywebexec -d ~/myscripts -l 0.0.0.0 -p 8080 -t myscripts
109
112
  ```
110
113
 
111
- ## Basic auth
114
+ ## Safe url token
115
+
116
+ * generate safe url, use the url to access the server
117
+ ```shell
118
+ $ pywebexec -T
119
+ $ pywebexec --tokenurl
120
+ Starting server:
121
+ http://<host>:8080?token=jSTWiNgEVkddeEJ7I97x2ekOeaiXs2mErRSKNxm3DP0
122
+ http://x.x.x.x:8080?token=jSTWiNgEVkddeEJ7I97x2ekOeaiXs2mErRSKNxm3DP0
123
+ ```
124
+
125
+ ## Basic auth
112
126
 
113
127
  * single user/password
114
128
  ```shell
@@ -1,6 +1,6 @@
1
1
  pywebexec/__init__.py,sha256=4spIsVaF8RJt8S58AG_wWoORRNkws9Iwqprj27C3ljM,99
2
- pywebexec/pywebexec.py,sha256=LRKJ2KgklZsHawd97mSKodBpH5gLm3oMza30hcLcekc,22735
3
- pywebexec/version.py,sha256=zd_kt_wsJmqzo58jY4byL9Q3ywofrUGUVUN0VUMtx8w,411
2
+ pywebexec/pywebexec.py,sha256=ljvGw-inDwsP6KQqYwwfKUeN_q9k7JwyP7PfRacnqFQ,25475
3
+ pywebexec/version.py,sha256=HGwtpza1HCPtlyqElUvIyH97K44TO13CYiYVZNezQ1M,411
4
4
  pywebexec/static/css/style.css,sha256=nuJodEFojt_kCLPqbDBQAaBtWcRZ6uLjfI52mSf3EJA,5302
5
5
  pywebexec/static/css/xterm.css,sha256=gy8_LGA7Q61DUf8ElwFQzHqHMBQnbbEmpgZcbdgeSHI,5383
6
6
  pywebexec/static/images/aborted.svg,sha256=_mP43hU5QdRLFZIknBgjx-dIXrHgQG23-QV27ApXK2A,381
@@ -11,17 +11,17 @@ pywebexec/static/images/failed.svg,sha256=ADZ7IKrUyOXtqpivnz3VcH0-Wru-I5MOi3OJAk
11
11
  pywebexec/static/images/favicon.svg,sha256=ti80IfuDZwIvQcmJxkOeUaB1iMsiyOPmQmVO-h0y1IU,1126
12
12
  pywebexec/static/images/running.gif,sha256=iYuzQGkMxrakSIwt6gPieKCImGZoSAHmU5MUNZa7cpw,25696
13
13
  pywebexec/static/images/success.svg,sha256=PJDcCSTevJh7rkfSFLtc7P0pbeh8PVQBS8DaOLQemmc,489
14
- pywebexec/static/js/commands.js,sha256=4noexrtU-nTNV1L-qp6QpoGxokELKOdYDxGChYELb0w,7347
15
- pywebexec/static/js/script.js,sha256=-MAcmmeRqH4b0xcbHZWiOkJaIa9C6boZ-QOHuXXbMpc,9775
14
+ pywebexec/static/js/commands.js,sha256=VdMeCop9V5KwsR2v1J_OY1xFE7tJUYgcMg_lh2VGNjs,7476
15
+ pywebexec/static/js/script.js,sha256=WL8wvYjbAQpm_uMrGmuqx6rmHz9V_yMiGZPb1mU8xOU,10103
16
16
  pywebexec/static/js/xterm/LICENSE,sha256=EU1P4eXTull-_T9I80VuwnJXubB-zLzUl3xpEYj2T1M,1083
17
17
  pywebexec/static/js/xterm/ansi_up.min.js,sha256=KNGV0vEr30hNqKQimTAvGVy-icD5A1JqMQTtvYtKR2Y,13203
18
18
  pywebexec/static/js/xterm/xterm-addon-fit.js,sha256=Pprm9pZe4SadVXS5Bc8b9VnC9Ex4QlWwA0pxOH53Gck,1460
19
19
  pywebexec/static/js/xterm/xterm.js,sha256=Bzka76jZwEhVt_LlS0e0qMw7ryGa1p5qfxFyeohphBo,283371
20
20
  pywebexec/templates/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
21
- pywebexec/templates/index.html,sha256=M-9JUAR5vrk-19oF8CpxkWJvyWJkZIa71AI-1jpsMWE,2116
22
- pywebexec-1.2.9.dist-info/LICENSE,sha256=gRJf0JPT_wsZJsUGlWPTS8Vypfl9vQ1qjp6sNbKykuA,1064
23
- pywebexec-1.2.9.dist-info/METADATA,sha256=nvOnY9AFUD_7E79MhEIg_fhniAxF1HRB5CcuUt5I4jQ,6936
24
- pywebexec-1.2.9.dist-info/WHEEL,sha256=In9FTNxeP60KnTkGw7wk6mJPYd_dQSjEZmXdBdMCI-8,91
25
- pywebexec-1.2.9.dist-info/entry_points.txt,sha256=l52GBkPCXRkmlHfEyoVauyfBdg8o-CAtC8qQpOIjJK0,55
26
- pywebexec-1.2.9.dist-info/top_level.txt,sha256=vHoHyzngrfGdm_nM7Xn_5iLmaCrf10XO1EhldgNLEQ8,10
27
- pywebexec-1.2.9.dist-info/RECORD,,
21
+ pywebexec/templates/index.html,sha256=LaRXHXsOR2eWkBcLIlPxGKHSLTa8JfDkDCJZWadn_1Q,2116
22
+ pywebexec-1.3.0.dist-info/LICENSE,sha256=gRJf0JPT_wsZJsUGlWPTS8Vypfl9vQ1qjp6sNbKykuA,1064
23
+ pywebexec-1.3.0.dist-info/METADATA,sha256=-9Gg5Bjeonp57OkAoVCq6BOTjG5EK6hoLH31q_AnkzU,7325
24
+ pywebexec-1.3.0.dist-info/WHEEL,sha256=In9FTNxeP60KnTkGw7wk6mJPYd_dQSjEZmXdBdMCI-8,91
25
+ pywebexec-1.3.0.dist-info/entry_points.txt,sha256=l52GBkPCXRkmlHfEyoVauyfBdg8o-CAtC8qQpOIjJK0,55
26
+ pywebexec-1.3.0.dist-info/top_level.txt,sha256=vHoHyzngrfGdm_nM7Xn_5iLmaCrf10XO1EhldgNLEQ8,10
27
+ pywebexec-1.3.0.dist-info/RECORD,,