Sunday, 31 March 2013

Renaming a Drupal module


Sometimes you find the name you gave a module is not that great. Maybe it's just not by the conventions, or very similar to a contrib or a feature. Maybe it's just not reflecting precisely the purpose of the module. Or a feature. And you have to rename it. But renaming a Drupal module is not easy.


Why? First you have the manual labour. Renaming all the files, renaming the functions, maybe the comments, includes, strings, etc. It's a lot of replacement. If you're leveraging a VCS you ought to keep the history clean, meaning applying a rename action instead of a delete-create. That's quite some action on the code.

Next you have the Drupal registry. In the system table all paths are stored. When you rename a module it's gonna be disabled by default. However it's not as simple as re-enabling it. You have your schema and applied update hooks - which can mess up a lot. So you have to rewrite the system record.

And that's just the beginning. Now you have the cache. Some is pretty easy flush, drush takes care of it. However some cache I've found irrationally hard to erase. Like cached class files and such.

But you know - not all modules are overly-attached. So I was looking for tools or modules that could do at least the manual work for me. Unfortunately found nothing. So I wrote a little Python script that does it.

We need an opener that could handle 3 arguments: the original name, the new name, and the path to the current module:

import sys

if __name__ == '__main__':
  if len(sys.argv) < 4:
    sys.exit(1)


It's always better to separate the logic so let's create a simple class that takes care of the actual job:

import os
import shutil
import re
from string import capitalize as cap

class ModuleRenamer(object):

  def __init__(self, from_name, to_name, path_to_module = './'):
    self.from_name, self.to_name, self.path_to_module = from_name, to_name, path_to_module


First thing first let's check those arguments:

  def validate_args(self):
    valid_name = '^[a-z][a-z0-9_]*$'
    return re.match(valid_name, self.from_name) and \
           re.match(valid_name, self.to_name) and \
           os.path.exists(self.path_to_module)


And add it to the runner:

from module_renamer import ModuleRenamer

if __name__ == '__main__':
  # ...
  renamer = ModuleRenamer(sys.argv[1], sys.argv[2], sys.argv[3])

  if not renamer.validate_args():
    sys.exit(1)


Now let's go and and write the replacer part. The action involves 2 main elements: copying the whole directory tree and renaming all folders / files where necessary. Then you have to take care of the file contents. The directory tree copy is easy, first we just copy the main folder and then rename each file:

  def rename(self):
    new_module_dir = os.path.join(self.path_to_module, '..', self.to_name)

    if __debug__ and os.path.exists(new_module_dir):
      shutil.rmtree(new_module_dir)

    shutil.copytree(self.path_to_module, new_module_dir)

    for dirpath, dirnames, filenames in os.walk(new_module_dir):
      for filename in filenames:
        new_filename = re.sub(self.from_name, self.to_name, filename)
        file_path = os.path.join(dirpath, filename)
        new_file_path = os.path.join(dirpath, new_filename)
        shutil.move(file_path, new_file_path)

        if new_filename.lower().endswith(('.module', '.info', '.php', '.inc', '.install', '.test', '.theme')):
          self.rename_strings(new_file_path)


The next clue can be found in the last line - where we handle the content each time the file is a valid Drupal code file:

  def rename_strings(self, filename):
    file = open(filename, 'r')
    content = file.read()
    content = self.replace_string(self.from_name, self.to_name, content)
    content = self.replace_string(cap(self.from_name), cap(self.to_name), content)
    file.close()

    file = open(filename, 'w')
    file.write(content)
    file.close()


We extracted here the replacement call - in order to add a simple logging system. It's kinda important to see in console what was replaced in the code:

  def replace_string(self, pattern, to, text):
    matches = re.findall('^.*' + pattern + '.*$', text, flags = re.M)
    if matches:
      for match in matches:
        print('\033[37m[sub]\033[0m ' + re.sub(pattern, "\033[31m" + to + "\033[0m", match))
    text = re.sub(pattern, to, text)
    return text


And we're done. We have run the replacer in the runner file:

renamer.rename()


Here you are a little sample of a run on a small module:


You can track what files are renamed, what code snippets were replaced and some clue what to rename in the database. You can find and download the code on GitHub, of course.

Warning: do not use it without verifying each the whole change carefully. It does what it does but it's far from being a perfect module renaming tool. Modules are containing semantical information and lower level code connections to other modules. Also the algorithm is too simple here.
So any suggestion to make it better would be very much welcomed.

---

Peter

2 comments:

  1. Nice stuff! Regarding updating the system table, check this little drush tool: http://drupal.org/project/registry_rebuild

    ReplyDelete
  2. Hey Erno, thank you so much! This looks like a neat tool! I just realized it's equal to electroshocking Drupal - but for the good cause. Thanks!

    ReplyDelete