side project: namehint 파이썬 패키지 cli 개발

◆◆ Index ◆◆

namehint 파이썬 패키지 cli 개발

개인적으로 자꾸 영어 단어를 생각할 때 ‘아 그 단어 뭐였지’ 하는 경우가 많아서 대충 만들어서 쓰고 있었는데 쓰기에 괜찮아 보여서 패키지로 배포하기로 생각하게 되었다.

데이터 준비

데이터는 한국어기초사전의 데이터를 썼다. 잘 만들어진 자료라서 활용하기 좋다. 한국어 관련 자료는 국립국어원에서 만든 좋은 자료가 많다.

한국어 기초사전 데이터를 하나로 합침

데이터 열 중 표제어, 영어 대역어 열만 따로 저장

NAN 있는 줄 제거

같은 이름 그룹화

겹치는 항목 제거

json으로 저장

라이선스

한국어기초사전의 자료는 다음과 같은 저작권 조건을 요구하기에 요건을 준수하였다.

‘크리에이티브 커먼즈 저작자표시-동일조건변경허락2.0 라이선스’가 적용되는 본 누리집의 자료는 누구나 상업적 용도까지 포함하여 자유롭게 이용할 수 있으며 저작자의 특별한 허가가 필요하지 않습니다. 다만, 저작물을 이용하기 위해서는 다음의 조건을 지켜야 합니다.

  - 저작자 표시: 자료를 사용할 때 저작자를 필수로 표시해야 합니다.
  - 동일조건변경허락: 자료를 변경하여 새로운 저작물을 만들 때, 그 저작물도 동일한 라이선스로 배포해야 합니다.

 텍스트 자료가 아닌 이미지, 동영상, 음악, 소리, 발음 등 다중 매체(멀티미디어) 파일 자료는 상업적 이용 허용 여부와 저작물 변경의 허용 여부가 개별적으로 설정되어 있으므로 개별적으로 설정된 저작권 정책에 따라 해당 저작물을 이용해야 합니다.

LICENSE 파일을 만들어서 라이선스 관련 사항을 적었다. 저작권 페이지 링크 하는 정도로 간단히 작성.

README에 저작권 관련 사항을 간단히 작성하였다

개인적으로 테스트

개인적으로 테스트 할 때는 이렇게 사용했다

pip install editable .

이렇게 하면 전역으로 저장되지 않고 파일이 수정되면 바로 적용되어서 테스트 하기 쉽다.

코드 작성

데이터 파일 형식 정하기

csv로 할까 json으로 할까 아니면 다른 파일 형식으로 할까 고민하다가 json 으로 했다.

검색 시 일치하는 단어가 없는 경우

일치하는 단어가 없는 경우 비슷한 단어를 보여주는데 그냥 가장 긴 스트링 매치를 보여주자니 완벽하지 않아서 자음 모음 분해의 필요성을 느꼈다. python 패키지 jamo를 써서 자음 모음 분해를 해서 검색을 시도했다 처음에는 이런식으로 단어 파일을 가져와서 자음 모음 분해를 해서 딕셔너리 키로 써서 검색을 시도했다.

#!/usr/bin/env python3
import csv
import argparse
from collections import defaultdict
import os
import json
from jamo import h2j, j2hcj

script_dir = os.path.dirname(os.path.abspath(__file__))
file_path = os.path.join(script_dir, 'dictionary.json')


def get_jamo(word):  # 자음모음 쪼갠 스트링 리턴하는 함수
    return j2hcj(h2j(word))


def search_word(word):
    words = []
    brokenword_meaning = defaultdict(list)
    brokenword_word = defaultdict(list)
    broken_search_word = get_jamo(word)

    # JSON 파일에서 단어와 의미를 로드
    with open(file_path, 'r', encoding='utf-8') as jsonfile:
        data = json.load(jsonfile)
        for w, m in data.items():
            brokenword = get_jamo(w)
            words.append(brokenword)
            brokenword_meaning[brokenword].append(m)
            brokenword_word[brokenword].append(w)

    # 일치하는거 있으면 일치하는거 보여줌
    if broken_search_word in brokenword_meaning:
        print(
            f"[ {brokenword_word[broken_search_word][0]} ]: {brokenword_meaning[broken_search_word][0]}")
        return

    # 일치하는게 없는 경우
    # 가장 길게 일치하는 단어를 찾아서 보여줌
    longest_matches = []
    max_prefix_length = 0
    for w in words:
        # 단어별로 가장 길게 일치하는 길이를 찾고
        prefix_length = 0
        for i in range(min(len(broken_search_word), len(w))):
            if broken_search_word[i] == w[i]:
                prefix_length += 1
            else:
                break

        # 일치 목록 갱신
        if prefix_length > 0:
            # 이전 보다 긴 경우 새로 리스트
            if prefix_length > max_prefix_length:
                max_prefix_length = prefix_length
                longest_matches = [w]

            # 이전과 길이 같은 경우 그냥 목록 추가
            elif prefix_length == max_prefix_length:
                longest_matches.append(w)

    # 짧은 단어를 우선적으로 보여주도록 정렬함
    longest_matches.sort(key=lambda x: len(x))
    if longest_matches:
        print(
            f"'{word}' 해당 단어를 찾을 수 없습니다. 이 단어를 찾으셨나요?")
        # 최대 5개만 보여줌
        for i in range(min(5, len(longest_matches))):
            print(
                f" - {brokenword_word[longest_matches[i]][0]}: {brokenword_meaning[longest_matches[i]][0]}")
    else:
        print(f"'{word}' 해당 단어를 찾을 수 없습니다.")


def main():
    parser = argparse.ArgumentParser(
        description="tool that provides English synonyms for Korean words")
    parser.add_argument("word", help="Enter korean word to search")
    args = parser.parse_args()
    search_word(args.word)


if __name__ == "__main__":
    main()

그런데 문제는 좀 느리다는 것이었다. 역시 아예 파일에 자음 모음 분해를 캐싱을 해서 넣는 것이 나을 것 같다는 생각을 해서 데이터 파일을 자음 모음 분해한 부분을 포함해서 다시 구성하고 코드를 수정했다.

#!/usr/bin/env python3
import csv
import argparse
from collections import defaultdict
import os
import json
from jamo import h2j, j2hcj

SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__))
FILE_PATH = os.path.join(SCRIPT_DIR, 'dictionary.json')
MAX_MATCH_SHOWN = 5


def get_brokenword(word):  # 자음모음 쪼갠 스트링 리턴하는 함수
    return j2hcj(h2j(word))


def get_longest_matches(data, broken_search_word):
    longest_matches = []
    max_prefix_length = 0
    for w in data:
        # 단어별로 가장 길게 일치하는 길이를 찾고
        prefix_length = 0
        for i in range(min(len(broken_search_word), len(w))):
            if broken_search_word[i] == w[i]:
                prefix_length += 1
            else:
                break

        # 일치 목록 갱신
        if prefix_length > 0:
            # 이전 보다 긴 경우 새로 리스트
            if prefix_length > max_prefix_length:
                max_prefix_length = prefix_length
                longest_matches = [w]

            # 이전과 길이 같은 경우 그냥 목록 추가
            elif prefix_length == max_prefix_length:
                longest_matches.append(w)

    # 짧은 단어를 우선적으로 보여주도록 정렬함
    longest_matches.sort(key=lambda x: len(x))
    return longest_matches


def search_word(word):
    broken_search_word = get_brokenword(word)

    # JSON 파일에서 단어와 의미를 로드
    with open(FILE_PATH, 'r', encoding='utf-8') as jsonfile:
        data = json.load(jsonfile)

    # 일치하는거 있으면 일치하는거 보여줌
    if broken_search_word in data:
        print(
            f"[ {data[broken_search_word]['word']} ]: {data[broken_search_word]['meaning']}")

    # 일치하는게 없는 경우
    # 가장 길게 일치하는 단어를 찾아서 보여줌
    else:
        longest_matches = get_longest_matches(data, broken_search_word)

        if longest_matches:
            print(
                f"'{word}' 해당 단어를 찾을 수 없습니다. 이 단어를 찾으셨나요?")
            # 최대 5개만 보여줌
            for i in range(min(MAX_MATCH_SHOWN, len(longest_matches))):
                print(
                    f" - {data[longest_matches[i]]['word']}: {data[longest_matches[i]]['meaning']}")
        else:
            print(f"'{word}' 해당 단어를 찾을 수 없습니다.")


def main():
    parser = argparse.ArgumentParser(
        description="tool that provides English synonyms for Korean words")
    parser.add_argument("word", help="Enter korean word to search")
    args = parser.parse_args()
    search_word(args.word)


if __name__ == "__main__":
    main()

구조

project folder
├── LICENSE
├── README.md
├── namehint
│ ├── \_\_init\_\_.py
│ ├── dictionary.json
│ └── namehint.py
└── setup.py

왠지 모르겠는데 main함수 py 파일이 폴더 안에 있지 않으니 에러났다.
pypi 에 올리기 위해서 setup.py 가 필요한데 다음과 같이 작성했다.

from setuptools import setup, find_packages

setup(
    name='namehint',
    version='1.0.0',
    description="A tool that provides English synonyms for Korean words to assist in English naming.",
    author="gongboo",
    author_email="gongboolearn@gmail.com",
    py_modules=['namehint'],  # hello.py 모듈 포함
    packages=find_packages(),
    include_package_data=True,
    package_data={
        'namehint': ['dictionary.json'],  # 모든 패키지에 dictionary.json 포함
    },
    entry_points={
        'console_scripts': [
            'namehint=namehint.namehint:main',  # 'namehint' 명령어를 namehint.py의 main 함수에 연결
        ],
    },
    install_requires=[                 # 필요한 패키지들
        "jamo",
    ],
    # PyPI 페이지에 README.md 내용이 표시
    long_description=open("README.md", "r", encoding="utf-8").read(),
    long_description_content_type="text/markdown",
    python_requires='>=3.6',
)

test pypi 업로드 테스트

하기 전에 build랑 twine 패키지가 필요하다

pip install build twine

pypi는 테스트 해볼 수 있도록 test pypi를 운영하는데
가입이 따로라서 귀찮았다. 패키지 올리려면 2FA(two factor authentication)을 해야하는데 두배로 해야해서 귀찮았다.

빌드 파일을 만들면 dist 폴더에 파일 두개가 생긴다

python3 -m build

testpy에 올린다.

python3 -m twine upload --repository testpypi dist/*

이거 하면 api 키를 입력하라고 하는데 test pypi에 가서 api키를 발급 받아야 한다. api키는 사이트의 account setting에 있다.

pypi 업로드

빌드를 하는데 pypi는 이전에 올렸던 버전과 같거나 작으면 400 에러가 난다. 새로 수정해서 올리는 경우라면 버전을 수정해 주는 것 잊지 말자.

python3 -m build

pypi에 올린다. 깃허브에 연동해서 올리는 방법도 있는데 생각보다 번거로워서(yml 파일 따로작성해야함) 그냥 깃허브 따로, 올리는 거 따로 하기로 했다.

python3 -m twine upload dist/*