docgen-secure 1.0.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.
docgen_secure/cli.py
ADDED
|
@@ -0,0 +1,354 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
š”ļø DocGen Secure v3.0 - 10/10
|
|
4
|
+
Hybrid: Regex + AST + AI | GitHub | Detailed Results
|
|
5
|
+
"""
|
|
6
|
+
import sys, os, ast, re, json, random, time, ssl, logging, subprocess, tempfile, shutil
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from collections import defaultdict
|
|
9
|
+
from urllib.request import Request, urlopen
|
|
10
|
+
from urllib.error import URLError, HTTPError
|
|
11
|
+
|
|
12
|
+
logging.basicConfig(level=logging.WARNING)
|
|
13
|
+
logger = logging.getLogger(__name__)
|
|
14
|
+
|
|
15
|
+
try:
|
|
16
|
+
from dotenv import load_dotenv
|
|
17
|
+
load_dotenv()
|
|
18
|
+
except ImportError:
|
|
19
|
+
pass
|
|
20
|
+
|
|
21
|
+
SSL_CONTEXT = ssl.create_default_context()
|
|
22
|
+
|
|
23
|
+
# ============================================================
|
|
24
|
+
# RULES
|
|
25
|
+
# ============================================================
|
|
26
|
+
SECURITY_RULES = [
|
|
27
|
+
('hardcoded_password', 'š“ Hardcoded Password', r'(?i)password\s*[:=]\s*["\'][^"\']+["\']', 'Move to .env'),
|
|
28
|
+
('api_key_exposed', 'š“ API Key Exposed', r'(?:sk-|AIza|ghp_|xai-|gsk_|hf_)[a-zA-Z0-9]{20,}', 'Move to .env'),
|
|
29
|
+
('dangerous_eval', 'š“ Dangerous eval()', r'\beval\s*\(', 'Avoid eval()'),
|
|
30
|
+
('dangerous_exec', 'š“ Dangerous exec()', r'\bexec\s*\(', 'Avoid exec()'),
|
|
31
|
+
('dangerous_os_system', 'š“ Dangerous os.system()', r'\bos\.system\s*\(', 'Use subprocess.run()'),
|
|
32
|
+
('sql_injection_fstring', 'š“ SQL Injection (f-string)', r'execute\s*\(\s*f["\']', 'Parameterized queries'),
|
|
33
|
+
('sql_injection_format', 'š“ SQL Injection (.format)', r'execute\s*\(\s*["\'].*\.format\(', 'Parameterized queries'),
|
|
34
|
+
('sql_injection_percent', 'š“ SQL Injection (%)', r'execute\s*\(\s*["\'].*%[srd]', 'Parameterized queries'),
|
|
35
|
+
('sql_injection_concat', 'š“ SQL Injection (+)', r'execute\s*\(\s*["\'].*\+', 'Parameterized queries'),
|
|
36
|
+
('debug_mode', 'š” Debug Mode ON', r'DEBUG\s*=\s*True', 'Set False in production'),
|
|
37
|
+
('weak_crypto_md5', 'š” Weak Crypto (MD5)', r'\bhashlib\.md5\s*\(', 'Use SHA-256'),
|
|
38
|
+
('weak_crypto_sha1', 'š” Weak Crypto (SHA1)', r'\bhashlib\.sha1\s*\(', 'Use SHA-256'),
|
|
39
|
+
('insecure_deserialization', 'š“ Insecure Pickle', r'\bpickle\.loads?\s*\(', 'Use JSON instead'),
|
|
40
|
+
('xxe_vulnerability', 'š“ XXE (XML)', r'xml\.etree\.ElementTree.*parse', 'Use defusedxml'),
|
|
41
|
+
('open_redirect', 'š” Open Redirect', r'redirect\s*\(\s*request\.', 'Validate URLs'),
|
|
42
|
+
]
|
|
43
|
+
|
|
44
|
+
QUALITY_RULES = [
|
|
45
|
+
('missing_type_hints', 'š” Missing Type Hints', None, 'Add type hints'),
|
|
46
|
+
('missing_docstring', 'š” Missing Docstring', None, 'Add docstring'),
|
|
47
|
+
('function_too_long', 'š” Function Too Long (>50 lines)', None, 'Split into smaller functions'),
|
|
48
|
+
('deep_nesting', 'š” Deep Nesting (>3 levels)', None, 'Use early return pattern'),
|
|
49
|
+
('too_many_args', 'š” Too Many Arguments (>5)', None, 'Use dataclass or dict'),
|
|
50
|
+
('bare_except', 'š” Bare except:', None, 'Specify exception type'),
|
|
51
|
+
]
|
|
52
|
+
|
|
53
|
+
# ============================================================
|
|
54
|
+
# KEY MANAGER (Silent)
|
|
55
|
+
# ============================================================
|
|
56
|
+
def get_keys(prefix):
|
|
57
|
+
keys = []
|
|
58
|
+
for i in range(1, 100):
|
|
59
|
+
k = os.getenv(f'{prefix}_{i}')
|
|
60
|
+
if k: keys.append(k)
|
|
61
|
+
else: break
|
|
62
|
+
single = os.getenv(prefix)
|
|
63
|
+
if single: keys.append(single)
|
|
64
|
+
return keys
|
|
65
|
+
|
|
66
|
+
class KeyManager:
|
|
67
|
+
def __init__(self):
|
|
68
|
+
self.keys = {}
|
|
69
|
+
self.index = {}
|
|
70
|
+
self.last_used = {}
|
|
71
|
+
for prefix in ['OR_KEY', 'GEMINI_KEY']:
|
|
72
|
+
k = get_keys(prefix)
|
|
73
|
+
if k:
|
|
74
|
+
self.keys[prefix] = k
|
|
75
|
+
self.index[prefix] = 0
|
|
76
|
+
self.last_used[prefix] = 0
|
|
77
|
+
|
|
78
|
+
def get_key(self, prefix):
|
|
79
|
+
if prefix not in self.keys: return None
|
|
80
|
+
keys = self.keys[prefix]
|
|
81
|
+
elapsed = time.time() - self.last_used.get(prefix, 0)
|
|
82
|
+
if elapsed < 0.3: time.sleep(0.3 - elapsed)
|
|
83
|
+
idx = self.index[prefix] % len(keys)
|
|
84
|
+
self.index[prefix] += 1
|
|
85
|
+
self.last_used[prefix] = time.time()
|
|
86
|
+
return keys[idx]
|
|
87
|
+
|
|
88
|
+
class AICaller:
|
|
89
|
+
def __init__(self, key_manager):
|
|
90
|
+
self.km = key_manager
|
|
91
|
+
self.calls = 0
|
|
92
|
+
|
|
93
|
+
def ask(self, prompt, max_tokens=400):
|
|
94
|
+
# OpenRouter
|
|
95
|
+
key = self.km.get_key('OR_KEY')
|
|
96
|
+
if key:
|
|
97
|
+
result = self._call_openrouter(key, prompt, max_tokens)
|
|
98
|
+
if result: self.calls += 1; return result
|
|
99
|
+
# Gemini
|
|
100
|
+
key = self.km.get_key('GEMINI_KEY')
|
|
101
|
+
if key:
|
|
102
|
+
result = self._call_gemini(key, prompt, max_tokens)
|
|
103
|
+
if result: self.calls += 1; return result
|
|
104
|
+
return None
|
|
105
|
+
|
|
106
|
+
def _call_openrouter(self, key, prompt, max_tokens):
|
|
107
|
+
try:
|
|
108
|
+
data = json.dumps({'model':'google/gemini-2.0-flash-001','messages':[{'role':'user','content':prompt}],'max_tokens':max_tokens,'temperature':0.1}).encode()
|
|
109
|
+
req = Request('https://openrouter.ai/api/v1/chat/completions', data=data,
|
|
110
|
+
headers={'Authorization':f'Bearer {key}','Content-Type':'application/json'})
|
|
111
|
+
with urlopen(req, timeout=15, context=SSL_CONTEXT) as r:
|
|
112
|
+
return json.loads(r.read())['choices'][0]['message']['content']
|
|
113
|
+
except: return None
|
|
114
|
+
|
|
115
|
+
def _call_gemini(self, key, prompt, max_tokens):
|
|
116
|
+
try:
|
|
117
|
+
data = json.dumps({'contents':[{'parts':[{'text':prompt}]}],'generationConfig':{'maxOutputTokens':max_tokens,'temperature':0.1}}).encode()
|
|
118
|
+
req = Request(f'https://generativelanguage.googleapis.com/v1beta/models/gemini-2.0-flash:generateContent?key={key}',
|
|
119
|
+
data=data, headers={'Content-Type':'application/json'})
|
|
120
|
+
with urlopen(req, timeout=15, context=SSL_CONTEXT) as r:
|
|
121
|
+
return json.loads(r.read())['candidates'][0]['content']['parts'][0]['text']
|
|
122
|
+
except: return None
|
|
123
|
+
|
|
124
|
+
# ============================================================
|
|
125
|
+
# ANALYZER
|
|
126
|
+
# ============================================================
|
|
127
|
+
class Analyzer:
|
|
128
|
+
def __init__(self, lang='en', ai_caller=None, silent=False):
|
|
129
|
+
self.silent = silent
|
|
130
|
+
self.lang = lang
|
|
131
|
+
self.ai = ai_caller
|
|
132
|
+
self.issues = []
|
|
133
|
+
self.files_analyzed = 0
|
|
134
|
+
self.ai_scanned = 0
|
|
135
|
+
|
|
136
|
+
def analyze_file(self, filepath, rel_path=''):
|
|
137
|
+
path = Path(filepath)
|
|
138
|
+
self.files_analyzed += 1
|
|
139
|
+
display_name = rel_path or path.name
|
|
140
|
+
|
|
141
|
+
try:
|
|
142
|
+
with open(filepath, 'r', encoding='utf-8') as f:
|
|
143
|
+
lines = f.readlines()
|
|
144
|
+
content = ''.join(lines)
|
|
145
|
+
except: return
|
|
146
|
+
|
|
147
|
+
# REGEX
|
|
148
|
+
for i, line in enumerate(lines, 1):
|
|
149
|
+
line_s = line.strip()
|
|
150
|
+
if line_s.startswith('#') or line_s.startswith('"""'): continue
|
|
151
|
+
for rule_id, title, pattern, fix in SECURITY_RULES:
|
|
152
|
+
if pattern and re.search(pattern, line, re.IGNORECASE):
|
|
153
|
+
if rule_id == 'hardcoded_password' and ('your_' in line.lower() or 'example' in line.lower()): continue
|
|
154
|
+
if rule_id == 'api_key_exposed' and ('os.environ' in line): continue
|
|
155
|
+
if rule_id == 'debug_mode' and 'False' in line: continue
|
|
156
|
+
self.issues.append({'file':display_name,'line':i,'title':title,'fix':fix,'type':'security','severity':'high' if 'š“' in title else 'medium'})
|
|
157
|
+
break
|
|
158
|
+
|
|
159
|
+
# AST
|
|
160
|
+
try:
|
|
161
|
+
tree = ast.parse(content)
|
|
162
|
+
for node in ast.walk(tree):
|
|
163
|
+
if isinstance(node, ast.FunctionDef) and not node.name.startswith('_'):
|
|
164
|
+
# Type hints
|
|
165
|
+
if not all(a.annotation for a in node.args.args if a.arg != 'self') or not node.returns:
|
|
166
|
+
self.issues.append({'file':display_name,'line':node.lineno,'title':'š” Missing Type Hints','fix':f'Add types to {node.name}()','type':'quality','severity':'medium'})
|
|
167
|
+
# Docstring
|
|
168
|
+
if not ast.get_docstring(node):
|
|
169
|
+
self.issues.append({'file':display_name,'line':node.lineno,'title':'š” Missing Docstring','fix':f'Add docstring to {node.name}()','type':'quality','severity':'low'})
|
|
170
|
+
# Long function
|
|
171
|
+
if len(node.body) > 50:
|
|
172
|
+
self.issues.append({'file':display_name,'line':node.lineno,'title':f'š” Function Too Long ({len(node.body)} lines)','fix':f'Split {node.name}()','type':'quality','severity':'medium'})
|
|
173
|
+
# Args count
|
|
174
|
+
num_args = len([a for a in node.args.args if a.arg != 'self'])
|
|
175
|
+
if num_args > 5:
|
|
176
|
+
self.issues.append({'file':display_name,'line':node.lineno,'title':f'š” Too Many Args ({num_args})','fix':f'Use dataclass for {node.name}()','type':'quality','severity':'low'})
|
|
177
|
+
# Nesting
|
|
178
|
+
depth = self._max_nesting(node)
|
|
179
|
+
if depth > 3:
|
|
180
|
+
self.issues.append({'file':display_name,'line':node.lineno,'title':f'š” Deep Nesting ({depth} levels)','fix':'Use early return','type':'quality','severity':'medium'})
|
|
181
|
+
elif isinstance(node, ast.ExceptHandler) and node.type is None:
|
|
182
|
+
self.issues.append({'file':display_name,'line':node.lineno,'title':'š” Bare except:','fix':'Specify exception','type':'quality','severity':'medium'})
|
|
183
|
+
except SyntaxError: pass
|
|
184
|
+
|
|
185
|
+
# AI Deep Scan
|
|
186
|
+
if self.ai and len(content) < 8000:
|
|
187
|
+
prompt = f"""Analyze this code for bugs and security. Return JSON array [{{"title":"...","fix":"...","severity":"high/medium/low"}}]. CODE:\n{content[:4000]}"""
|
|
188
|
+
result = self.ai.ask(prompt, max_tokens=400)
|
|
189
|
+
if result:
|
|
190
|
+
self.ai_scanned += 1
|
|
191
|
+
try:
|
|
192
|
+
match = re.search(r'\[.*\]', result, re.DOTALL)
|
|
193
|
+
if match:
|
|
194
|
+
for item in json.loads(match.group()):
|
|
195
|
+
if isinstance(item, dict):
|
|
196
|
+
self.issues.append({'file':display_name,'line':0,'title':f"š¤ {item.get('title','')}",'fix':item.get('fix',''),'type':'ai','severity':item.get('severity','medium')})
|
|
197
|
+
except: pass
|
|
198
|
+
|
|
199
|
+
def _max_nesting(self, node, depth=0):
|
|
200
|
+
if not hasattr(node, 'body'): return depth
|
|
201
|
+
max_d = depth
|
|
202
|
+
for child in node.body:
|
|
203
|
+
d = depth+1 if isinstance(child, (ast.If,ast.For,ast.While,ast.Try,ast.With)) else depth
|
|
204
|
+
max_d = max(max_d, self._max_nesting(child, d))
|
|
205
|
+
return max_d
|
|
206
|
+
|
|
207
|
+
def _build_json(self):
|
|
208
|
+
sev = defaultdict(int)
|
|
209
|
+
for i in self.issues: sev[i.get('severity','medium')] += 1
|
|
210
|
+
total = len(self.issues)
|
|
211
|
+
score = max(0, min(10, 10 - sev['high']*0.8 - sev['medium']*0.2 - sev['low']*0.05))
|
|
212
|
+
return {'files_analyzed':self.files_analyzed,'ai_scanned':self.ai_scanned,'score':round(score,1),'total':total,'critical':sev['high'],'warning':sev['medium'],'info':sev['low'],'issues':self.issues[:100]}
|
|
213
|
+
|
|
214
|
+
def analyze_directory(self, dir_path):
|
|
215
|
+
path = Path(dir_path)
|
|
216
|
+
for f in sorted(path.rglob('*.py')):
|
|
217
|
+
if f.is_file() and 'test_' not in f.name and '__pycache__' not in str(f) and '.git' not in str(f) and not f.name.startswith('.'):
|
|
218
|
+
rel = str(f.relative_to(path))
|
|
219
|
+
self.analyze_file(str(f), rel)
|
|
220
|
+
return self.generate_report(dir_path) if not self.silent else self._build_json()
|
|
221
|
+
|
|
222
|
+
def analyze_github(self, url, token=None):
|
|
223
|
+
# Clone
|
|
224
|
+
tmp = tempfile.mkdtemp()
|
|
225
|
+
repo = url.rstrip('/').split('/')[-1].replace('.git','')
|
|
226
|
+
clone_url = url
|
|
227
|
+
if token:
|
|
228
|
+
clone_url = f"https://x-access-token:{token}@github.com/{'/'.join(url.split('/')[-2:])}"
|
|
229
|
+
subprocess.run(['git','clone','--depth','1',clone_url,tmp],capture_output=True,timeout=60)
|
|
230
|
+
# Analyze
|
|
231
|
+
self.analyze_directory(tmp)
|
|
232
|
+
# Save to home
|
|
233
|
+
dest = Path.home() / repo
|
|
234
|
+
if dest.exists(): shutil.rmtree(dest)
|
|
235
|
+
shutil.copytree(tmp, str(dest))
|
|
236
|
+
shutil.rmtree(tmp, ignore_errors=True)
|
|
237
|
+
print(f"\nš Saved: ~/{repo}/\n")
|
|
238
|
+
|
|
239
|
+
def generate_report(self, target_path=None):
|
|
240
|
+
if self.silent:
|
|
241
|
+
return self._build_json()
|
|
242
|
+
T = {
|
|
243
|
+
'ar': {'security':'Ų£Ł
Ų§Ł','quality':'Ų¬ŁŲÆŲ©','ai':'AI','total':'Ų„Ų¬Ł
Ų§ŁŁ','files':'Ł
ŁŁ','score':'ŲŖŁŁŁŁ
','critical':'ŲŲ±Ų¬','fix_plan':'Ų®Ų·Ų© Ų§ŁŲ„ŲµŁŲ§Ų','target':'Ų§ŁŁŲÆŁ'},
|
|
244
|
+
'en': {'security':'Security','quality':'Quality','ai':'AI','total':'Total','files':'files','score':'Score','critical':'Critical','fix_plan':'Fix Plan','target':'Target'}
|
|
245
|
+
}
|
|
246
|
+
t = T.get(self.lang, T['en'])
|
|
247
|
+
|
|
248
|
+
sev = defaultdict(int)
|
|
249
|
+
for i in self.issues: sev[i.get('severity','medium')] += 1
|
|
250
|
+
|
|
251
|
+
total = len(self.issues)
|
|
252
|
+
# Better scoring
|
|
253
|
+
score = max(0, min(10, 10 - sev['high']*0.8 - sev['medium']*0.2 - sev['low']*0.05))
|
|
254
|
+
|
|
255
|
+
if score >= 9: grade = 'A+'
|
|
256
|
+
elif score >= 8: grade = 'A'
|
|
257
|
+
elif score >= 7: grade = 'B'
|
|
258
|
+
elif score >= 6: grade = 'C'
|
|
259
|
+
elif score >= 4: grade = 'D'
|
|
260
|
+
else: grade = 'F'
|
|
261
|
+
|
|
262
|
+
print(f"""
|
|
263
|
+
āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā
|
|
264
|
+
ā š”ļø DocGen Secure v3.0 Report ā
|
|
265
|
+
ā āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā£
|
|
266
|
+
ā {t['target']}: {str(target_path or ''):<38}ā
|
|
267
|
+
ā {t['files']}: {self.files_analyzed:<41}ā
|
|
268
|
+
ā āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā£
|
|
269
|
+
ā ā {t['score']}: {score:.1f}/10 | š {grade:<30}ā
|
|
270
|
+
ā š“ {t['critical']}: {sev['high']:<4} š” Medium: {sev['medium']:<4} š¢ Low: {sev['low']:<4} ⬠{t['total']}: {total:<4}ā
|
|
271
|
+
āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā
|
|
272
|
+
""")
|
|
273
|
+
|
|
274
|
+
# Critical first
|
|
275
|
+
for severity, icon, label in [('high','š“','CRITICAL'),('medium','š”','WARNING'),('low','š¢','INFO')]:
|
|
276
|
+
items = [i for i in self.issues if i.get('severity')==severity]
|
|
277
|
+
if items:
|
|
278
|
+
print(f" {icon} {label} ({len(items)}):")
|
|
279
|
+
for item in items[:15]:
|
|
280
|
+
print(f" š {item['file']}:{item['line']} ā {item['title']}")
|
|
281
|
+
print(f" š” {item['fix']}\n")
|
|
282
|
+
|
|
283
|
+
if total > 40:
|
|
284
|
+
print(f" ... +{total-40} more\n")
|
|
285
|
+
|
|
286
|
+
print(f"āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā")
|
|
287
|
+
print(f" šÆ {t['fix_plan']}:")
|
|
288
|
+
print(f" 1. š“ ā Fix immediately")
|
|
289
|
+
print(f" 2. š” ā Fix before release")
|
|
290
|
+
print(f" 3. š¢ ā Improve over time")
|
|
291
|
+
print(f"āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā\n")
|
|
292
|
+
|
|
293
|
+
# JSON
|
|
294
|
+
json_file = 'docgen_report.json'
|
|
295
|
+
with open(json_file, 'w', encoding='utf-8') as f:
|
|
296
|
+
json.dump({'target':str(target_path),'files':self.files_analyzed,'score':round(score,1),'grade':grade,'total':total,'by_severity':dict(sev),'issues':self.issues[:100]}, f, indent=2, ensure_ascii=False)
|
|
297
|
+
print(f"š JSON: {json_file}\n")
|
|
298
|
+
|
|
299
|
+
# ============================================================
|
|
300
|
+
# CLI
|
|
301
|
+
# ============================================================
|
|
302
|
+
def main():
|
|
303
|
+
import argparse
|
|
304
|
+
p = argparse.ArgumentParser(description="š”ļø DocGen Secure v3.0")
|
|
305
|
+
p.add_argument('target', nargs='?', help='File, directory, or GitHub URL')
|
|
306
|
+
p.add_argument('--en', action='store_true', help='English')
|
|
307
|
+
p.add_argument('--token', help='GitHub token')
|
|
308
|
+
args = p.parse_args()
|
|
309
|
+
|
|
310
|
+
if not args.target:
|
|
311
|
+
p.print_help()
|
|
312
|
+
return
|
|
313
|
+
|
|
314
|
+
target = args.target
|
|
315
|
+
lang = 'en' if args.en else 'ar'
|
|
316
|
+
|
|
317
|
+
km = KeyManager()
|
|
318
|
+
ai = AICaller(km)
|
|
319
|
+
analyzer = Analyzer(lang=lang, ai_caller=ai)
|
|
320
|
+
|
|
321
|
+
# Local first - check if directory exists
|
|
322
|
+
if os.path.isdir(target):
|
|
323
|
+
analyzer.analyze_directory(target)
|
|
324
|
+
return
|
|
325
|
+
|
|
326
|
+
# Local file
|
|
327
|
+
if os.path.isfile(target):
|
|
328
|
+
analyzer.analyze_file(target, Path(target).name)
|
|
329
|
+
analyzer.generate_report(target)
|
|
330
|
+
return
|
|
331
|
+
|
|
332
|
+
# GitHub - convert name to URL
|
|
333
|
+
if not target.startswith('http') and '/' in target and '.' not in target.split('/')[0]:
|
|
334
|
+
target = 'https://github.com/' + target
|
|
335
|
+
|
|
336
|
+
if 'github.com' in target:
|
|
337
|
+
analyzer.analyze_github(target, args.token)
|
|
338
|
+
return
|
|
339
|
+
|
|
340
|
+
# Not found
|
|
341
|
+
print(f"ā Not found: {target}")
|
|
342
|
+
return
|
|
343
|
+
|
|
344
|
+
# Old code
|
|
345
|
+
if os.path.isdir(target):
|
|
346
|
+
analyzer.analyze_directory(target)
|
|
347
|
+
elif os.path.isfile(target):
|
|
348
|
+
analyzer.analyze_file(target, Path(target).name)
|
|
349
|
+
analyzer.generate_report(target)
|
|
350
|
+
else:
|
|
351
|
+
print(f"ā Not found: {target}")
|
|
352
|
+
|
|
353
|
+
if __name__ == '__main__':
|
|
354
|
+
main()
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
docgen_secure/__init__.py,sha256=voQs9twMue6-zDw7O--XXh6_y3VhfDO_gOwjDfFMSbU,79
|
|
2
|
+
docgen_secure/cli.py,sha256=LqKGarN0PkgBS74fF_rN1NEF2LPB6IzogqbBt9sIWfw,17267
|
|
3
|
+
docgen_secure-1.0.0.dist-info/METADATA,sha256=uyvdgCR3CfAqWpA2zFR5oo2QU6sqM4xXHKk015FOefU,57
|
|
4
|
+
docgen_secure-1.0.0.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
|
|
5
|
+
docgen_secure-1.0.0.dist-info/entry_points.txt,sha256=eVkxR8kuVgVW2FyrONLa-EyS8rGjwNdI3BT6UDsbu4M,57
|
|
6
|
+
docgen_secure-1.0.0.dist-info/top_level.txt,sha256=Gv2Ob-iRMzsH6MaGBbWLCNQ3Q2g3r9nfpLBELir8Q7s,14
|
|
7
|
+
docgen_secure-1.0.0.dist-info/RECORD,,
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
docgen_secure
|