🐖

requirements.txtを作るだけのアプリケーションを作った

2022/02/17に公開

demo

https://github.com/ogty/requirements.txt-generator

https://reqgene.vercel.app/

実装機能

  • フォルダ検索
  • フォルダの詳細表示
  • 複数のフォルダ選択
  • 言語選択
  • バージョンの書き込み
  • パッケージの選択

requirements.txt とは

requirements.txtは、プロジェクトを他のユーザーと共有したり、環境を復元したりするために、Python で使用されるテキストファイルです。このファイルでは、プロジェクトに必要な外部パッケージを指定します。

requirements.txt
matplotlib==3.5.1
numpy==1.22.0
pandas==1.3.5

パッケージリストをrequirements.txtに記録し、次のいずれかのコマンドを実行してパッケージをインストールします。オプションは-r:-requirement <file>です。

$ pip3 install -r requirements.txt
$ python3 -m pip install -r requirements.txt

パッケージをrequirements.txtに記録するときにバージョンを指定することもできます。

メソッド
等しい Django==3.0.3
以上 Django>=3.0.3
互換性あり Django~=3.0.3

現在の環境で外部パッケージのバージョンを表示するには、pipfreezeコマンドを実行します。また、requirements.txtに書き込むには、以下のコマンドを実行します。

$ pip freeze > requirements.txt

requirements.txtは Julia にも同じように適応できます。Julia では、REPL で]statusと入力して実行することにより、現在インストールされている外部パッケージとそのバージョンを確認できます。

REPL
$julia
julia > # press ]
(@v1.x) pkg> status

または、プログラムで Julia ファイルを実行します。

status.jl
using Pkg; Pkg.status()

Julia は、バージョンの表記が Python と異なります。バージョンの表記は、==ではなく@で表されます。

requirements.txt
CSV@v0.8.5
DataFrames@v0.22.7
Distributions@v0.24.18
Flux@v0.12.8
Genie@v3.0.0
IJulia@v1.23.2
Lathe@v0.1.6
Plots@v1.23.6

Julia でrequirements.txtを使用してパッケージをインストールするには、以下のファイルを実行します。

install.jl
using Pkg; Pkg.add(open(f->f.readlines(f), "requirements.txt"))

モジュールを抽出するクラスの作成

Python, Julia で共通のモジュール抽出を行うメソッドの作成

ソースコードの形式は Python、Julia とそれぞれの Jupyter Notebook の 4 つがあるので 4 つのメソッドを実装します。Python と Julia はモジュールを読み込む部分がかなり似ているので、同じ関数で処理をすることにします。

class ModuleExtractor:
    def common(self, source: str, ipynb: bool = False) -> Tuple[Set[str], List[str]]:
        called_function_name = str(inspect.stack()[1].function)

        if ipynb:
            source_list = []
            ipynb_data: object = json.loads(source)
            for cell in ipynb_data["cells"]:
                source_list += cell["source"]
            source = "".join(source_list)

        language = "python" if "python" in called_function_name else "julia"
        prefixes: list = settings.CONFIG["languages"][language][1]
        embedded: list = settings.CONFIG["languages"][language][2]

        process = [f"x.startswith('{prefix}')" for prefix in prefixes]
        process_word = " or ".join(process)

        splited_source = source.split("\n")
        line_with_module = [x for x in splited_source if eval(process_word)]
        modules = list(map(lambda m: m.split()[1], line_with_module))
        modules = list(map(lambda m: m.replace(":", "").replace(";", ""), modules))
        result = set(map(lambda m: m.split(".")[0] if not m.startswith(".") else "", modules))

        return (result, embedded)

1 行目では、common関数がどの関数から呼び出されたのか、その関数名を取得しています。これには標準ライブラリのinspectを使います。

called_function_name = str(inspect.stack()[1].function)

以下の部分は、ファイルが Jupyter Notebook だった場合に行う処理です。

if ipynb:
    source_list = []
    ipynb_data: object = json.loads(source)
    for cell in ipynb_data["cells"]:
        source_list += cell["source"]
    source = "".join(source_list)

.ipynbファイルは読み込むと json 形式になっていて、print('Hello, Wrold!')を実行するだけのファイルの場合は以下のようになっています。

{
  "cells": [
    {
      "cell_type": "code",
      "execution_count": null,
      "metadata": {},
      "outputs": [],
      "source": ["print('Hello, Wrold!')"]
    }
  ],
  "metadata": {
    "language_info": {
      "name": "python"
    },
    "orig_nbformat": 4
  },
  "nbformat": 4,
  "nbformat_minor": 2
}

cellssourceでソースコードを取得できます。これをsource_listという変数に格納していき、最終的に文字列に変換しています。これで Jupyter Notebook の場合とそうでない場合の違いがなくなりました。

次のコードはモジュールを読み込むための接頭辞と標準ライブラリを取得する部分になります。

language = "python" if "python" in called_function_name else "julia"
prefixes: list = settings.CONFIG["languages"][language][1]
embedded: list = settings.CONFIG["languages"][language][2]

まずは三項演算子を使って、言語を Python か Julia に分けます。
そして、設定が書かれたconfig.jsonファイルを読み込んだsettings.pyCONFIGから、接頭辞と標準ライブラリを取得します。

settings.py
import os


TOP_DIR = os.path.dirname(__file__)
STATIC_DIR = os.path.join(TOP_DIR, "static")

with open(os.path.join(STATIC_DIR, "config.json"), "r") as config_file:
    CONFIG = json.load(config_file)

CONFIGは、以下のようになっています。

config.json
{
    "languages": {
    "python": [
        "py",
        [
            "import",
            "from"
        ],
        [
            "__future__",
            "..."
        ]
    ],
    "python-ipynb": [
        "ipynb"
    ],
    "julia": [
        "jl",
        [
            "import",
            "using"
        ],
        [
            "Base",
            "..."
        ]
    ],
    "julia-ipynb": [
        "ipynb"
    ]
}

次にモジュールを取得する処理を書いていきます。

process = [f"x.startswith('{prefix}')" for prefix in prefixes]
process_word = " or ".join(process)

splited_source = source.split("\n")
line_with_module = [x for x in splited_source if eval(process_word)]
modules = list(map(lambda m: m.split()[1], line_with_module))
modules = list(map(lambda m: m.replace(":", "").replace(";", ""), modules))
result = set(map(lambda m: m.split(".")[0] if not m.startswith(".") else "", modules))

return (result, embedded)

まずは先ほどCONFIGから読み込んだprefixesを接頭辞をorで繋げます。Python と Julia で接頭辞が異なるので、evalで処理します。
ソースコードを改行で分割し、モジュールを読み込んでいる行を変数に格納します。さらに、モジュールを読み込んでいる行を空白で分割し、その 1 番目のインデックスを取得します。これはimport timeとなっていた場合のtimeになります。ここで Julia のモジュール読み込み用に:;を削除します。相対パスで読み込んでいる場合を考慮して、.でさらに分割すると、その最初のインデックスがモジュールであると考えられるので、これらを集合にしてモジュールを一意に扱います。

最後に、抽出したモジュールの集合と標準ライブラリを、呼び出された関数に返します。

commonメソッドを呼び出し標準ライブラリを削除する各形式のメソッドの作成

ここからは、先ほどのcommonメソッドを呼び出す、4 つのメソッドを実装します。

  • Python
  • Python(ipynb)
  • Julia
  • Julia(ipynb)

どのメソッドでも処理内容に変更はなく、モジュール抽出はcommonで行ったので、ここではcommonの返値のモジュールから標準ライブラリを削除する処理を行います。filter関数を使って標準ライブラリではないものを、新しい集合にして返しています。

  def python(self, source: str) -> Set[str]:
      result, embedded_modules = self.common(source)
      filtered_result = set(filter(lambda m: False if m in embedded_modules else m, result))
      return filtered_result

  def pythonipynb(self, ipynb_data: str) -> Set[str]:
      result, embedded_modules = self.common(ipynb_data, ipynb=True)
      filtered_result = set(filter(lambda m: False if m in embedded_modules else m, result))
      return filtered_result

  def julia(self, source: str) -> Set[str]:
      result, embedded_modules = self.common(source)
      filtered_result = set(filter(lambda m: False if m in embedded_modules else m, replaced_result))
      return filtered_result

  def juliaipynb(self, ipynb_data: str) -> Set[str]:
      result, embedded_modules = self.common(ipynb_data, ipynb=True)
      filtered_result = set(filter(lambda m: False if m in embedded_modules else m, replaced_result))
      return filtered_result

これでモジュール抽出を行うクラスを作成することができました。

requirements.txt を作成するクラスの作成

ここからは requirements.txt を作成するための処理に加えて、アプリケーションの機能に必要な「詳細表示」と「パッケージ選択」を行うためのメソッドも作成します。具体的に以下を作成します。

  • 選択されたフォルダから、その下にあるすべてのファイルとフォルダを取得するクラス
  • バージョンを取得するメソッド
  • バージョンが書かれたモジュールを返すメソッド
  • requirements.txt を作成するメソッド
  • 詳細情報を取得するメソッド

選択されたフォルダから、その下にあるすべてのファイルとフォルダを取得するクラスの作成

まず初めに、後ほど作成するRequirementsGeneratorクラスが継承するOperateクラスの作成です。このOperateは、選択されたフォルダから、その下にあるすべてのファイルとフォルダを取得するためのメソッドを有しています。

class Operate:

    def get_directories(self, path: str) -> None:
        parent: list = os.listdir(path)
        directories = [f for f in parent if os.path.isdir(os.path.join(path, f))]

        for dir in directories:
            dir_full_path = os.path.join(path, dir)
            self.all_directory.append(dir_full_path)
            self.get_directories(dir_full_path)

    def get_files(self, selected_lang: str) -> None:
        if "ipynb" in selected_lang:
            ipynb_index = selected_lang.find("ipynb")
            selected_lang = f"{selected_lang[:ipynb_index]}-{selected_lang[ipynb_index:]}"

        for dir in self.all_directory:
            parent: list = os.listdir(dir)
            files = [f for f in parent if os.path.isfile(os.path.join(dir, f))]
            filtered_files_path = list(filter(lambda path: path.endswith(settings.CONFIG["languages"][selected_lang][0]), files))
            file_full_path = list(map(lambda path: os.path.join(dir, path), filtered_files_path))
            self.all_file += file_full_path

get_directoriesメソッドは、引数に選択されたフォルダの絶対パスを取得します。これをos.listdir()に渡すことで、選択されたフォルダパスの直下にあるフォルダを、リストで取得することができます。取得したリストには隠しファイルが含まれることがあるのでos.path.isdirを使ってフォルダであるもののみを取得します。ここまでで取得できたリストに含まれる文字列は、選択されたフォルダパスからの相対パスなのでos.path.join()を使って、絶対パスにします。そしてこのget_directoriesを再帰的に呼び出すことで、フォルダの下にあるフォルダの情報も、全て取得することができます。

  def get_directories(self, path: str) -> None:
      parent: list = os.listdir(path)
      directories = [f for f in parent if os.path.isdir(os.path.join(path, f))]

      for dir in directories:
          dir_full_path = os.path.join(path, dir)
          self.all_directory.append(dir_full_path)
          self.get_directories(dir_full_path)

次にget_filesメソッドを作成します。ここでは先ほどのget_directoriesメソッドを実行した上で得られる、全てのフォルダの中に含まれる、全てのファイルを取得します。しかし、選択された言語の拡張子と一致するファイルのみの取得になります。

def get_files(self, selected_lang: str) -> None:
    if "ipynb" in selected_lang:
        ipynb_index = selected_lang.find("ipynb")
        selected_lang = f"{selected_lang[:ipynb_index]}-{selected_lang[ipynb_index:]}"

    for dir in self.all_directory:
        parent: list = os.listdir(dir)
        files = [f for f in parent if os.path.isfile(os.path.join(dir, f))]
        filtered_files_path = list(filter(lambda path: path.endswith(settings.CONFIG["languages"][selected_lang][0]), files))
        file_full_path = list(map(lambda path: os.path.join(dir, path), filtered_files_path))
        self.all_file += file_full_path

初めの 3 行は、アプリケーションに表示させる言語選択の文字が、Jupyter Notebook の場合「Python(ipynb)」としていて、そのvalueが「python-ipynb」となっているので、config.jsonファイルと合わせるためにフォーマットを揃えています。次のforの中ではget_directoriesと同じように、全てのフォルダ内に含まれるファイルを取得しています。

RequirementsGenerator クラスの作成

class RequirementsGenerator(Operate):

    def __init__(self, path: str = None, lang: str = None, version: bool = False) -> None:
        self.path = "" if path is None else path
        self.lang = "" if lang is None else lang
        self.all_file = []
        self.all_directory = [path]

        if version:
            if "python" in self.lang:
                stdout_result_splited = self.command_runner(["pip3", "freeze"])

                self.installed_modules = [x for x in stdout_result_splited if "==" in x]
                self.version_split_word = "=="
                self.module_match_process_word = "module.replace('_', '-') == module_name.lower()"

            elif "julia" in self.lang:
                stdout_result_splited = self.command_runner(["julia", "-e", "using Pkg; Pkg.status()"])
                installed_packages = list(map(lambda x: x.lstrip("  "), stdout_result_splited))
                installed_packages.remove("")

                self.installed_modules = ["@".join(package_info.split(" ")[1:]) for package_info in installed_packages[1:]]
                self.version_split_word = "@"
                self.module_match_process_word = "module == module_name"

    def command_runner(self, command: List[str]) -> List[str]:
        stdout_result = subprocess.run(command, capture_output=True)
        stdout_result_splited = stdout_result.stdout.decode("utf-8").split("\n")
        return stdout_result_splited

    def confirm(self) -> List[str]:
        self.get_directories(self.path)
        self.get_files(self.lang)

        module_extractor = ModuleExtractor()
        modules_for_return = set()

        for file_path in self.all_file:
            with open(file_path, "r", encoding="utf-8") as file:
                file_contents = file.read()

            modules_for_return = modules_for_return.union(getattr(module_extractor, self.lang)(file_contents))

        if hasattr(self, "installed_modules"):
            tmp_modules = set()
            matched_modules = set()

            for module in modules_for_return:
                for installed_module in self.installed_modules:
                    module_name = installed_module.split(self.version_split_word)[0] # Note: Used in eval

                    if eval(self.module_match_process_word):
                        matched_modules.add(module)
                        tmp_modules.add(installed_module)
                    else:
                        tmp_modules.add(module)

            module_list = list(tmp_modules)
            for matched_module in matched_modules:
                try:
                    module_list.remove(matched_module)
                except ValueError as ex:
                    print(f"Error: {ex}")
        else:
            module_list = list(modules_for_return)

        module_list.sort()
        return module_list

    def generate(self, module_list: List[str]) -> None:
        module_list = list(map(lambda x: x.replace("\n", ""), module_list))
        with open(os.path.join(self.path, "requirements.txt"), "w", encoding="utf-8") as f:
            modules = "\n".join(module_list)
            f.write(modules)

    def detail(self, directories: List[str]) -> Dict[str, Dict[str, int]]:
        result = {}

        for dir in directories:
            supported_extension = {"py": 0, "jl": 0, "go": 0, "ipynb": 0, "other": 0}

            if self.all_directory.count(""):
                self.all_directory.remove("")

            self.all_directory.append(dir)
            self.get_directories(dir)

            for middle_dir in self.all_directory:
                parent: list = os.listdir(middle_dir)
                try:
                    files = [f for f in parent if os.path.isfile(os.path.join(middle_dir, f))]
                    for extension in supported_extension:
                        supported_extension[extension] += len(list(filter(lambda f: f.endswith(extension), files)))
                except TypeError as ex:
                    print(f"Error: {ex}")

            extension_counted = [v for v in supported_extension.values()]
            sum_extension_counted = sum(extension_counted)

            if 0 < sum_extension_counted:
                supported_extension = {k: round((v / sum_extension_counted) * 100, 2) for k, v in zip(supported_extension, extension_counted)}
            else:
                supported_extension["other"] = 100

            display_dir_name = dir.split("/")[-1]
            result[display_dir_name] = supported_extension
            self.all_directory.clear()

        return result

外部パッケージのバージョンを取得するメソッドの作成

__init__では、変数の初期化と、アプリケーションでバージョンを表示させるかどうかのチェックボックスで、trueだった場合に、このアプリケーションが実行されている環境にインストールされた外部パッケージを取得する処理を実装しています。Python であればpip3 freezeでバージョンを標準出力させ、その結果を文字列として取得します。Julia も同様に、-eオプションを使用して、標準出力からバージョンを取得しています。

def __init__(self, path: str = None, lang: str = None, version: bool = False) -> None:
    self.path = "" if path is None else path
    self.lang = "" if lang is None else lang
    self.all_file = []
    self.all_directory = [path]

    if version:
        if "python" in self.lang:
            stdout_result_splited = self.command_runner(["pip3", "freeze"])

            self.installed_modules = [x for x in stdout_result_splited if "==" in x]
            self.version_split_word = "=="
            self.module_match_process_word = "module.replace('_', '-') == module_name.lower()"

        elif "julia" in self.lang:
            stdout_result_splited = self.command_runner(["julia", "-e", "using Pkg; Pkg.status()"])
            installed_packages = list(map(lambda x: x.lstrip("  "), stdout_result_splited))
            installed_packages.remove("")

            self.installed_modules = ["@".join(package_info.split(" ")[1:]) for package_info in installed_packages[1:]]
            self.version_split_word = "@"
            self.module_match_process_word = "module == module_name"

コマンドを実行するメソッド ↓

def command_runner(self, command: List[str]) -> List[str]:
    stdout_result = subprocess.run(command, capture_output=True)
    stdout_result_splited = stdout_result.stdout.decode("utf-8").split("\n")
    return stdout_result_splited

バージョンが書かれたモジュールを返すメソッドの作成

def confirm(self) -> List[str]:
    self.get_directories(self.path)
    self.get_files(self.lang)

    module_extractor = ModuleExtractor()
    modules_for_return = set()

    for file_path in self.all_file:
        with open(file_path, "r", encoding="utf-8") as file:
            file_contents = file.read()

        modules_for_return = modules_for_return.union(getattr(module_extractor, self.lang)(file_contents))

    if hasattr(self, "installed_modules"):
        tmp_modules = set()
        matched_modules = set()

        for module in modules_for_return:
            for installed_module in self.installed_modules:
                module_name = installed_module.split(self.version_split_word)[0] # Note: Used in eval

                if eval(self.module_match_process_word):
                    matched_modules.add(module)
                    tmp_modules.add(installed_module)
                else:
                    tmp_modules.add(module)

        module_list = list(tmp_modules)
        for matched_module in matched_modules:
            try:
                module_list.remove(matched_module)
            except ValueError as ex:
                print(f"Error: {ex}")
    else:
        module_list = list(modules_for_return)

    module_list.sort()
    return module_list

まず初めに選択されたフォルダ内のフォルダ・ファイルの情報をすべて取得します。

self.get_directories(self.path)
self.get_files(self.lang)

次にModuleExtractorクラスのインスタンスを作成して、getattr()で選択された言語と対応したメソッドを呼び出します。

module_extractor = ModuleExtractor()
modules_for_return = set()

for file_path in self.all_file:
    with open(file_path, "r", encoding="utf-8") as file:
        file_contents = file.read()

    modules_for_return = modules_for_return.union(getattr(module_extractor, self.lang)(file_contents))

クラス内にinstalled_modulesという変数が存在するかどうかをhasattrで調べます。変数が存在しなかった場合は集合をリストにして、ソートしてアプリケーションに渡します。変数が存在する場合は、選択されたフォルダから取得したモジュールと、環境にインストールされたモジュールが一致しているものを集合に格納していきます。

    if hasattr(self, "installed_modules"):
        tmp_modules = set()
        matched_modules = set()

        for module in modules_for_return:
            for installed_module in self.installed_modules:
                module_name = installed_module.split(self.version_split_word)[0] # Note: Used in eval

                if eval(self.module_match_process_word):
                    matched_modules.add(module)
                    tmp_modules.add(installed_module)
                else:
                    tmp_modules.add(module)

        module_list = list(tmp_modules)
        for matched_module in matched_modules:
            try:
                module_list.remove(matched_module)
            except ValueError as ex:
                print(f"Error: {ex}")
    else:
        module_list = list(modules_for_return)

    module_list.sort()
    return module_list

requirements.txt を作成するメソッドの作成

アプリケーションでパッケージが選択され、それをリストとして引数に受け取るgenerateメソッドは、選択されたパスにrequirements.txtを生成します。

def generate(self, module_list: List[str]) -> None:
    module_list = list(map(lambda x: x.replace("\n", ""), module_list))
    with open(os.path.join(self.path, "requirements.txt"), "w", encoding="utf-8") as f:
        modules = "\n".join(module_list)
        f.write(modules)

詳細情報を取得するメソッドの作成

detailは、アプリケーションで詳細表示ボタンが押された時に実行されるメソッドです。これはkeyに選択された絶対パスと、valueにそのフォルダ直下にあるファイルの数を返します。ファイルは対応言語とそれ以外という区分になっています。処理自体は単純で、パス直下の全てのファイルを取得し、拡張子の数をカウントするだけです。

def detail(self, directories: List[str]) -> Dict[str, Dict[str, int]]:
    result = {}

    for dir in directories:
        supported_extension = {"py": 0, "jl": 0, "go": 0, "ipynb": 0, "other": 0}

        if self.all_directory.count(""):
            self.all_directory.remove("")

        self.all_directory.append(dir)
        self.get_directories(dir)

        for middle_dir in self.all_directory:
            parent: list = os.listdir(middle_dir)
            try:
                files = [f for f in parent if os.path.isfile(os.path.join(middle_dir, f))]
                for extension in supported_extension:
                    supported_extension[extension] += len(list(filter(lambda f: f.endswith(extension), files)))
            except TypeError as ex:
                print(f"Error: {ex}")

        extension_counted = [v for v in supported_extension.values()]
        sum_extension_counted = sum(extension_counted)

        if 0 < sum_extension_counted:
            supported_extension = {k: round((v / sum_extension_counted) * 100, 2) for k, v in zip(supported_extension, extension_counted)}
        else:
            supported_extension["other"] = 100

        display_dir_name = dir.split("/")[-1]
        result[display_dir_name] = supported_extension
        self.all_directory.clear()

    return result

デスクトップ直下のフォルダツリーを作成する関数

Desktop の絶対パスをos.walk()に渡すと、全てのフォルダの全てのパスを取得することができます。実はOperateクラスのget_directoriesget_filesの処理はこれで置き換えることができます。このos.walk()で取得した最初のインデックスには、フォルダの絶対パスが格納されています。もしこのパスが.git__pycache__などの場合は、requirements.txtに必要のないフォルダとなるので、それ以外のパスでフォルダツリーの情報を取得していきます。

def generate_tree() -> None:
    tree_data = {"data": []}

    for directory_stracture in os.walk(settings.DESKTOP_PATH):
        tree_information = {}

        dir_path = directory_stracture[0]
        if not list(filter(lambda x: True if x in dir_path else False, settings.IGNORE_DIRECTORIES)):
            dir_list = dir_path.split(settings.PATH_SPLIT_WORD)
            tree_information["id"] = dir_path
            tree_information["text"] = dir_list[-1]
            tree_information["parent"] = settings.PATH_SPLIT_WORD.join(dir_list[:-1])

            if settings.DESKTOP_PATH == dir_path:
                tree_information["parent"] = "#"

            tree_data["data"].append(tree_information)

    with open(settings.TREE_PATH, "w", encoding="utf-8") as f:
        json.dump(tree_data, f, ensure_ascii=False, indent=2)

今回アプリケーションのツリー構造を表示させるのに使用しているのはtree.jsで、これがツリーを構成するのに必要な情報は、親のフォルダと、親フォルダからの相対パス、作成するフォルダツリーで一意である必要がある ID です。ID には絶対パスを割り当てています。しかし、親のないフォルダは、親に#を指定する必要があり、今回はDesktop直下のフォルダツリーを構成するため、絶対パスがDesktopであった場合の親を#にしています。これを辞書に格納していき、json.dump()jsonファイルに変換しています。

Discussion