Initial Commit

This commit is contained in:
2024-12-25 15:05:39 -08:00
commit aa6581a805
8 changed files with 1219 additions and 0 deletions

3
.gitignore vendored Normal file
View File

@ -0,0 +1,3 @@
.venv
__pycache__

201
app.py Normal file
View File

@ -0,0 +1,201 @@
from flask import Flask, render_template, request, jsonify, send_file
import os
import time
import tempfile
import zipfile
import io
from werkzeug.utils import secure_filename
from req_generator import (
get_all_imports, get_stdlib_modules, get_import_to_pkg_mapping,
resolve_package_name, get_package_releases, find_compatible_version,
get_file_dates, get_file_min_version
)
import logging
from logging.handlers import RotatingFileHandler
# 1.file date is like right now, how can we preserve the uploaded file mod
# date? Or change to use file creation date?
# 2.it would be better to have a local pypi repository (bandersnatch?)
# to reduce chances of getting too many request errors while scraping
# OR just scrape every so often and build a list of packages and their
# versions, and do a cron job to refresh it every week or so.
app = Flask(__name__)
app.config['MAX_CONTENT_LENGTH'] = 10 * 1024 * 1024 # 10MB max file size
logger = logging.getLogger(__name__)
logger.setLevel(logging.INFO)
os.makedirs('logs', exist_ok=True)
file_handler = RotatingFileHandler('logs/app.log', maxBytes=1024*1024, backupCount=5)
file_handler.setLevel(logging.INFO)
console_handler = logging.StreamHandler()
console_handler.setLevel(logging.INFO)
ALLOWED_EXTENSIONS = {'py', 'zip'}
def allowed_file(filename):
return '.' in filename and filename.rsplit('.', 1)[1].lower() in ALLOWED_EXTENSIONS
def process_python_file(file_path):
'''
Tries to get requirements and python version for this file
'''
# Get all imports
imports = get_all_imports(file_path)
if not imports:
return {"error": "No imports found."}
# Filter out stdlib modules
stdlib_modules = get_stdlib_modules()
imports = {imp for imp in imports if imp not in stdlib_modules}
if not imports:
return {"error": "No non-stdlib imports found."}
interpreter_version = get_file_min_version(file_path=file_path)
# Get import to package mapping
import_mapping = get_import_to_pkg_mapping()
# Get dates
file_date, git_date = get_file_dates(file_path)
reference_date = git_date if git_date else file_date
# Process each import
requirements = []
unresolved_imports = []
for import_name in sorted(imports):
# Try to resolve package name
package_name = resolve_package_name(import_name, import_mapping)
if not package_name:
unresolved_imports.append(import_name)
continue
releases = get_package_releases(package_name)
if not releases:
continue
version = find_compatible_version(releases, reference_date)
if version:
req_line = f"{package_name}=={version} # https://pypi.org/project/{package_name}/"
requirements.append(req_line)
return {
"requirements": requirements,
"unresolved": unresolved_imports,
"reference_date": reference_date.isoformat(),
"interpreter_version": interpreter_version
}
@app.route('/')
def index():
return render_template('index.html')
@app.route('/analyze', methods=['POST'])
def analyze():
if 'file' not in request.files:
return jsonify({"error": "No file provided"}), 400
file = request.files['file']
if file.filename == '':
return jsonify({"error": "No file selected"}), 400
if not allowed_file(file.filename):
return jsonify({"error": "Invalid file type"}), 400
with tempfile.TemporaryDirectory() as temp_dir:
if file.filename.endswith('.zip'):
scanned_files = []
# Handle zip file, preserving original file dates from the archive
zip_path = os.path.join(temp_dir, 'upload.zip')
file.save(zip_path)
requirements = []
unresolved = set()
min_py_vers = []
with zipfile.ZipFile(zip_path, 'r') as zip_ref:
zip_ref.extractall(temp_dir)
# Get list of all files in zip archive
zip_info_list = zip_ref.filelist
# Create mapping of filenames to their dates from the archive
file_dates = {
os.path.join(temp_dir, info.filename): info.date_time
for info in zip_info_list
}
for root, _, files in os.walk(temp_dir):
for filename in files:
if filename.endswith('.py'):
file_path = os.path.join(root, filename)
# Set the file's modification time to match the zip archive
if file_path in file_dates:
dt = file_dates[file_path]
timestamp = time.mktime(dt + (0, 0, 0)) # Add hours, minutes, seconds
os.utime(file_path, (timestamp, timestamp))
result = process_python_file(file_path)
scanned_files.append(file_path + " | Vermin reported version: " + result["interpreter_version"])
if "error" not in result:
requirements.extend(result["requirements"])
unresolved.update(result["unresolved"])
min_py_vers.append(result["interpreter_version"])
if not requirements and not unresolved:
return jsonify({"error": "No Python files found in zip"}), 400
logging.info(f"Analyzed {len(scanned_files)} with {len(requirements)} requirements. Unresolved: {unresolved}")
return jsonify({
"requirements": sorted(set(requirements)),
"unresolved": sorted(list(unresolved)),
"file_list": sorted(list(scanned_files)),
#"interpreter_version": " | ".join(min_py_vers)
})
else:
# Handle single Python file, we need to use our custom header
# X-Last-Modified-Date (UTC String Format)
file_path = os.path.join(temp_dir, secure_filename(file.filename))
file.save(file_path)
try:
lastModifiedStr = request.headers.get("X-Last-Modified-Date")
modification_time = time.strptime(lastModifiedStr, "%a, %d %b %Y %H:%M:%S GMT")
modification_timestamp = time.mktime(modification_time)
os.utime(file_path, (modification_timestamp, modification_timestamp))
except Exception as e:
logging.debug(e)
result = process_python_file(file_path)
if "error" in result:
return jsonify({"error": result["error"]}), 400
return jsonify(result)
@app.route('/download', methods=['POST'])
def download():
data = request.json
if not data or 'requirements' not in data:
return jsonify({"error": "No requirements provided"}), 400
content = "\n".join(data['requirements'])
if 'unresolved' in data and data['unresolved']:
content += "\n\n# Unresolved Requirements:\n"
content += "\n".join(f"#{imp}==???" for imp in data['unresolved'])
buffer = io.BytesIO()
buffer.write(content.encode('utf-8'))
buffer.seek(0)
return send_file(
buffer,
mimetype='text/plain',
as_attachment=True,
download_name='requirements.txt'
)
if __name__ == '__main__':
app.run(debug=True)

0
logs/app.log Normal file
View File

745
req_generator.py Normal file
View File

@ -0,0 +1,745 @@
import os
import sys
import git
import requests
import datetime
import ast
from bs4 import BeautifulSoup
from pathlib import Path
import argparse
from typing import Dict, List, Optional, Tuple, Set
import importlib.metadata
import vermin
import logging
from logging.handlers import RotatingFileHandler
logger = logging.getLogger()
logger.setLevel(logging.INFO)
def get_file_min_version(file_path):
with open(file_path, 'r') as f:
contents = f.read()
return vermin.version_strings(vermin.detect(contents))
return 'unknown'
class ImportVisitor(ast.NodeVisitor):
"""AST visitor to find all imports in a Python file."""
def __init__(self):
self.imports = set()
def visit_Import(self, node):
"""Handle 'import x' statements."""
for name in node.names:
# Get the top-level module name
self.imports.add(name.name.split(".")[0])
def visit_ImportFrom(self, node):
"""Handle 'from x import y' statements."""
if node.level == 0: # Absolute import
# Get the top-level module name
self.imports.add(node.module.split(".")[0])
def get_file_imports(file_path: str) -> Set[str]:
"""Parse a Python file and extract all import names."""
try:
with open(file_path, "r", encoding="utf-8") as f:
tree = ast.parse(f.read(), filename=file_path)
visitor = ImportVisitor()
visitor.visit(tree)
return visitor.imports
except Exception as e:
print(f"Error parsing imports from {file_path}: {e}")
return set()
def get_all_imports(path: str) -> Set[str]:
"""Get all imports from a file or directory."""
imports = set()
if os.path.isfile(path):
if path.endswith(".py"):
imports.update(get_file_imports(path))
else:
for root, _, files in os.walk(path):
for file in files:
if file.endswith(".py"):
file_path = os.path.join(root, file)
imports.update(get_file_imports(file_path))
return imports
def get_stdlib_modules() -> set:
"""Get a set of all Python standard library module names."""
stdlib_modules = set()
# Get Python's stdlib directory
stdlib_paths = [os.path.dirname(os.__file__)]
if hasattr(sys, "base_prefix"):
stdlib_paths.append(
os.path.join(
sys.base_prefix,
"lib",
f"python{sys.version_info.major}.{sys.version_info.minor}",
)
)
try:
for stdlib_path in stdlib_paths:
# Walk through the standard library directories
for root, dirs, files in os.walk(stdlib_path):
# Skip test directories
if "test" in root or "tests" in root:
continue
for file in files:
if file.endswith(".py"):
# Convert path to module name
rel_path = os.path.relpath(
os.path.join(root, file), stdlib_path
)
module_path = os.path.splitext(rel_path)[0]
module_name = module_path.replace(os.sep, ".")
stdlib_modules.add(module_name.split(".")[0])
# Add some common built-in modules
stdlib_modules.update(
[
"sys",
"builtins",
"_thread",
"_signal",
"_sre",
"_ast",
"_operator",
"_symtable",
"_random",
"_pickle",
"_datetime",
"_elementtree",
]
)
except Exception as e:
print(f"Error getting stdlib modules: {e}")
return stdlib_modules
def get_import_to_pkg_mapping() -> Dict[str, str]:
"""Create a mapping of import names to PyPI package names."""
# Common known mappings for imports:pypi_name
KNOWN_MAPPINGS = {
"bs4": "beautifulsoup4",
"PIL": "pillow",
"dotenv": "python-dotenv",
"yaml": "pyyaml",
"mx": "mxnet",
"cairo": "pycairo",
"dateutil": "python-dateutil",
"lxml.etree": "lxml",
"nacl": "pynacl",
"pkg_resources": "setuptools",
"googleapiclient": "google-api-python-client",
"psycopg2": "psycopg2-binary",
"MySQLdb1": "mysqlclient",
"git": "GitPython",
"winreg": "windows-curses",
"win32com": "pywin32",
"win32api": "pywin32",
"pythoncom": "pywin32",
"scipy.io": "scipy",
"skimage": "scikit-image",
"sklearn.external.joblib": "joblib",
"wx": "wxPython",
"sounddevice": "python-sounddevice",
"magic": "python-magic",
"dbus": "dbus-python",
"keyring": "keyring",
"tabulate-stubs": "types-tabulate",
"seaborn-stubs": "types-seaborn",
"stripe-stubs": "types-stripe",
"flake8_2020-stubs": "types-flake8-2020",
"click_spinner-stubs": "types-click-spinner",
"pysftp-stubs": "types-pysftp",
"retry-stubs": "types-retry",
"regex-stubs": "types-regex",
"zxcvbn-stubs": "types-zxcvbn",
"debian": "importlib-metadata",
"importlib_metadata": "importlib-metadata",
"opentracing-stubs": "types-opentracing",
"flask_socketio-stubs": "types-Flask-SocketIO",
"ttkthemes-stubs": "types-ttkthemes",
"humanfriendly-stubs": "types-humanfriendly",
"flake8_simplify-stubs": "types-flake8-simplify",
"peewee-stubs": "types-peewee",
"pika-stubs": "types-pika-ts",
"docutils-stubs": "types-docutils",
"PIL-stubs": "types-Pillow",
"xmltodict-stubs": "types-xmltodict",
"pep8ext_naming-stubs": "types-pep8-naming",
"playsound-stubs": "types-playsound",
"pycurl-stubs": "types-pycurl",
"httplib2-stubs": "types-httplib2",
"LanguageSelector": "language-selector",
"language_support_pkgs": "language-selector",
"pytest_lazyfixture-stubs": "types-pytest-lazy-fixture",
"fpdf-stubs": "types-fpdf2",
"croniter-stubs": "types-croniter",
"dateutil-stubs": "types-python-dateutil",
"xdg": "pyxdg",
"wtforms-stubs": "types-WTForms",
"tensorflow-stubs": "types-tensorflow",
"OpenSSL": "pyOpenSSL",
"google-stubs": "types-google-cloud-ndb",
"zope": "zope.interface",
"ujson-stubs": "types-ujson",
"toml-stubs": "types-toml",
"toposort-stubs": "types-toposort",
"markdown": "Markdown",
"zstd-stubs": "types-zstd",
"boltons-stubs": "types-boltons",
"decorator-stubs": "types-decorator",
"mypy_extensions-stubs": "types-mypy-extensions",
"pynput-stubs": "types-pynput",
"translationstring-stubs": "types-translationstring",
"pymysql-stubs": "types-PyMySQL",
"whatthepatch-stubs": "types-whatthepatch",
"src": "kiwisolver",
"aws_xray_sdk-stubs": "types-aws-xray-sdk",
"caldav-stubs": "types-caldav",
"paho-stubs": "types-paho-mqtt",
"mock-stubs": "types-mock",
"matplotlib_inline": "matplotlib-inline",
"IPython": "ipython",
"consolemenu-stubs": "types-console-menu",
"passpy-stubs": "types-passpy",
"ibm_db-stubs": "types-ibm-db",
"brlapi": "Brlapi",
"tree_sitter_languages-stubs": "types-tree-sitter-languages",
"_pytest": "pytest",
"py": "pytest",
"neovim": "pynvim",
"pyasn1-stubs": "types-pyasn1",
"systemd": "systemd-python",
"systemd/_daemon": "systemd-python",
"systemd/_journal": "systemd-python",
"systemd/_reader": "systemd-python",
"systemd/id128": "systemd-python",
"systemd/login": "systemd-python",
"flake8_plugin_utils-stubs": "types-flake8-plugin-utils",
"pkg_resources-stubs": "types-setuptools",
"setuptools-stubs": "types-setuptools",
"isympy": "sympy",
"six-stubs": "types-six",
"html5lib-stubs": "types-html5lib",
"singledispatch-stubs": "types-singledispatch",
"uwsgi-stubs": "types-uWSGI",
"uwsgidecorators-stubs": "types-uWSGI",
"bs4-stubs": "types-beautifulsoup4",
"cups": "pycups",
"polib-stubs": "types-polib",
"serial-stubs": "types-pyserial",
"openpyxl-stubs": "types-openpyxl",
"jsonschema-stubs": "types-jsonschema",
"redis-stubs": "types-redis",
"pytest_flask": "pytest-flask",
"portpicker-stubs": "types-portpicker",
"pure_eval": "pure-eval",
"pygments-stubs": "types-Pygments",
"cachetools-stubs": "types-cachetools",
"psutil-stubs": "types-psutil",
"deprecated-stubs": "types-Deprecated",
"Xlib-stubs": "types-python-xlib",
"axi": "apt-xapian-index",
"flask_cors-stubs": "types-Flask-Cors",
"gdb-stubs": "types-gdb",
"vobject-stubs": "types-vobject",
"yaml-stubs": "types-PyYAML",
"simplejson-stubs": "types-simplejson",
"Cryptodome": "pycryptodomex",
"flake8_builtins-stubs": "types-flake8-builtins",
"netaddr-stubs": "types-netaddr",
"hgdemandimport": "mercurial",
"hgext": "mercurial",
"hgext3rd": "mercurial",
"flake8_rst_docstrings-stubs": "types-flake8-rst-docstrings",
"automat": "Automat",
"nmap-stubs": "types-python-nmap",
"serial": "pyserial",
"_pyrsistent_version": "pyrsistent",
"pvectorc": "pyrsistent",
"tree_sitter-stubs": "types-tree-sitter",
"deb822": "python-debian",
"debian_bundle": "python-debian",
"secretstorage": "SecretStorage",
"ldap3-stubs": "types-ldap3",
"jwt": "PyJWT",
"_dbus_bindings": "dbus-python",
"_dbus_glib_bindings": "dbus-python",
"oauthlib-stubs": "types-oauthlib",
"MySQLdb-stubs": "types-mysqlclient",
"waitress-stubs": "types-waitress",
"Xlib": "python-xlib",
"jks-stubs": "types-pyjks",
"qrcode-stubs": "types-qrcode",
"xattr": "pyxattr",
"slugify-stubs": "types-python-slugify",
"mypy_extensions": "mypy-extensions",
"cronlog-stubs": "types-python-crontab",
"crontab-stubs": "types-python-crontab",
"crontabs-stubs": "types-python-crontab",
"entrypoints-stubs": "types-entrypoints",
"dateparser-stubs": "types-dateparser",
"dateparser_data-stubs": "types-dateparser",
"_win32typing-stubs": "types-pywin32",
"afxres-stubs": "types-pywin32",
"commctrl-stubs": "types-pywin32",
"dde-stubs": "types-pywin32",
"isapi-stubs": "types-pywin32",
"mmapfile-stubs": "types-pywin32",
"mmsystem-stubs": "types-pywin32",
"ntsecuritycon-stubs": "types-pywin32",
"odbc-stubs": "types-pywin32",
"perfmon-stubs": "types-pywin32",
"pythoncom-stubs": "types-pywin32",
"pythonwin-stubs": "types-pywin32",
"pywintypes-stubs": "types-pywin32",
"regutil-stubs": "types-pywin32",
"servicemanager-stubs": "types-pywin32",
"sspicon-stubs": "types-pywin32",
"timer-stubs": "types-pywin32",
"win2kras-stubs": "types-pywin32",
"win32-stubs": "types-pywin32",
"win32api-stubs": "types-pywin32",
"win32clipboard-stubs": "types-pywin32",
"win32com-stubs": "types-pywin32",
"win32comext-stubs": "types-pywin32",
"win32con-stubs": "types-pywin32",
"win32console-stubs": "types-pywin32",
"win32cred-stubs": "types-pywin32",
"win32crypt-stubs": "types-pywin32",
"win32cryptcon-stubs": "types-pywin32",
"win32event-stubs": "types-pywin32",
"win32evtlog-stubs": "types-pywin32",
"win32evtlogutil-stubs": "types-pywin32",
"win32file-stubs": "types-pywin32",
"win32gui-stubs": "types-pywin32",
"win32gui_struct-stubs": "types-pywin32",
"win32help-stubs": "types-pywin32",
"win32inet-stubs": "types-pywin32",
"win32inetcon-stubs": "types-pywin32",
"win32job-stubs": "types-pywin32",
"win32lz-stubs": "types-pywin32",
"win32net-stubs": "types-pywin32",
"win32netcon-stubs": "types-pywin32",
"win32pdh-stubs": "types-pywin32",
"win32pdhquery-stubs": "types-pywin32",
"win32pipe-stubs": "types-pywin32",
"win32print-stubs": "types-pywin32",
"win32process-stubs": "types-pywin32",
"win32profile-stubs": "types-pywin32",
"win32ras-stubs": "types-pywin32",
"win32security-stubs": "types-pywin32",
"win32service-stubs": "types-pywin32",
"win32serviceutil-stubs": "types-pywin32",
"win32timezone-stubs": "types-pywin32",
"win32trace-stubs": "types-pywin32",
"win32transaction-stubs": "types-pywin32",
"win32ts-stubs": "types-pywin32",
"win32ui-stubs": "types-pywin32",
"win32uiole-stubs": "types-pywin32",
"win32wnet-stubs": "types-pywin32",
"winerror-stubs": "types-pywin32",
"winioctlcon-stubs": "types-pywin32",
"winnt-stubs": "types-pywin32",
"winperf-stubs": "types-pywin32",
"winxpgui-stubs": "types-pywin32",
"winxptheme-stubs": "types-pywin32",
"pyflakes-stubs": "types-pyflakes",
"prompt_toolkit": "prompt-toolkit",
"icu": "PyICU",
"youtube_dl": "youtube-dl",
"pexpect-stubs": "types-pexpect",
"jaraco": "jaraco.classes",
"fontTools": "fonttools",
"_yaml": "PyYAML",
"xdg-stubs": "types-pyxdg",
"NvidiaDetector": "ubuntu-drivers-common",
"Quirks": "ubuntu-drivers-common",
"UbuntuDrivers": "ubuntu-drivers-common",
"flake8_typing_imports-stubs": "types-flake8-typing-imports",
"webob-stubs": "types-WebOb",
"distro_info": "distro-info",
"exifread-stubs": "types-ExifRead",
"jose-stubs": "types-python-jose",
"farmhash-stubs": "types-pyfarmhash",
"fuse": "fuse-python",
"fuseparts": "fuse-python",
"click_default_group-stubs": "types-click-default-group",
"parsimonious-stubs": "types-parsimonious",
"markdown-stubs": "types-Markdown",
"usersettings-stubs": "types-usersettings",
"chevron-stubs": "types-chevron",
"cloudinit": "cloud-init",
"lazr": "lazr.restfulclient",
"pyautogui-stubs": "types-PyAutoGUI",
"aiofiles-stubs": "types-aiofiles",
"posix1e": "pylibacl",
"stack_data": "stack-data",
"hdbcli-stubs": "types-hdbcli",
"first-stubs": "types-first",
"pyscreeze-stubs": "types-PyScreeze",
"_bcrypt": "bcrypt",
"datemath-stubs": "types-python-datemath",
"freetype": "freetype-py",
"commonmark-stubs": "types-commonmark",
"_cffi_backend-stubs": "types-cffi",
"cffi-stubs": "types-cffi",
"jack-stubs": "types-JACK-Client",
"uaclient": "ubuntu-pro-client",
"s2clientprotocol-stubs": "types-s2clientprotocol",
"pluggy-stubs": "types-pluggy",
"apt": "python-apt",
"apt_inst": "python-apt",
"apt_pkg": "python-apt",
"aptsources": "python-apt",
"OpenSSL-stubs": "types-pyOpenSSL",
"tzlocal-stubs": "types-tzlocal",
"flask_migrate-stubs": "types-Flask-Migrate",
"pytz-stubs": "types-pytz",
"babel": "Babel",
"PyQt5": "PyQt5-sip",
"bugbear-stubs": "types-flake8-bugbear",
"pyrfc3339": "pyRFC3339",
"influxdb_client-stubs": "types-influxdb-client",
"untangle-stubs": "types-untangle",
"stdlib_list-stubs": "types-stdlib-list",
"passlib-stubs": "types-passlib",
"jmespath-stubs": "types-jmespath",
"docopt-stubs": "types-docopt",
"dockerfile_parse-stubs": "types-dockerfile-parse",
"PyInstaller-stubs": "types-pyinstaller",
"pyi_splash-stubs": "types-pyinstaller",
"configargparse": "ConfigArgParse",
"ssh_import_id": "ssh-import-id",
"pycocotools-stubs": "types-pycocotools",
"paramiko-stubs": "types-paramiko",
"flake8_docstrings-stubs": "types-flake8-docstrings",
"psycopg2-stubs": "types-psycopg2",
"workalendar-stubs": "types-workalendar",
"websockets/extensions": "websockets",
"websockets/legacy": "websockets",
"inifile-stubs": "types-inifile",
"gflags-stubs": "types-python-gflags",
"editdistance-stubs": "types-editdistance",
"colorama-stubs": "types-colorama",
"sass-stubs": "types-libsass",
"sassutils-stubs": "types-libsass",
"tqdm-stubs": "types-tqdm",
"_distutils_hack": "setuptools",
"braintree-stubs": "types-braintree",
"pyaudio-stubs": "types-pyaudio",
"google": "protobuf",
"keyboard-stubs": "types-keyboard",
"jinja2": "Jinja2",
"send2trash-stubs": "types-Send2Trash",
"pyrfc3339-stubs": "types-pyRFC3339",
"bleach-stubs": "types-bleach",
"_brotli": "Brotli",
"brotli": "Brotli",
"slumber-stubs": "types-slumber",
"requests-stubs": "types-requests",
"markupsafe": "MarkupSafe",
"usbcreator": "usb-creator",
"boto-stubs": "types-boto",
"greenlet-stubs": "types-greenlet",
"validate": "configobj",
"requests_oauthlib-stubs": "types-requests-oauthlib",
"mpl_toolkits": "matplotlib",
"pylab": "matplotlib",
}
mappings = KNOWN_MAPPINGS.copy()
# using locally installed packages, we can try to add their package names/invocation
try:
for dist in importlib.metadata.distributions():
try:
top_level_names = dist.read_text("top_level.txt").split()
for name in top_level_names:
if name not in mappings and name != dist.metadata["Name"]:
mappings[name] = dist.metadata["Name"]
# print(f'Updated local mapping for {name}')
except Exception as e:
continue
# print(f"Error processing metadata for {dist}: {e}")
except Exception as e:
return
#print(f"Error getting package mappings: {e}")
return mappings
def verify_package_exists(package_name: str) -> Optional[str]:
"""
Verify if a package exists on PyPI and return the correct name.
Returns None if package doesn't exist.
"""
# first try to see if the local pip can resolve a name
# First try the exact name
url = f"https://pypi.org/project/{package_name}/"
try:
response = requests.head(url)
if response.status_code == 200:
return package_name
except requests.RequestException as e:
logging.info(f"Error checking package {package_name}: {e}")
# If exact name doesn't work, try normalized name
normalized_name = package_name.lower().replace("_", "-")
if normalized_name != package_name:
url = f"https://pypi.org/project/{normalized_name}/"
try:
response = requests.head(url)
if response.status_code == 200:
return normalized_name
except requests.RequestException as e:
print(f"Error checking normalized package name {normalized_name}: {e}")
return None
def resolve_package_name(import_name: str, mapping: Dict[str, str]) -> Optional[str]:
"""
Resolve an import name to a PyPI package name.
Returns None if no package can be found.
"""
# Check mapping first
if import_name in mapping:
pkg_name = mapping[import_name]
if verify_package_exists(pkg_name):
return pkg_name
# Try import name directly
if verify_package_exists(import_name):
return import_name
# Try normalized version of import name
normalized_name = import_name.lower().replace("_", "-")
if verify_package_exists(normalized_name):
return normalized_name
return
def get_package_releases(package_name: str) -> List[Tuple[str, datetime.datetime]]:
"""Fetch and parse package release history from PyPI."""
url = f"https://pypi.org/project/{package_name}/#history"
try:
response = requests.get(url)
response.raise_for_status()
except requests.RequestException as e:
logging.warning(f"Error fetching release history for {package_name}: {e}")
return []
soup = BeautifulSoup(response.text, "html.parser")
releases = []
for release_div in soup.find_all("div", class_="release"):
try:
# Skip if release is yanked
if "yanked" in release_div.get("class", []):
continue
version = release_div.find("p", class_="release__version")
date = release_div.find("p", class_="release__version-date")
if not (version and date):
continue
version_text = version.text.strip()
# Skip pre-releases
if any(
pre in version_text.lower()
for pre in ["a", "b", "rc", "dev", "alpha", "beta"]
):
continue
try:
date_text = date.text.strip()
release_date = datetime.datetime.strptime(date_text, "%b %d, %Y")
releases.append((version_text, release_date))
except ValueError as e:
logger.warning(f"Error parsing date {date_text} for {package_name}: {e}")
continue
except Exception as e:
logger.warning(f"Error processing release for {package_name}: {e}")
continue
return releases
def find_compatible_version(
releases: List[Tuple[str, datetime.datetime]], file_date: datetime.datetime
) -> Optional[str]:
"""Find the newest package version that's older than the file date."""
try:
compatible_releases = [
(version, date) for version, date in releases if date <= file_date
]
if not compatible_releases:
return None
return max(compatible_releases, key=lambda x: x[1])[0]
except Exception as e:
logger.info(f"Error finding compatible version: {e}")
return None
def get_file_dates(path: str) -> Tuple[datetime.datetime, Optional[datetime.datetime]]:
"""Get filesystem modification date and last git commit date for a path."""
try:
# Get filesystem modification date
path_obj = Path(path)
mod_timestamp = path_obj.stat().st_mtime
mod_date = datetime.datetime.fromtimestamp(mod_timestamp)
# Try to get git commit date
try:
repo = git.Repo(path, search_parent_directories=True)
commits = list(repo.iter_commits(paths=path))
if commits:
git_date = datetime.datetime.fromtimestamp(commits[0].committed_date)
else:
git_date = None
except:
git_date = None
#print("Could not get latest git commit reference date.")
#print(f"Got date {mod_date} from {path}")
return mod_date, git_date
except Exception as e:
#print(f"Error getting file dates for {path}: {e}")
return datetime.datetime.now(), None
help_description = """
Guess python3 requirements.txt based on file or git commit dates,
by looking at the local installed packages and comparing with release dates
on https://PyPI.org.
WARNING!: For the file date comparison, it uses the modify date. If you made a local
change to the file, you will need to use touch to restore the old date.'
"""
def main():
parser = argparse.ArgumentParser(
formatter_class=argparse.RawTextHelpFormatter, description=help_description
)
parser.add_argument("path", help="File or directory path to analyze")
parser.add_argument(
"--output",
default="requirements_guess.txt",
help="Output file path (default: requirements_guess.txt)",
)
args = parser.parse_args()
try:
# Get all imports
imports = get_all_imports(args.path)
if not imports:
print("No imports found.")
return
# Filter out stdlib modules
stdlib_modules = get_stdlib_modules()
imports = {imp for imp in imports if imp not in stdlib_modules}
if not imports:
print("No non-stdlib imports found.")
return
# Get import to package mapping
import_mapping = get_import_to_pkg_mapping()
# Get dates
file_date, git_date = get_file_dates(args.path)
reference_date = git_date if git_date else file_date
print(f"Using reference date: {reference_date}")
# Process each import
requirements = []
unresolved_imports = []
for import_name in sorted(imports):
print(f"\nProcessing import: {import_name}")
# Try to resolve package name
package_name = resolve_package_name(import_name, import_mapping)
if not package_name:
print(
f"Warning: Could not find package for import '{import_name}' on PyPI"
)
unresolved_imports.append(import_name)
continue
print(f"Found package: {package_name}")
releases = get_package_releases(package_name)
if not releases:
print(f"Warning: Could not fetch release history for {package_name}")
continue
version = find_compatible_version(releases, reference_date)
if version:
req_line = f"{package_name}=={version} # https://pypi.org/project/{package_name}/"
requirements.append(req_line)
print(f"Found likely compatible version: {package_name}=={version}")
else:
print(f"Warning: No compatible version found for {package_name}")
# Write requirements.txt
if requirements:
with open(args.output, "w") as f:
f.write("# To use: do `python -m venv .venv` then `source .venv/bin/activate` then `pip install -r requirements.txt`")
f.write("\n".join(sorted(requirements)))
f.write("\n")
if unresolved_imports:
unresolved_imports.sort()
f.write("# Unresolved Requirements:\n")
for unresolved in unresolved_imports:
f.write(f"#{unresolved}==???")
f.write("\n")
print(f"\nWritten {len(requirements)} packages to {args.output}")
else:
print("\nNo requirements to write.")
# Report unresolved imports
if unresolved_imports:
print(
"\nWARNING: The following imports could not be resolved to PyPI packages:"
)
for imp in sorted(unresolved_imports):
print(f" - {imp}")
print(
"\nPlease manually verify these imports and add them to requirements.txt if needed."
)
except Exception as e:
print(f"Error in main execution: {e}")
sys.exit(1)
if __name__ == "__main__":
main()

4
requirements.txt Normal file
View File

@ -0,0 +1,4 @@
GitPython==3.1.43 # https://pypi.org/project/GitPython/
beautifulsoup4==4.12.3 # https://pypi.org/project/beautifulsoup4/
requests==2.32.3 # https://pypi.org/project/requests/
vermin==1.6.0 # https://pypi.org/project/vermin/

4
requirements_guess.txt Normal file
View File

@ -0,0 +1,4 @@
# To use: do `python -m venv .venv` then `source .venv/bin/activate` then `pip install -r requirements.txt`GitPython==3.1.43 # https://pypi.org/project/GitPython/
beautifulsoup4==4.12.3 # https://pypi.org/project/beautifulsoup4/
requests==2.32.3 # https://pypi.org/project/requests/
vermin==1.6.0 # https://pypi.org/project/vermin/

254
templates/index.html Normal file
View File

@ -0,0 +1,254 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Python requirements.txt Generator</title>
<link href="https://cdnjs.cloudflare.com/ajax/libs/tailwindcss/2.2.19/tailwind.min.css" rel="stylesheet">
<style>
.drop-zone {
border: 2px dashed #cbd5e0;
transition: all 0.3s ease;
}
.drop-zone.dragover {
border-color: #4299e1;
background-color: #ebf8ff;
}
.results-box {
max-height: 400px;
overflow-y: auto;
}
</style>
</head>
<body class="bg-gray-50">
<div class="min-h-screen p-6">
<div class="max-w-4xl mx-auto">
<h1 class="text-3xl font-bold text-gray-800 mb-8">Python requirements.txt Generator</h1>
<div class="bg-white rounded-lg shadow-md p-6 mb-6">
<h3 class="text-1xl font-bold text-gray-800 mb-2">About this tool</h3>
<p>
This tool is used to help you find out what original python package versions were used if you are missing a requirements.txt or it is
not included in the program's documentation. In most cases, it will get you 99% of the way there, so depreciations and behavior changes
won't bite you as hard. Take these estimations with a grain of salt.
</p>
<br>
<ul>
<li>1. Use an original unmodified file - due to JS limitations (skill issue), it cannot use the creation date and it will improperly resolve
unless there's a git repo in a .zip.
</li>
<li>2. Relative imports may not be resolvable - you will need to check this manually</li>
<li>2a. Some relative imports with common generic names (tools, utils) will incorrectly resolve to a pip package when they are actually
a relative import.
</li>
</ul>
<br>
<p>
It is up to you, the user, to double check your imports. This tool is just to get you close.
</p>
</div>
<div class="bg-white rounded-lg shadow-md p-6 mb-6">
<div id="dropZone" class="drop-zone rounded-lg p-8 text-center cursor-pointer">
<div class="text-gray-500">
<p class="text-lg mb-2">Drag and drop your Python file or ZIP archive here</p>
<p class="text-sm mb-4">(or click to select)</p>
<p class="text-xs">Maximum file size: 10MB</p>
</div>
</div>
<input type="file" id="fileInput" class="hidden" accept=".py,.zip">
</div>
<div id="resultsContainer" class="hidden bg-white rounded-lg shadow-md p-6">
<h2 class="text-xl font-semibold text-gray-800 mb-4">Analysis Results</h2>
<div id="loadingIndicator" class="hidden">
<div class="flex items-center justify-center py-4">
<div class="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-500"></div>
</div>
</div>
<div id="results" class="results-box font-mono text-sm bg-gray-50 rounded p-4 mb-4"></div>
<button id="downloadBtn" class="hidden bg-blue-500 hover:bg-blue-600 text-white font-semibold py-2 px-4 rounded">
Download requirements.txt
</button> <br><br>
<script async
src="https://js.stripe.com/v3/buy-button.js">
</script>
<stripe-buy-button
buy-button-id="buy_btn_1QZkdjF7XLGd2a5gUp7oO0Xl"
publishable-key="pk_live_51ODgd2F7XLGd2a5gWi8b4cBLoKrc2gr3nLHkvZolog1I0qcGadqsgDAhTAERs6ocR1UwebkhVsOQ7yn5YHdZ320r00dYH3oVr2"
>
</stripe-buy-button>
</div>
<div class="bg-white rounded-lg shadow-md p-6 mb-6">
<h3 class="text-1xl font-bold text-gray-800 mb-2">Privacy or whatever</h3>
<ul>
<li>1. Although processing happens server-side, we don't keep your uploaded source code. It's stored in a temporary
directory then cleaned up after. Might port this to be a WASM project to learn it, if it's possible to do so, so all processing happens
on your device and I don't risk getting rate limited by pypi.org.
</li>
<br>
<li>2. Only normal browser logs are kept in rotation to see if something is broken. You specificically are not tracked.</li>
<li>
</li>
</ul>
</div>
</div>
</div>
<script>
const dropZone = document.getElementById('dropZone');
const fileInput = document.getElementById('fileInput');
const resultsContainer = document.getElementById('resultsContainer');
const loadingIndicator = document.getElementById('loadingIndicator');
const results = document.getElementById('results');
const downloadBtn = document.getElementById('downloadBtn');
let currentResults = null;
['dragenter', 'dragover', 'dragleave', 'drop'].forEach(eventName => {
dropZone.addEventListener(eventName, preventDefaults, false);
});
function preventDefaults(e) {
e.preventDefault();
e.stopPropagation();
}
['dragenter', 'dragover'].forEach(eventName => {
dropZone.addEventListener(eventName, () => {
dropZone.classList.add('dragover');
});
});
['dragleave', 'drop'].forEach(eventName => {
dropZone.addEventListener(eventName, () => {
dropZone.classList.remove('dragover');
});
});
dropZone.addEventListener('drop', handleDrop);
dropZone.addEventListener('click', () => fileInput.click());
fileInput.addEventListener('change', handleFileSelect);
function handleDrop(e) {
const dt = e.dataTransfer;
const file = dt.files[0];
handleFile(file);
}
function handleFileSelect(e) {
const file = e.target.files[0];
handleFile(file);
}
function handleFile(file) {
if (!file) return;
const maxSize = 10 * 1024 * 1024; // 10MB
if (file.size > maxSize) {
alert('File size exceeds 10MB limit');
return;
}
if (!file.name.endsWith('.py') && !file.name.endsWith('.zip')) {
alert('Please upload a Python file (.py) or ZIP archive');
return;
}
const formData = new FormData();
formData.append('file', file);
resultsContainer.classList.remove('hidden');
loadingIndicator.classList.remove('hidden');
results.innerHTML = '';
downloadBtn.classList.add('hidden');
fetch('/analyze', {
method: 'POST',
body: formData,
headers: {
'X-Last-Modified-Date': new Date(file.lastModified).toUTCString()
}
})
.then(response => response.json())
.then(data => {
loadingIndicator.classList.add('hidden');
if (data.error) {
results.innerHTML = `<div class="text-red-500">${data.error}</div>`;
return;
}
currentResults = data;
let output = '';
if (data.interpreter_version){
console.log(data.interpreter_version)
output += `<div class="mb-4"><strong>Vermin reported min Python Version: ${data.interpreter_version}</strong><br>`
output += '</div>';
}
if (data.file_list && data.file_list.length > 0) {
output += '<div class="mb-4"><strong>File List:</strong><br>';
data.file_list.forEach(fi => {
output += `${fi}<br>`;
});
output += '</div>';
}
if (data.requirements && data.requirements.length > 0) {
output += '<div class="mb-4"><strong>Requirements:</strong><br>';
data.requirements.forEach(req => {
output += `${req}<br>`;
});
output += '</div>';
}
if (data.unresolved && data.unresolved.length > 0) {
output += '<div class="text-yellow-600"><strong>Unresolved Imports:</strong><br>';
data.unresolved.forEach(imp => {
output += `${imp}<br>`;
});
output += '</div>';
}
if (data.reference_date) {
output += `<div class="text-gray-500 mt-4">Reference Date: ${new Date(data.reference_date).toLocaleString()}</div>`;
}
results.innerHTML = output;
downloadBtn.classList.remove('hidden');
})
.catch(error => {
loadingIndicator.classList.add('hidden');
results.innerHTML = `<div class="text-red-500">Error: ${error.message}</div>`;
});
}
downloadBtn.addEventListener('click', () => {
if (!currentResults) return;
fetch('/download', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(currentResults)
})
.then(response => response.blob())
.then(blob => {
const url = window.URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = 'requirements.txt';
document.body.appendChild(a);
a.click();
window.URL.revokeObjectURL(url);
a.remove();
})
.catch(error => {
alert('Error downloading file: ' + error.message);
});
});
</script>
</body>
</html>

8
uwsgi.ini Normal file
View File

@ -0,0 +1,8 @@
[uwsgi]
module = app:app
master = true
processes = 4
socket = 0.0.0.0:5000
chmod-socket = 660
vacuum = true
die-on-term = true