unitlab 2.3.3__py3-none-any.whl → 2.3.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.
- unitlab/tunnel_config.py +140 -179
- unitlab/tunnel_service_token.py +104 -0
- {unitlab-2.3.3.dist-info → unitlab-2.3.4.dist-info}/METADATA +2 -1
- {unitlab-2.3.3.dist-info → unitlab-2.3.4.dist-info}/RECORD +8 -7
- {unitlab-2.3.3.dist-info → unitlab-2.3.4.dist-info}/LICENSE.md +0 -0
- {unitlab-2.3.3.dist-info → unitlab-2.3.4.dist-info}/WHEEL +0 -0
- {unitlab-2.3.3.dist-info → unitlab-2.3.4.dist-info}/entry_points.txt +0 -0
- {unitlab-2.3.3.dist-info → unitlab-2.3.4.dist-info}/top_level.txt +0 -0
unitlab/tunnel_config.py
CHANGED
@@ -1,192 +1,161 @@
|
|
1
1
|
"""
|
2
|
-
Cloudflare Tunnel Configuration
|
2
|
+
Cloudflare Tunnel Configuration using Service Token
|
3
|
+
No user login required - uses pre-generated service token
|
3
4
|
"""
|
4
5
|
|
5
|
-
import
|
6
|
+
import os
|
6
7
|
import subprocess
|
7
8
|
import socket
|
8
9
|
import time
|
9
|
-
import
|
10
|
-
|
10
|
+
import logging
|
11
|
+
|
12
|
+
logger = logging.getLogger(__name__)
|
11
13
|
|
12
14
|
|
13
15
|
class CloudflareTunnel:
|
14
|
-
def __init__(self, base_domain, device_id):
|
15
|
-
|
16
|
-
|
16
|
+
def __init__(self, base_domain, device_id): # base_domain kept for compatibility
|
17
|
+
"""
|
18
|
+
Initialize tunnel with service token
|
19
|
+
No login or credential files needed
|
20
|
+
"""
|
21
|
+
# Configuration
|
22
|
+
self.base_domain = "1scan.uz" # Hardcoded domain
|
17
23
|
self.device_id = device_id
|
18
24
|
self.hostname = socket.gethostname()
|
19
|
-
|
20
|
-
|
21
|
-
|
25
|
+
|
26
|
+
# Service token - replace with your actual token from cloudflared tunnel token command
|
27
|
+
# This token can ONLY run the tunnel, cannot modify or delete it
|
28
|
+
# To generate: cloudflared tunnel token unitlab-shared
|
29
|
+
self.service_token = os.getenv(
|
30
|
+
"CLOUDFLARE_TUNNEL_TOKEN",
|
31
|
+
"eyJhIjoiYzkxMTkyYWUyMGE1ZDQzZjY1ZTA4NzU1MGQ4ZGM4OWIiLCJzIjoiZmdnSHowbFJFRnBHa05TZzIzV3JKMVBiaDVROGVUd0oyYWtJWThXdjhtTT0iLCJ0IjoiYjMzZGFhOGYtMmNjMy00Y2FkLWEyMjgtOTdlMDYwNzBlNjAwIn0=" # Replace this with your actual token from step 1
|
32
|
+
)
|
33
|
+
|
34
|
+
if self.service_token == "YOUR_SERVICE_TOKEN_HERE":
|
35
|
+
logger.warning(
|
36
|
+
"⚠️ No service token configured. "
|
37
|
+
"Set CLOUDFLARE_TUNNEL_TOKEN env var or update the token in tunnel_config.py"
|
38
|
+
)
|
22
39
|
|
23
40
|
# Subdomain names
|
24
41
|
self.jupyter_subdomain = f"jupyter-{device_id}"
|
25
42
|
self.ssh_subdomain = f"ssh-{device_id}"
|
26
43
|
|
27
|
-
# Full URLs
|
44
|
+
# Full URLs
|
28
45
|
self.jupyter_url = f"https://{self.jupyter_subdomain}.{self.base_domain}"
|
29
46
|
self.ssh_url = f"https://{self.ssh_subdomain}.{self.base_domain}"
|
30
47
|
|
31
|
-
self.
|
32
|
-
self.credentials_file = None
|
48
|
+
self.tunnel_process = None
|
33
49
|
|
34
|
-
def
|
35
|
-
"""
|
50
|
+
def check_cloudflared_installed(self):
|
51
|
+
"""Check if cloudflared is installed"""
|
36
52
|
try:
|
37
|
-
print("🔐 Checking Cloudflare authentication...")
|
38
53
|
result = subprocess.run(
|
39
|
-
["cloudflared", "
|
54
|
+
["cloudflared", "--version"],
|
40
55
|
capture_output=True,
|
41
56
|
text=True
|
42
57
|
)
|
43
|
-
|
44
|
-
|
45
|
-
return True
|
46
|
-
else:
|
47
|
-
print("❌ Cloudflare authentication failed")
|
48
|
-
return False
|
49
|
-
except Exception as e:
|
50
|
-
print(f"❌ Error during Cloudflare login: {e}")
|
58
|
+
return result.returncode == 0
|
59
|
+
except FileNotFoundError:
|
51
60
|
return False
|
52
61
|
|
53
|
-
def
|
54
|
-
"""
|
62
|
+
def install_cloudflared(self):
|
63
|
+
"""Auto-install cloudflared if not present"""
|
64
|
+
import platform
|
65
|
+
|
66
|
+
system = platform.system().lower()
|
67
|
+
machine = platform.machine().lower()
|
68
|
+
|
69
|
+
print("📦 Installing cloudflared...")
|
70
|
+
|
55
71
|
try:
|
56
|
-
|
57
|
-
|
58
|
-
|
59
|
-
|
60
|
-
|
61
|
-
|
62
|
-
|
63
|
-
|
64
|
-
|
65
|
-
if list_result.returncode == 0:
|
66
|
-
tunnels = json.loads(list_result.stdout)
|
67
|
-
for tunnel in tunnels:
|
68
|
-
if tunnel.get("name") == self.tunnel_name:
|
69
|
-
self.tunnel_uuid = tunnel.get("id")
|
70
|
-
print(f"✅ Tunnel already exists with ID: {self.tunnel_uuid}")
|
71
|
-
self.credentials_file = self.config_dir / f"{self.tunnel_uuid}.json"
|
72
|
-
return True
|
73
|
-
|
74
|
-
# Create new tunnel
|
75
|
-
result = subprocess.run(
|
76
|
-
["cloudflared", "tunnel", "create", self.tunnel_name],
|
77
|
-
capture_output=True,
|
78
|
-
text=True
|
79
|
-
)
|
80
|
-
|
81
|
-
if result.returncode == 0:
|
82
|
-
for line in result.stdout.split('\n'):
|
83
|
-
if "Created tunnel" in line and "with id" in line:
|
84
|
-
self.tunnel_uuid = line.split("with id")[1].strip()
|
85
|
-
break
|
72
|
+
if system == "linux":
|
73
|
+
# Determine architecture
|
74
|
+
if machine in ["x86_64", "amd64"]:
|
75
|
+
arch = "amd64"
|
76
|
+
elif machine in ["aarch64", "arm64"]:
|
77
|
+
arch = "arm64"
|
78
|
+
else:
|
79
|
+
arch = "386"
|
86
80
|
|
87
|
-
|
88
|
-
|
89
|
-
["cloudflared", "tunnel", "list", "--output", "json"],
|
90
|
-
capture_output=True,
|
91
|
-
text=True
|
92
|
-
)
|
93
|
-
if list_result.returncode == 0:
|
94
|
-
tunnels = json.loads(list_result.stdout)
|
95
|
-
for tunnel in tunnels:
|
96
|
-
if tunnel.get("name") == self.tunnel_name:
|
97
|
-
self.tunnel_uuid = tunnel.get("id")
|
98
|
-
break
|
81
|
+
# Download and install
|
82
|
+
url = f"https://github.com/cloudflare/cloudflared/releases/latest/download/cloudflared-linux-{arch}"
|
99
83
|
|
100
|
-
|
101
|
-
|
102
|
-
|
103
|
-
|
104
|
-
|
105
|
-
|
106
|
-
|
107
|
-
|
108
|
-
|
109
|
-
|
110
|
-
|
111
|
-
|
112
|
-
|
113
|
-
|
114
|
-
|
115
|
-
|
116
|
-
|
117
|
-
|
118
|
-
|
119
|
-
["
|
120
|
-
|
121
|
-
|
122
|
-
|
123
|
-
|
124
|
-
|
125
|
-
|
126
|
-
|
127
|
-
|
128
|
-
|
129
|
-
|
130
|
-
|
131
|
-
|
132
|
-
["cloudflared", "tunnel", "route", "dns",
|
133
|
-
self.tunnel_name, f"{self.ssh_subdomain}.{self.base_domain}"],
|
134
|
-
capture_output=True,
|
135
|
-
text=True
|
136
|
-
)
|
137
|
-
|
138
|
-
if ssh_result.returncode == 0:
|
139
|
-
print(f"✅ SSH route configured: {self.ssh_url}")
|
140
|
-
else:
|
141
|
-
print(f"⚠️ SSH route may already exist or failed: {ssh_result.stderr}")
|
84
|
+
commands = [
|
85
|
+
f"curl -L {url} -o /tmp/cloudflared",
|
86
|
+
"chmod +x /tmp/cloudflared",
|
87
|
+
"sudo mv /tmp/cloudflared /usr/local/bin/cloudflared 2>/dev/null || "
|
88
|
+
"mkdir -p ~/.local/bin && mv /tmp/cloudflared ~/.local/bin/cloudflared"
|
89
|
+
]
|
90
|
+
|
91
|
+
for cmd in commands:
|
92
|
+
subprocess.run(cmd, shell=True, check=False)
|
93
|
+
|
94
|
+
# Add ~/.local/bin to PATH if needed
|
95
|
+
local_bin = os.path.expanduser("~/.local/bin")
|
96
|
+
if local_bin not in os.environ.get("PATH", ""):
|
97
|
+
os.environ["PATH"] = f"{local_bin}:{os.environ['PATH']}"
|
98
|
+
|
99
|
+
return self.check_cloudflared_installed()
|
100
|
+
|
101
|
+
elif system == "darwin":
|
102
|
+
# Try homebrew first
|
103
|
+
result = subprocess.run(["brew", "install", "cloudflared"], capture_output=True)
|
104
|
+
if result.returncode != 0:
|
105
|
+
# Fallback to direct download
|
106
|
+
url = "https://github.com/cloudflare/cloudflared/releases/latest/download/cloudflared-darwin-amd64.tgz"
|
107
|
+
subprocess.run(f"curl -L {url} | tar xz", shell=True)
|
108
|
+
subprocess.run("sudo mv cloudflared /usr/local/bin/", shell=True)
|
109
|
+
|
110
|
+
return self.check_cloudflared_installed()
|
111
|
+
|
112
|
+
elif system == "windows":
|
113
|
+
print("⚠️ Please install cloudflared manually on Windows")
|
114
|
+
print(" Download from: https://github.com/cloudflare/cloudflared/releases")
|
115
|
+
return False
|
142
116
|
|
143
|
-
return True
|
144
|
-
|
145
117
|
except Exception as e:
|
146
|
-
|
118
|
+
logger.error(f"Failed to install cloudflared: {e}")
|
147
119
|
return False
|
148
120
|
|
149
|
-
def
|
150
|
-
"""
|
151
|
-
|
152
|
-
|
153
|
-
|
154
|
-
|
155
|
-
{
|
156
|
-
"hostname": f"{self.jupyter_subdomain}.{self.base_domain}",
|
157
|
-
"service": f"http://localhost:{jupyter_port}",
|
158
|
-
"originRequest": {
|
159
|
-
"noTLSVerify": True
|
160
|
-
}
|
161
|
-
},
|
162
|
-
{
|
163
|
-
"hostname": f"{self.ssh_subdomain}.{self.base_domain}",
|
164
|
-
"service": "ssh://localhost:22",
|
165
|
-
"originRequest": {
|
166
|
-
"noTLSVerify": True
|
167
|
-
}
|
168
|
-
},
|
169
|
-
{
|
170
|
-
"service": "http_status:404"
|
171
|
-
}
|
172
|
-
]
|
173
|
-
}
|
121
|
+
def setup(self, jupyter_port): # jupyter_port kept for compatibility
|
122
|
+
"""
|
123
|
+
Setup and start tunnel with service token
|
124
|
+
No login required!
|
125
|
+
"""
|
126
|
+
print("🚀 Setting up Cloudflare tunnel with service token...")
|
174
127
|
|
175
|
-
|
176
|
-
|
177
|
-
|
128
|
+
# Check if cloudflared is installed
|
129
|
+
if not self.check_cloudflared_installed():
|
130
|
+
print("cloudflared not found, attempting to install...")
|
131
|
+
if not self.install_cloudflared():
|
132
|
+
print("❌ Failed to install cloudflared. Please install it manually:")
|
133
|
+
print(" https://developers.cloudflare.com/cloudflare-one/connections/connect-apps/install-and-setup/installation/")
|
134
|
+
return None
|
178
135
|
|
179
|
-
|
180
|
-
return
|
136
|
+
# Start tunnel with service token
|
137
|
+
return self.start_tunnel_with_token()
|
181
138
|
|
182
|
-
def
|
183
|
-
"""
|
139
|
+
def start_tunnel_with_token(self):
|
140
|
+
"""
|
141
|
+
Start the tunnel using service token
|
142
|
+
This is much simpler than the credential file approach
|
143
|
+
"""
|
184
144
|
try:
|
185
145
|
print("🚀 Starting Cloudflare tunnel...")
|
186
146
|
|
187
|
-
|
147
|
+
# Simple command with service token
|
148
|
+
cmd = [
|
149
|
+
"cloudflared",
|
150
|
+
"tunnel",
|
151
|
+
"--no-autoupdate", # Prevent auto-updates during run
|
152
|
+
"run",
|
153
|
+
"--token",
|
154
|
+
self.service_token
|
155
|
+
]
|
188
156
|
|
189
|
-
|
157
|
+
# Start the tunnel process
|
158
|
+
self.tunnel_process = subprocess.Popen(
|
190
159
|
cmd,
|
191
160
|
stdout=subprocess.PIPE,
|
192
161
|
stderr=subprocess.STDOUT,
|
@@ -195,44 +164,36 @@ class CloudflareTunnel:
|
|
195
164
|
)
|
196
165
|
|
197
166
|
# Wait for tunnel to establish
|
167
|
+
print("⏳ Waiting for tunnel to connect...")
|
198
168
|
time.sleep(5)
|
199
169
|
|
200
|
-
if process
|
201
|
-
|
202
|
-
|
170
|
+
# Check if process is still running
|
171
|
+
if self.tunnel_process.poll() is None:
|
172
|
+
print("✅ Tunnel is running!")
|
173
|
+
print(f"📌 Jupyter URL: {self.jupyter_url}")
|
174
|
+
print(f"📌 SSH URL: {self.ssh_url}")
|
175
|
+
return self.tunnel_process
|
203
176
|
else:
|
177
|
+
# Read any error output
|
178
|
+
output = self.tunnel_process.stdout.read()
|
204
179
|
print("❌ Tunnel failed to start")
|
180
|
+
print(f"Error output: {output}")
|
205
181
|
return None
|
206
182
|
|
207
183
|
except Exception as e:
|
208
184
|
print(f"❌ Error starting tunnel: {e}")
|
209
185
|
return None
|
210
186
|
|
211
|
-
def
|
212
|
-
"""
|
213
|
-
|
214
|
-
|
215
|
-
|
216
|
-
|
217
|
-
|
218
|
-
|
219
|
-
|
220
|
-
|
221
|
-
|
222
|
-
|
223
|
-
|
224
|
-
return None
|
225
|
-
|
226
|
-
# Create config file
|
227
|
-
config_file = self.create_config_file(jupyter_port)
|
228
|
-
|
229
|
-
# Start tunnel
|
230
|
-
tunnel_process = self.start_tunnel(config_file)
|
231
|
-
|
232
|
-
if tunnel_process:
|
233
|
-
print("\n✅ Tunnel setup complete!")
|
234
|
-
print(f"📌 Jupyter URL: {self.jupyter_url}")
|
235
|
-
print(f"📌 SSH URL: {self.ssh_url}")
|
236
|
-
return tunnel_process
|
237
|
-
|
238
|
-
return None
|
187
|
+
def stop(self):
|
188
|
+
"""Stop the tunnel if running"""
|
189
|
+
if self.tunnel_process and self.tunnel_process.poll() is None:
|
190
|
+
print("Stopping tunnel...")
|
191
|
+
self.tunnel_process.terminate()
|
192
|
+
self.tunnel_process.wait(timeout=5)
|
193
|
+
print("Tunnel stopped")
|
194
|
+
|
195
|
+
# Removed all the old methods that are no longer needed:
|
196
|
+
# - login() - not needed with service token
|
197
|
+
# - create_tunnel() - tunnel already exists
|
198
|
+
# - configure_dns() - already configured
|
199
|
+
# - create_config_file() - not needed with service token
|
@@ -0,0 +1,104 @@
|
|
1
|
+
"""
|
2
|
+
Service Token implementation for Cloudflare Tunnel
|
3
|
+
More secure than embedding full credentials
|
4
|
+
"""
|
5
|
+
|
6
|
+
import os
|
7
|
+
import subprocess
|
8
|
+
import time
|
9
|
+
from pathlib import Path
|
10
|
+
|
11
|
+
|
12
|
+
class ServiceTokenTunnel:
|
13
|
+
"""
|
14
|
+
Use Cloudflare Service Token instead of credentials file
|
15
|
+
This is more secure and doesn't require login
|
16
|
+
"""
|
17
|
+
|
18
|
+
# Embed the service token (generated once by admin)
|
19
|
+
# This token can ONLY run the tunnel, cannot modify it
|
20
|
+
DEFAULT_SERVICE_TOKEN = "eyJhIjoiYzkxMTkyYWUyMGE1ZDQzZjY1ZTA4NzU1MGQ4ZGM4OWIiLCJzIjoiZmdnSHowbFJFRnBHa05TZzIzV3JKMVBiaDVROGVUd0oyYWtJWThXdjhtTT0iLCJ0IjoiYjMzZGFhOGYtMmNjMy00Y2FkLWEyMjgtOTdlMDYwNzBlNjAwIn0="
|
21
|
+
|
22
|
+
def __init__(self, device_id, service_token=None):
|
23
|
+
self.device_id = device_id
|
24
|
+
# Allow override via environment variable or parameter
|
25
|
+
self.service_token = (
|
26
|
+
service_token or
|
27
|
+
os.getenv("CLOUDFLARE_TUNNEL_TOKEN") or
|
28
|
+
self.DEFAULT_SERVICE_TOKEN
|
29
|
+
)
|
30
|
+
|
31
|
+
# With service token, we don't need credential files
|
32
|
+
# The token contains all necessary information
|
33
|
+
|
34
|
+
def start_tunnel_with_token(self):
|
35
|
+
"""
|
36
|
+
Start tunnel using service token
|
37
|
+
No login, no credential files needed
|
38
|
+
"""
|
39
|
+
print("🚀 Starting tunnel with service token...")
|
40
|
+
|
41
|
+
# Service token method - super simple!
|
42
|
+
cmd = [
|
43
|
+
"cloudflared",
|
44
|
+
"tunnel",
|
45
|
+
"run",
|
46
|
+
"--token",
|
47
|
+
self.service_token
|
48
|
+
]
|
49
|
+
|
50
|
+
try:
|
51
|
+
# Start the tunnel
|
52
|
+
process = subprocess.Popen(
|
53
|
+
cmd,
|
54
|
+
stdout=subprocess.PIPE,
|
55
|
+
stderr=subprocess.PIPE,
|
56
|
+
text=True
|
57
|
+
)
|
58
|
+
|
59
|
+
# Wait for tunnel to establish
|
60
|
+
time.sleep(3)
|
61
|
+
|
62
|
+
if process.poll() is None:
|
63
|
+
print("✅ Tunnel running with service token")
|
64
|
+
return process
|
65
|
+
else:
|
66
|
+
print("❌ Failed to start tunnel")
|
67
|
+
return None
|
68
|
+
|
69
|
+
except Exception as e:
|
70
|
+
print(f"❌ Error: {e}")
|
71
|
+
return None
|
72
|
+
|
73
|
+
def get_tunnel_info(self):
|
74
|
+
"""
|
75
|
+
Service tokens use predetermined URLs
|
76
|
+
The admin sets these up when creating the tunnel
|
77
|
+
"""
|
78
|
+
# These URLs are configured when creating the tunnel
|
79
|
+
base_domain = "1scan.uz"
|
80
|
+
return {
|
81
|
+
"jupyter_url": f"https://jupyter-{self.device_id}.{base_domain}",
|
82
|
+
"ssh_url": f"https://ssh-{self.device_id}.{base_domain}"
|
83
|
+
}
|
84
|
+
|
85
|
+
|
86
|
+
# Usage example
|
87
|
+
def run_with_service_token(device_id):
|
88
|
+
"""
|
89
|
+
Example of how simple it is with service token
|
90
|
+
"""
|
91
|
+
tunnel = ServiceTokenTunnel(device_id)
|
92
|
+
|
93
|
+
# No login needed!
|
94
|
+
# No credential files needed!
|
95
|
+
# Just run with the token
|
96
|
+
process = tunnel.start_tunnel_with_token()
|
97
|
+
|
98
|
+
if process:
|
99
|
+
urls = tunnel.get_tunnel_info()
|
100
|
+
print(f"Jupyter: {urls['jupyter_url']}")
|
101
|
+
print(f"SSH: {urls['ssh_url']}")
|
102
|
+
return process
|
103
|
+
|
104
|
+
return None
|
@@ -1,6 +1,6 @@
|
|
1
1
|
Metadata-Version: 2.1
|
2
2
|
Name: unitlab
|
3
|
-
Version: 2.3.
|
3
|
+
Version: 2.3.4
|
4
4
|
Home-page: https://github.com/teamunitlab/unitlab-sdk
|
5
5
|
Author: Unitlab Inc.
|
6
6
|
Author-email: team@unitlab.ai
|
@@ -23,4 +23,5 @@ Requires-Dist: typer
|
|
23
23
|
Requires-Dist: validators
|
24
24
|
Requires-Dist: psutil
|
25
25
|
Requires-Dist: pyyaml
|
26
|
+
Requires-Dist: jupyter
|
26
27
|
|
@@ -3,11 +3,12 @@ unitlab/__main__.py,sha256=6Hs2PV7EYc5Tid4g4OtcLXhqVHiNYTGzSBdoOnW2HXA,29
|
|
3
3
|
unitlab/client.py,sha256=LGbjto3jisF5LojlYjJyLKRc55wxfZTO_tkoipZBKd4,22910
|
4
4
|
unitlab/exceptions.py,sha256=68Tr6LreEzjQ3Vns8HAaWdtewtkNUJOvPazbf6NSnXU,950
|
5
5
|
unitlab/main.py,sha256=XD8PF_EJ1gAKqHcRT_bWy62LWrODGWMx2roKoaQaoY4,5253
|
6
|
-
unitlab/tunnel_config.py,sha256=
|
6
|
+
unitlab/tunnel_config.py,sha256=WhxjuOjvOXz16KbMAgpvbCpRD0Y5y53y9jVizQ7CxR8,7841
|
7
|
+
unitlab/tunnel_service_token.py,sha256=ji96a4s4W2cFJrHZle0zBD85Ac_T862-gCKzBUomrxM,3125
|
7
8
|
unitlab/utils.py,sha256=83ekAxxfXecFTg76Z62BGDybC_skKJHYoLyawCD9wGM,1920
|
8
|
-
unitlab-2.3.
|
9
|
-
unitlab-2.3.
|
10
|
-
unitlab-2.3.
|
11
|
-
unitlab-2.3.
|
12
|
-
unitlab-2.3.
|
13
|
-
unitlab-2.3.
|
9
|
+
unitlab-2.3.4.dist-info/LICENSE.md,sha256=Gn7RRvByorAcAaM-WbyUpsgi5ED1-bKFFshbWfYYz2Y,1069
|
10
|
+
unitlab-2.3.4.dist-info/METADATA,sha256=4GHYHBRrtcbbHw06gcZ5mfucnjJvOGFC4Y8DvRqw-28,814
|
11
|
+
unitlab-2.3.4.dist-info/WHEEL,sha256=iAkIy5fosb7FzIOwONchHf19Qu7_1wCWyFNR5gu9nU0,91
|
12
|
+
unitlab-2.3.4.dist-info/entry_points.txt,sha256=ig-PjKEqSCj3UTdyANgEi4tsAU84DyXdaOJ02NHX4bY,45
|
13
|
+
unitlab-2.3.4.dist-info/top_level.txt,sha256=Al4ZlTYE3fTJK2o6YLCDMH5_DjuQkffRBMxgmWbKaqQ,8
|
14
|
+
unitlab-2.3.4.dist-info/RECORD,,
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|