Path: blob/main/shared/convert_sage_to_python.py
483 views
unlisted
#!/usr/bin/env python31"""2Convert SageMath notebooks to pure Python notebooks using cryptolab.34Reads .ipynb files from sage/ directories, applies mechanical translations,5and writes to python/ directories. Manual review is still needed for6visualization-heavy cells.7"""89import json10import re11import sys12import os131415# Standard header cell for all Python notebooks16HEADER_SOURCE = [17"# This notebook has two versions:\n",18"# Python (this file) -- runs in browser via JupyterLite, no install needed\n",19"# SageMath (../sage/) -- richer algebra system, needs local install or Codespaces\n",20"#\n",21"# Both versions cover the same material. Choose whichever works for you.\n",22"\n",23"import sys, os\n",24"sys.path.insert(0, os.path.join('..', '..', '..', 'shared'))\n",25]262728# Import lines added based on what the notebook uses29IMPORT_MAP = {30'Mod(': 'from cryptolab import Mod',31'Zmod(': 'from cryptolab import Zmod',32'Integers(': 'from cryptolab import Integers',33'gcd(': 'from cryptolab import gcd',34'euler_phi(': 'from cryptolab import euler_phi',35'factor(': 'from cryptolab import factor',36'divisors(': 'from cryptolab import divisors',37'is_prime(': 'from cryptolab import is_prime',38'power_mod(': 'from cryptolab import power_mod',39'inverse_mod(': 'from cryptolab import inverse_mod',40'primitive_root(': 'from cryptolab import primitive_root',41'discrete_log(': 'from cryptolab import discrete_log',42'CRT(': 'from cryptolab import crt',43'DiGraph(': 'from cryptolab.plot import cayley_graph',44'Poset(': 'from cryptolab.plot import subgroup_lattice',45'matrix_plot(': 'from cryptolab.plot import multiplication_heatmap',46'Graphics(': 'from cryptolab.plot import cycle_diagram, coset_coloring',47'graphics_array(': 'from cryptolab.plot import graphics_array',48}495051def detect_imports(notebook):52"""Scan all code cells to determine which imports are needed."""53all_code = ''54for cell in notebook['cells']:55if cell['cell_type'] == 'code':56all_code += ''.join(cell['source'])5758imports = set()59for trigger, import_line in IMPORT_MAP.items():60if trigger in all_code:61imports.add(import_line)6263# Group imports from cryptolab64cryptolab_names = []65plot_names = []66other_imports = []6768for imp in sorted(imports):69if imp.startswith('from cryptolab.plot'):70names = imp.replace('from cryptolab.plot import ', '').split(', ')71plot_names.extend(names)72elif imp.startswith('from cryptolab'):73names = imp.replace('from cryptolab import ', '').split(', ')74cryptolab_names.extend(names)75else:76other_imports.append(imp)7778lines = []79if cryptolab_names:80names_str = ', '.join(sorted(set(cryptolab_names)))81lines.append(f'from cryptolab import {names_str}\n')82if plot_names:83names_str = ', '.join(sorted(set(plot_names)))84lines.append(f'from cryptolab.plot import {names_str}\n')85for imp in other_imports:86lines.append(imp + '\n')8788return lines899091def translate_code(source_lines):92"""Apply mechanical SageMath -> Python translations to code cell source."""93result = []94for line in source_lines:95translated = translate_line(line)96result.append(translated)97return result9899100def translate_line(line):101"""Translate a single line of SageMath code to Python."""102original = line103104# Remove SageMath-specific type conversions105line = re.sub(r'\bInteger\(([^)]+)\)', r'int(\1)', line)106line = re.sub(r'\bZZ\(([^)]+)\)', r'int(\1)', line)107108# CRT -> crt (lowercase)109line = re.sub(r'\bCRT\(', 'crt(', line)110111# ^ -> ** for exponentiation (but not in strings or comments)112# This is tricky - only translate ^ that are not in strings/comments113line = translate_caret(line)114115# var('t') -> remove (not needed)116if re.match(r"\s*var\s*\(\s*['\"]t['\"]\s*\)\s*$", line):117return '# (SageMath variable declaration removed)\n'118119# SageMath math functions -> Python math120line = re.sub(r'\bcos\(', 'math.cos(', line)121line = re.sub(r'\bsin\(', 'math.sin(', line)122line = re.sub(r'\bsqrt\(', 'math.sqrt(', line)123line = re.sub(r'\babs\(', 'abs(', line)124# pi -> math.pi (but not in strings)125line = re.sub(r'\bpi\b(?![\'"])', 'math.pi', line)126127# If we added math references, we should note the import is needed128# (handled by adding 'import math' in imports)129130return line131132133def translate_caret(line):134"""Replace ^ with ** outside of strings and comments."""135# Skip if line is a comment136stripped = line.lstrip()137if stripped.startswith('#'):138return line139140result = []141in_string = False142string_char = None143i = 0144while i < len(line):145c = line[i]146if in_string:147result.append(c)148if c == '\\' and i + 1 < len(line):149result.append(line[i + 1])150i += 2151continue152if c == string_char:153in_string = False154i += 1155else:156if c in ('"', "'"):157# Check for triple quotes158if line[i:i+3] in ('"""', "'''"):159result.append(line[i:i+3])160in_string = True161string_char = line[i:i+3]162i += 3163continue164in_string = True165string_char = c166result.append(c)167i += 1168elif c == '#':169# Rest of line is comment170result.append(line[i:])171break172elif c == '^':173result.append('**')174i += 1175else:176result.append(c)177i += 1178return ''.join(result)179180181def needs_math_import(source_lines):182"""Check if any line uses math functions."""183code = ''.join(source_lines)184return any(f in code for f in ['math.cos', 'math.sin', 'math.sqrt', 'math.pi'])185186187def convert_notebook(input_path, output_path):188"""Convert a SageMath notebook to a Python notebook."""189with open(input_path, 'r') as f:190nb = json.load(f)191192# Detect needed imports193import_lines = detect_imports(nb)194195# Check if math is needed after translation196all_translated = []197for cell in nb['cells']:198if cell['cell_type'] == 'code':199translated = translate_code(cell['source'])200all_translated.extend(translated)201202if needs_math_import(all_translated):203import_lines.insert(0, 'import math\n')204205# Build header cell206header_cell = {207'cell_type': 'code',208'execution_count': None,209'metadata': {},210'outputs': [],211'source': HEADER_SOURCE + ['\n'] + import_lines212}213214# Convert cells215new_cells = [header_cell]216for cell in nb['cells']:217if cell['cell_type'] == 'markdown':218new_cell = {219'cell_type': 'markdown',220'metadata': {},221'source': translate_markdown(cell['source'])222}223new_cells.append(new_cell)224elif cell['cell_type'] == 'code':225translated_source = translate_code(cell['source'])226new_cell = {227'cell_type': 'code',228'execution_count': None,229'metadata': {},230'outputs': [],231'source': translated_source232}233new_cells.append(new_cell)234235# Build output notebook with Python 3 kernel236output_nb = {237'nbformat': 4,238'nbformat_minor': 5,239'metadata': {240'kernelspec': {241'display_name': 'Python 3',242'language': 'python',243'name': 'python3'244},245'language_info': {246'name': 'python',247'version': '3.12.0'248}249},250'cells': new_cells251}252253os.makedirs(os.path.dirname(output_path), exist_ok=True)254with open(output_path, 'w') as f:255json.dump(output_nb, f, indent=1)256257return len(new_cells)258259260def translate_markdown(source_lines):261"""Translate markdown cells. Mostly verbatim but update SageMath references."""262result = []263for line in source_lines:264# Update references to SageMath-specific tools265line = line.replace("SageMath's `Mod()`", "`Mod()` from cryptolab")266line = line.replace("SageMath's `Zmod(n)`", "`Zmod(n)` from cryptolab")267line = line.replace('in SageMath', 'in Python')268line = line.replace('SageMath computes this with', 'We can compute this with')269line = line.replace('SageMath can also compute', 'We can also compute')270line = line.replace('SageMath confirms:', 'Computed:')271line = line.replace('Use `divmod()` and `Mod()` in SageMath',272'Use `divmod()` and `Mod()` from cryptolab')273result.append(line)274return result275276277def main():278base = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))279mod01 = os.path.join(base, 'foundations', '01-modular-arithmetic-groups')280281conversions = [282('sage/01a-integers-and-division.ipynb', 'python/01a-integers-and-division.ipynb'),283('sage/01b-modular-arithmetic.ipynb', 'python/01b-modular-arithmetic.ipynb'),284('sage/01c-groups-first-look.ipynb', 'python/01c-groups-first-look.ipynb'),285('sage/01d-cyclic-groups-generators.ipynb', 'python/01d-cyclic-groups-generators.ipynb'),286('sage/01e-subgroups-lagrange.ipynb', 'python/01e-subgroups-lagrange.ipynb'),287('sage/01f-group-visualization.ipynb', 'python/01f-group-visualization.ipynb'),288('break/weak-generator-attack.ipynb', 'python/break-weak-generator-attack.ipynb'),289('break/smooth-order-attack.ipynb', 'python/break-smooth-order-attack.ipynb'),290('connect/dh-parameter-selection.ipynb', 'python/connect-dh-parameter-selection.ipynb'),291('connect/rsa-key-generation.ipynb', 'python/connect-rsa-key-generation.ipynb'),292]293294for sage_rel, python_rel in conversions:295sage_path = os.path.join(mod01, sage_rel)296python_path = os.path.join(mod01, python_rel)297if os.path.exists(sage_path):298n_cells = convert_notebook(sage_path, python_path)299print(f' {sage_rel} -> {python_rel} ({n_cells} cells)')300else:301print(f' SKIP {sage_rel} (not found)')302303304if __name__ == '__main__':305main()306307308