Migration to Python 3

Here’s the script, sorry for the length but I don’t have any host to use right now:

# ============
# panda_to_py3
# ============

__version__ = "0.3"

import sys
import os
import shutil
from datetime import datetime
from lib2to3.refactor import *
from lib2to3.main import *

# Options.
LOGGING = True
PRINTING = True
BACK_UP = True
if "--no-log" in sys.argv: LOGGING = False
if "--no-backup" in sys.argv: BACK_UP = False
if "--no-print" in sys.argv: PRINTING = False


class Custom_Fixes:

    def panda_fix_builtin_ref(line):
        """Fix refs to __builtin__ that py2to3 misses."""
        fixed_line = line.replace("__builtin__", "builtins")
        return fixed_line

    # Map str patterns to custom fix methods.
    fix_map = {"__builtin__":panda_fix_builtin_ref,}


class Panda3D_Refactoring_Tool(StdoutRefactoringTool):

    def __init__(self, src_dir):
        """Custom Panda3d py2to3 refactoring tool."""
        fixers = sorted(refactor.get_fixers_from_package("lib2to3.fixes"))
        StdoutRefactoringTool.__init__(self, fixers, [],
                                       [], True, None,
                                       input_base_dir=src_dir)
                                       
    def refactor_panda_file(self, src_file):
        """Perform py2to3 conversion on panda file "src_file"."""
        global file_count, line_count, lines_fixed
        _file_lines_fixed = lines_fixed
        _file_lines_added = lines_added
        
        # Extract lines from src_file for stats and testing.
        with open(src_file) as file:
            lines = file.readlines()
        line_count += len(lines)
        file_count += 1
        
        # Refactor (test for indents by line.)
        for line in lines:
            if line == "\n": continue
            if line.startswith(" ") or line.startswith("\t"):
                # Some files begin indented; use workaround.
                self.handle_parse_error(src_file, "in")
                self.refactor_file(src_file, write=True)
                self.handle_parse_error(src_file, "out")
            else:
                self.refactor_file(src_file, write=True)
            break

        # Print file refactor info.
        if PRINTING:
            file_name = os.path.split(src_file)[-1]
            f_lines_fixed = lines_fixed - _file_lines_fixed
            f_lines_added = lines_added - _file_lines_added
            if f_lines_fixed > 1: f_str = "lines fixed"
            else: f_str = "line fixed"
            if f_lines_added > 1: a_str = "lines added"
            else: a_str = "line added"
            if f_lines_added:
                f_line_str = "({} {}, {} {})".format(f_lines_fixed, f_str,
                                                     f_lines_added, a_str)
            else:
                f_line_str = "({} lines fixed)".format(f_lines_fixed)
            print("  {:<30}{}".format(file_name, f_line_str))
        
    def handle_parse_error(self, file_path, mode):
        """Allow py2to3 to handle files in direct/extensions folder that
        begin with indents by putting a temporary "class Temp:" statement
        at the top of the file to fool the parser. Remove it after parse."""
        with open(file_path, "r") as file:
            lines = file.readlines()
            if mode == "in":
                lines.insert(0, "class Temp:")  # Temp class statement.
            elif mode == "out":
                lines.pop(0)  # Remove temp class statement in "out" mode.
                lines.insert(0, "\n")
        with open(file_path, "w") as file:
            file.writelines(lines)
                                       
    def print_output(self, old, new, filename, equal):
        """Override method in StdoutRefactoringTool so that we can
        set up custom log output as well as perform a second layer
        of custom fixes to cover things that py2to3 misses."""
        global lines_fixed, lines_added, custom_fixes
        
        if LOGGING:
            # Start new set of log lines for each file.
            dec_str = "".zfill(len(filename)).replace("0", "-")
            log_lines.extend(["".join(["\n", dec_str]),
                              filename.replace(".\\", ""),
                              "".join([dec_str, "\n"])])
        # Handle custom fixes.
        new_lines = new.split("\n")
        fix_list = list(Custom_Fixes.fix_map.keys())
        _new_lines = []
        for line in new_lines:
            for fix in fix_list:
                if fix in line:
                    panda_fix = Custom_Fixes.fix_map[fix]
                    line = panda_fix(line)
                    custom_fixes += 1
                    break
            _new_lines.append(line)
        new = "\n".join(_new_lines)
            
        # Get "diff_lines" (from py2to3.main) and set line tracking vars.
        diff_lines = diff_texts(old, new, filename)
        line_no = 0
        fixed_line_no = 0
        fixed_lines_offset = 0
        
        # "diff_lines" lists subtractions and additions in series
        # ie: (-,-,-,-) then (+,+,+,+). Increment 'stack' for every "-"
        # line and decrement it for every "+" line.
        stack = 0
        _prev_stack = 0  # helps track line removals.
        
        # Process diff lines to find and apply required panda fixes
        # Generate a log if LOGGING is True.
        for line in diff_lines:
            if line.startswith("+++") or line.startswith("---"):
                continue
                
            # Use sentinel lines to reset line counting vars.
            if line.startswith("@@"):
                line_list = line.split(" ")
                line_no = int(line_list[1].split(",")[0].replace("-", ""))
                fixed_line_no = line_no + fixed_lines_offset
                continue
                
            # Log removals.
            if line.startswith("-") and str(line).strip() != "-":
                stack += 1
                lines_fixed += 1
                if LOGGING: # Update log_lines.
                    log_str = "{:>6}: {}".format(line_no, line)
                    log_lines.append(log_str)
                line_no += 1
                continue
                
            # Log additions.
            elif line.startswith("+") and str(line).strip() != "+":
                _lines_added = 0
                if stack > 0:
                    fixed_line = line
                else:
                    # A new line when stack is at zero means this is a line
                    # that was simply added by py2to3, usually an import.
                    _lines_added += 1
                    line_no += 1
                    fixed_line_no += 1
                    fixed_line = line
                    
                # Update stats and log.
                if stack: stack -= 1
                if LOGGING:
                    log_str = "{:>6}: {}".format(fixed_line_no, fixed_line)
                    if stack == 0: log_str = "{}\n".format(log_str)
                    log_lines.append(log_str)
                fixed_line_no += 1
                
                # Handle rare case where py2to3 removes a line .
                if _prev_stack:
                    if LOGGING:
                        log_lines[-1] = "{}\n".format(log_lines[-1])
                    
                fixed_lines_offset += _lines_added
                lines_added += _lines_added
                continue
            
            # Line stats.
            line_no += 1
            fixed_line_no += 1
            _prev_stack = stack
            
        # Rewrite files.
        self.apply_fixes_to_file(filename, new)
        
    def apply_fixes_to_file(self, file_path, new):
        """Perform actual update of python file."""
        new_lines = []
        lines = new.splitlines(keepends=True)
        for i, line in enumerate(lines):
                new_lines.append(line)
        # Write new file.
        with open(file_path, "w") as file:
            file.writelines(new_lines)
    
    def write_file(self, new_text, filename, old_text, encoding=None):
        """Overriden from StdoutRT to prevent overwrite of our changes."""
        self.wrote = True

# Utility objects.
class Timer:
    def __enter__(self):
        self.start_dt = datetime.now()
        return self
    def __exit__(self, *e_info):
        if e_info[0] != None: print(e_info)
        end_dt = datetime.now()
        delta = end_dt - self.start_dt
        self.time = "{}.{}".format(delta.seconds, round(delta.microseconds, 3))

class Change_Logger:
    def __init__(self, dir):
        """Generate a "changes.txt" file for this dir if LOGGING == True."""
        self.dir = dir
    def __enter__(self):
        self._ref_dir_count = dir_count
        self._ref_file_count = file_count
        self._ref_line_count = line_count
        self._ref_lines_fixed = lines_fixed
        self._ref_lines_added = lines_added
        return self
    def __exit__(self, *e_info):
        if not LOGGING: return
        d_dir_count = dir_count - self._ref_dir_count
        d_file_count = file_count - self._ref_file_count
        d_line_count = line_count - self._ref_line_count
        d_lines_fixed = lines_fixed - self._ref_lines_fixed
        d_lines_added = lines_added - self._ref_lines_added
        with open(os.path.join(self.dir, "changes.txt"), "w") as log:
            date_str = datetime.now().strftime("%a %b %d, %Y - %H:%M")
            log.write("================\n")
            log.write("Panda to Python3 - {}\n".format(self.dir))
            log.write("================\n\n")
            log.write("date:         {}\n\n".format(date_str))
            log.write("dirs:         {}\n".format(d_dir_count))
            log.write("files:        {}\n".format(d_file_count))
            log.write("lines:        {}\n".format(d_line_count))
            log.write("lines fixed:  {}\n".format(d_lines_fixed))
            log.write("lines added:  {}\n\n\n".format(d_lines_added))
            log.write("'-' = old line\n'+' = py2to3 fix\n'*' = panda fix\n\n")
            for line in log_lines:
                line = "".join([line, "\n"])
                log.write(line)

# -----------------------------------
# Refactor panda.py files in SRC_DIRS
#------------------------------------

# Source directories for files to be refactored.
src_dir_list = ["direct", "pandac", "samples"]
SRC_DIRS = []
for s_dir in src_dir_list:
    src_dir = os.path.join(".", s_dir)
    SRC_DIRS.append(src_dir)
    if BACK_UP:
        # Back up src dirs.
        if PRINTING: print("Backing up: {}".format(s_dir))
        copy_dir = os.path.join(".", "_backup", s_dir)
        shutil.copytree(src_dir, copy_dir)   

# Refactoring algorithm.
dir_count, file_count, line_count = 0, 0, 0
lines_fixed, lines_added, custom_fixes = 0, 0, 0
with Timer() as timer:
    for src_dir in SRC_DIRS:
        for root, dirs, files in os.walk(src_dir):
            if PRINTING: print("".join(["\n", root.replace(".\\", "")]))
            with Change_Logger(root):
                prt = Panda3D_Refactoring_Tool(root)
                if LOGGING: log_lines = []
                for file in files:                    ## maybe allow __?
                    if file.endswith(".py") and not file.startswith("__"):  
                        src_file = os.path.join(root, file)
                        prt.refactor_panda_file(src_file)
                        pass
            dir_count += 1

# Finally refactor "panda3d.py" in main panda3d installation folder.
if PRINTING: print();print("<root dir>")
with Change_Logger("."):
    prt = Panda3D_Refactoring_Tool(".")
    if LOGGING: log_lines = []
    file_path = os.path.join(".", "panda3d.py")
    if BACK_UP:
        print("Backing up: panda3d.py")
        copy_path = os.path.join(".", "_backup")
        shutil.copy2(file_path, copy_path)  # backup panda3d.py.
    prt.refactor_panda_file(file_path)

if PRINTING: 
    # Print totals.
    print();print()
    print("panda_to_py3: {} seconds\n".format(timer.time))
    print("dirs:         {}".format(dir_count))
    print("files:        {}".format(file_count))
    print("lines:        {}".format(line_count))
    print("lines fixed:  {}".format(lines_fixed))
    print("lines added:  {}".format(lines_added))
    print("custom fixes: {}".format(custom_fixes))

Just run it as a file in the main panda installation directory using python 3. It prints the results for each file and creates a change log for each directory so if there’s any issues with the resulting code the exact change that caused it can be quickly found. It also backs up all the files and puts them in a dir called “_backup” before changing them. These features are really only for the development phase and can be turned off with various options for the release version.

Right now, I don’t expect that it would produce working results because there are numerous things that the py2to3 script misses. There’s a “Custom_Fixes” object for creating panda specific fixes for these cases, but I wasn’t able to get very far in my testing before encountering the memory error so I was only able to create a fix for one of these (py2to3 doesn’t catch references to the old builitin module if they are in quotations). I would anticipate there being similar cases along the way.

I’ve set it up so it’s fairly straightforward to add more custom fixes so either you can do this yourself if you encounter further problems or I can do it when there’s a 32-bit build ready that works with python 3. Also, obviously re-write or remove any parts you want for the final release version. Hopefully this helps; let me know if you have any issues.