편집실의 분류 번호를 운영 메뉴에 그대로 넣을 수 없는 경우를 작은 함수로 분리했다. 입력된 연결 규칙만 적용하고 의미를 추측하거나 실제 홈페이지를 바꾸지 않는다.

입력은 네 묶음으로 받는다

project_categories는 기존 공개 분류, 연결 규칙, 기존 경로 목록, 새 글 설명을 받는다. 기존 분류는 id·slug·meaning, 규칙은 source_id·target_id·target_slug·meaning, 글은 id·source_category_id·route 필드만 허용한다. 입력 문자열은 비어 있지 않은 160자 이내이며 분류·규칙은 각각 64개, 경로·글은 각각 128개 이하다.

기존 목록은 실제 운영 결과에서 호출자가 준비해야 한다. 이 함수는 운영 사이트를 방문하거나 디렉터리를 찾아 기존 자료를 발견하지 않는다. 목록이 빠져 있으면 빠진 자료와의 충돌도 알 수 없다. 같은 번호를 어떤 의미로 쓰는지 결정하는 사람과 자료 수집 경계가 먼저 필요하다.

번호와 슬러그를 함께 대조한다

규칙의 source_id는 편집 분류를 가리키고 target_id와 target_slug는 공개 분류를 가리킨다. 이미 쓰는 공개 ID가 다른 선언과 연결되면 PUBLIC_ID_CONFLICT로 거절한다. 공개 슬러그가 다른 ID나 의미와 겹치면 PUBLIC_SLUG_CONFLICT로 거절한다. 번호만 비어 있다고 경로 이름까지 비었다고 보지 않는 것이다.

같은 공개 분류를 재사용하려면 id·slug·meaning 세 값이 기존 선언과 정확히 같아야 한다. 여러 편집 분류가 같은 공개 분류로 합류하는 것도 그 선언이 같을 때 허용한다. 문자열이 같다는 사실은 사업상 의미까지 검증했다는 뜻이 아니다. meaning은 호출자가 정한 표지이며 이 함수가 글을 읽고 의미를 판정하지 않는다.

원본 식별자와 주소를 함께 남기는 구현

출력 글에는 source_category_id와 route를 그대로 남기고 public_category_id·public_category_slug를 더한다. source_category_id를 공개 값으로 덮어쓰지 않아 어떤 규칙을 거쳤는지 읽을 수 있다. 기존 공개 분류는 순서와 값을 남기고 새 대상 분류만 추가한다. 입력과 반환값을 분리해 반환값 편집이 원본 목록에 전파되지 않게 했다.

새 글의 주소 문자열이 기존 목록이나 다른 새 글과 같으면 ARTICLE_ROUTE_CONFLICT로 거절한다. 글 ID 중복과 연결 규칙 누락도 별도 오류다. 이 모듈은 route를 비교용 문자열로 취급한다. URL 정규화·상대 경로 생성·파일 시스템 경계·실제 HTTP 응답을 검사하는 기능은 기존 경로 모듈과 실제 배포 검수에 맡겨야 한다.

코드와 구현 살펴보기

독립 합성 전체 구현

"""Bounded, synthetic namespace projection. No IO, semantic inference or publishing."""
from copy import deepcopy


def project_categories(existing_categories, rules, existing_routes, articles):
    def rows(value, limit):
        if type(value) is not list or len(value) > limit:
            raise ValueError('BOUNDED_LIST')
        return value

    def fields(row, names):
        if type(row) is not dict or set(row) != set(names):
            raise ValueError('EXACT_FIELDS')
        for value in row.values():
            if type(value) is not str or not value.strip() or len(value) > 160:
                raise ValueError('BOUNDED_NONEMPTY_TEXT')

    categories, by_id, by_slug = [], {}, {}
    for row in rows(existing_categories, 64):
        fields(row, ('id', 'slug', 'meaning'))
        if row['id'] in by_id or row['slug'] in by_slug:
            raise ValueError('DUPLICATE_PUBLIC_CATEGORY')
        clone = deepcopy(row)
        categories.append(clone)
        by_id[row['id']] = clone
        by_slug[row['slug']] = clone

    mapping = {}
    for row in rows(rules, 64):
        fields(row, ('source_id', 'target_id', 'target_slug', 'meaning'))
        if row['source_id'] in mapping:
            raise ValueError('DUPLICATE_SOURCE_RULE')
        target = {'id': row['target_id'], 'slug': row['target_slug'], 'meaning': row['meaning']}
        if row['target_id'] in by_id and by_id[row['target_id']] != target:
            raise ValueError('PUBLIC_ID_CONFLICT')
        if row['target_slug'] in by_slug and by_slug[row['target_slug']] != target:
            raise ValueError('PUBLIC_SLUG_CONFLICT')
        if row['target_id'] not in by_id:
            categories.append(target)
            by_id[row['target_id']] = target
            by_slug[row['target_slug']] = target
        mapping[row['source_id']] = target

    routes = set()
    for value in rows(existing_routes, 128):
        if type(value) is not str or not value.strip() or len(value) > 160:
            raise ValueError('BOUNDED_NONEMPTY_TEXT')
        if value in routes:
            raise ValueError('DUPLICATE_EXISTING_ROUTE')
        routes.add(value)

    projected, ids = [], set()
    for row in rows(articles, 128):
        fields(row, ('id', 'source_category_id', 'route'))
        if row['id'] in ids:
            raise ValueError('DUPLICATE_ARTICLE_ID')
        if row['route'] in routes:
            raise ValueError('ARTICLE_ROUTE_CONFLICT')
        if row['source_category_id'] not in mapping:
            raise ValueError('UNMAPPED_SOURCE_CATEGORY')
        ids.add(row['id']); routes.add(row['route'])
        target = mapping[row['source_category_id']]
        projected.append({**deepcopy(row), 'public_category_id': target['id'],
                          'public_category_slug': target['slug']})

    return deepcopy({
        'categories': categories, 'articles': projected,
        'mapping': [{'source_category_id': source, 'public_category_id': target['id']}
                    for source, target in mapping.items()],
        'performs_write_or_publish': False,
        'authenticates_semantics': False,
    })

충돌하면 새 번호를 임의로 만들지 않는다

함수가 충돌을 만나면 더 큰 번호를 찾아 재배정하거나, 대소문자를 고쳐 같은 자료로 묶지 않는다. 필요한 연결 규칙이 빠진 경우에도 분류를 추측하지 않는다. 원래 입력을 보존하고 오류를 반환하면 호출자가 공개 분류를 다시 선택할 수 있다. 자동으로 정돈한 결과보다 선택 근거를 다시 읽을 수 있는 편을 택했다.

시험 24개는 ID·슬러그 충돌, 기존 분류 재사용, 여러 출처의 합류, 연결 누락, 중복 글·경로·규칙, 입력 상한과 필드 검사, 입력 보존과 반환값 분리를 확인한다. 실제 고객 사이트나 원래 배포기를 실행한 시험은 아니다. 통과한 규칙이 콘텐츠 정확성이나 공개 권한을 보장하는 것도 아니다.

실제 합성 실행 기록을 살펴보기

다섯 기록은 번호를 바꿔 연결한 정상 결과와 ID 충돌·슬러그 충돌·경로 충돌·연결 누락의 거절 결과다. 모두 만든 분류와 글 식별자를 사용해 로컬에서 실행했다. 정상 결과에서는 기존 section-a와 새 section-b가 함께 있고, 글의 출처 분류는 section-a로 남는다. 오류 기록에는 자료를 발행했다는 표시를 넣지 않았다.

아래 선택기는 그 저장된 JSON을 읽는 화면이다. 웹에서 Python을 다시 실행하거나 방문자의 CMS를 바꾸지 않는다. 결과를 바꿔 읽는 동작은 DOM 대역으로 확인하며 실제 렌더링·키보드·화면 크기 검수와는 구분한다. 구현을 적용할 때는 자신의 입력과 기대 결과를 추가해 시험해야 한다.

합성 로컬 기록의 읽기 뷰어

ZIP을 가져와 연결하기 전에

ZIP에는 bridge.py 전체 구현과 test_bridge.py의 24개 시험, examples.py, 다섯 실행 결과와 README가 들어 있다. Python 표준 라이브러리만 사용하고 수정·재사용할 수 있다. python -m unittest -v로 시험하고 python examples.py로 합성 기록을 다시 만들 수 있다. 실제 배포 명령은 들어 있지 않다.

공개 전에는 분류의 의미와 전체 기존 목록, 주소 생성 규칙, 글 검토·발행 권한과 배포 후 확인을 따로 연결한다. performs_write_or_publish와 authenticates_semantics는 항상 false다. 파일과 검색 목록의 실제 경로를 만들고 대조할 필요가 있다면 아래 기존 경로 모듈을 함께 검토하되 두 구현의 책임을 합쳐 설명하지 않는다.

코드·시험·실행 기록 ZIP 받기
Python 표준 라이브러리 · 합성 자료 · 수정과 재사용 가능