├── loglab ├── __init__.py ├── version.py ├── __main__.py ├── schema │ ├── models.py │ ├── config.py │ ├── __init__.py │ ├── interfaces.py │ ├── implementations.py │ ├── compatibility.py │ ├── generator.py │ ├── property_builder.py │ ├── validator.py │ └── log_validator.py ├── template │ ├── tmpl_obj.py.jinja │ ├── tmpl_obj.ts.jinja │ ├── tmpl_obj.java.jinja │ ├── tmpl_obj.cs.jinja │ ├── tmpl_obj.cpp.jinja │ └── tmpl_doc.html.jinja └── cli.py ├── schema └── README.md ├── image └── loglab.ico ├── tools ├── verpatch.exe ├── build.sh └── build.bat ├── docs ├── _static │ ├── guide.png │ ├── html.png │ └── loglab.png ├── index.rst ├── Makefile ├── make.bat ├── conf.py ├── introduction.rst ├── installation.rst ├── developer.rst └── etc.rst ├── MANIFEST.in ├── tests ├── .gitignore ├── cstest │ ├── cstest.csproj │ └── test_log_objects_csharp.cs ├── files │ ├── minimal.lab.json │ ├── bcom.lab.json │ ├── boo.lab.json │ ├── acme.lab.json │ ├── acme2.lab.json │ └── foo.lab.json ├── test_util.py ├── tstest │ ├── package.json │ ├── main.ts │ ├── main.js │ ├── tsconfig.json │ ├── test_log_objects_typescript.ts │ └── loglab_foo.js ├── cpptest │ └── test_log_objects_cpp.cpp ├── test_log_objects_python.py ├── javatest │ ├── pom.xml │ └── src │ │ └── main │ │ └── java │ │ └── loglab_foo │ │ └── TestLogObjects.java ├── test_util_extended.py ├── test_log_objects_typescript.py ├── test_implementations_extended.py ├── test_performance.py └── test_cli_integration.py ├── requirements.txt ├── bin ├── loglab └── loglab.bat ├── locales ├── en_US │ └── LC_MESSAGES │ │ ├── base.mo │ │ └── base.po ├── ja_JP │ └── LC_MESSAGES │ │ ├── base.mo │ │ └── base.po └── zh_CN │ └── LC_MESSAGES │ ├── base.mo │ └── base.po ├── setup.cfg ├── .readthedocs.yaml ├── pyproject.toml ├── example ├── common.lab.json ├── boo.lab.json ├── acme.lab.json └── foo.lab.json ├── requirements-dev.txt ├── tox.ini ├── CHANGELOG ├── LICENSE ├── .github ├── dependabot.yml └── workflows │ └── test.yml ├── setup.py ├── .pre-commit-config.yaml ├── .gitignore ├── README.md ├── CLAUDE.md └── Makefile /loglab/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /loglab/version.py: -------------------------------------------------------------------------------- 1 | VERSION = "0.3.4" 2 | -------------------------------------------------------------------------------- /schema/README.md: -------------------------------------------------------------------------------- 1 | 여기는 레가시 스키마 위치입니다. 2 | 호환성을 위해 한동안 유지 예정입니다. 3 | -------------------------------------------------------------------------------- /image/loglab.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/haje01/loglab/HEAD/image/loglab.ico -------------------------------------------------------------------------------- /tools/verpatch.exe: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/haje01/loglab/HEAD/tools/verpatch.exe -------------------------------------------------------------------------------- /docs/_static/guide.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/haje01/loglab/HEAD/docs/_static/guide.png -------------------------------------------------------------------------------- /docs/_static/html.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/haje01/loglab/HEAD/docs/_static/html.png -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | recursive-include loglab * 2 | global-exclude *.pyc 3 | global-exclude __pycache__ 4 | -------------------------------------------------------------------------------- /docs/_static/loglab.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/haje01/loglab/HEAD/docs/_static/loglab.png -------------------------------------------------------------------------------- /tests/.gitignore: -------------------------------------------------------------------------------- 1 | *.log.schema.json 2 | *.flow.schema.json 3 | /*.lab.json 4 | *.txt 5 | *.html 6 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | click>=8.0.0 2 | jsonschema==3.2.0 3 | jinja2 4 | tabulate[widechars] 5 | requests 6 | -------------------------------------------------------------------------------- /bin/loglab: -------------------------------------------------------------------------------- 1 | #! /usr/bin/env python 2 | 3 | from loglab.cli import cli 4 | 5 | cli(prog_name="loglab", obj={}) 6 | -------------------------------------------------------------------------------- /locales/en_US/LC_MESSAGES/base.mo: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/haje01/loglab/HEAD/locales/en_US/LC_MESSAGES/base.mo -------------------------------------------------------------------------------- /locales/ja_JP/LC_MESSAGES/base.mo: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/haje01/loglab/HEAD/locales/ja_JP/LC_MESSAGES/base.mo -------------------------------------------------------------------------------- /locales/zh_CN/LC_MESSAGES/base.mo: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/haje01/loglab/HEAD/locales/zh_CN/LC_MESSAGES/base.mo -------------------------------------------------------------------------------- /loglab/__main__.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | """LogLab module entry point.""" 3 | 4 | from loglab.cli import cli 5 | 6 | if __name__ == "__main__": 7 | cli() 8 | -------------------------------------------------------------------------------- /tools/build.sh: -------------------------------------------------------------------------------- 1 | pytest tests 2 | pyinstaller loglab/cli.py --name loglab --icon image/loglab.ico --onefile --noconfirm --add-data="schema/lab.schema.json:schema" 3 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [flake8] 2 | max-line-length = 88 3 | extend-ignore = E203,W503,F401,F403,F405,F541,E501,D100,D101,D102,D103,D104,D105,D107,D200,D202,D208,D301,D400,D403,I100,I101,I201,I202,F841,W291 4 | exclude = tests/loglab_*.py 5 | -------------------------------------------------------------------------------- /.readthedocs.yaml: -------------------------------------------------------------------------------- 1 | version: 2 2 | 3 | build: 4 | os: ubuntu-22.04 5 | tools: 6 | python: "3.10" 7 | 8 | python: 9 | install: 10 | - requirements: requirements.txt 11 | - requirements: requirements-dev.txt 12 | 13 | sphinx: 14 | configuration: docs/conf.py 15 | -------------------------------------------------------------------------------- /tests/cstest/cstest.csproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Exe 5 | net8.0 6 | enable 7 | enable 8 | 9 | 10 | 11 | -------------------------------------------------------------------------------- /tests/files/minimal.lab.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://raw.githubusercontent.com/haje01/loglab/master/loglab/schema/lab.schema.json", 3 | "domain": { 4 | "name": "foo", 5 | "desc": "위대한 모바일 게임" 6 | }, 7 | "events": { 8 | "Login": { 9 | "desc": "계정 로그인" 10 | } 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /tests/test_util.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | import pytest 4 | 5 | from loglab.util import download, test_reset 6 | 7 | 8 | @pytest.fixture 9 | def clear(): 10 | test_reset() 11 | 12 | # path = 'foo.lab.json' 13 | # download( 14 | # 'https://raw.githubusercontent.com/haje01/loglab/master/tests/files/foo.lab.json', 15 | # path 16 | # ) 17 | # assert os.path.isfile(path) 18 | -------------------------------------------------------------------------------- /bin/loglab.bat: -------------------------------------------------------------------------------- 1 | @echo off 2 | title loglab 3 | 4 | REM 설치된 도구 환경의 Python 직접 실행 5 | if exist "%USERPROFILE%\AppData\Roaming\uv\tools\loglab\Scripts\python.exe" ( 6 | "%USERPROFILE%\AppData\Roaming\uv\tools\loglab\Scripts\python.exe" -m loglab.cli %* 7 | goto :end 8 | ) 9 | 10 | echo Error: loglab is not properly installed. Please reinstall with uv. 11 | exit /b 1 12 | 13 | :end 14 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [tool.black] 2 | line-length = 88 3 | target-version = ['py38'] 4 | 5 | [tool.isort] 6 | profile = "black" 7 | line_length = 88 8 | multi_line_output = 3 9 | include_trailing_comma = true 10 | force_grid_wrap = 0 11 | use_parentheses = true 12 | ensure_newline_before_comments = true 13 | 14 | [tool.pytest.ini_options] 15 | testpaths = ["tests"] 16 | python_files = ["test_*.py"] 17 | python_classes = ["Test*"] 18 | python_functions = ["test_*"] 19 | -------------------------------------------------------------------------------- /tests/tstest/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "loglab-typescript-test", 3 | "version": "1.0.0", 4 | "description": "TypeScript compilation test for LogLab generated objects", 5 | "main": "test_log_objects_typescript.ts", 6 | "scripts": { 7 | "build": "tsc --noEmit --skipLibCheck", 8 | "test": "node test_log_objects_typescript.js" 9 | }, 10 | "devDependencies": { 11 | "typescript": "^5.0.0", 12 | "@types/node": "^20.0.0" 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /tests/tstest/main.ts: -------------------------------------------------------------------------------- 1 | import { Login, Logout } from './loglab_foo'; 2 | 3 | // Login 이벤트 생성 4 | const loginEvent = new Login(1, 1001, "ios"); 5 | console.log("Login Event:", loginEvent.serialize()); 6 | 7 | // Logout 이벤트 생성 (옵셔널 필드 포함) 8 | const logoutEvent = new Logout(1, 1001); 9 | logoutEvent.PlayTime = 123.45; // 옵셔널 필드 설정 10 | console.log("Logout Event:", logoutEvent.serialize()); 11 | 12 | // 객체 재사용을 위한 리셋 13 | loginEvent.reset(2, 2002, "aos"); 14 | console.log("Reset Login Event:", loginEvent.serialize()); 15 | -------------------------------------------------------------------------------- /example/common.lab.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://raw.githubusercontent.com/haje01/loglab/master/loglab/schema/lab.schema.json", 3 | "domain": { 4 | "name": "common", 5 | "desc": "공용 구조" 6 | }, 7 | "types": { 8 | "stringId": { 9 | "type": "string", 10 | "desc": "문자열 ID" 11 | } 12 | }, 13 | "bases": { 14 | "CommonAcnt": { 15 | "desc": "공통 계정 정보", 16 | "fields": [ 17 | ["CommonId", "types.stringId", "공통 계정 ID"] 18 | ] 19 | } 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /tests/files/bcom.lab.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://raw.githubusercontent.com/haje01/loglab/master/loglab/schema/lab.schema.json", 3 | "domain": { 4 | "name": "bcom", 5 | "desc": "최고의 회사" 6 | }, 7 | "types": { 8 | "unsigned": { 9 | "type": "integer", 10 | "desc": "0 이상 정수", 11 | "minimum": 0 12 | } 13 | }, 14 | "bases": { 15 | "BcomAcnt": { 16 | "desc": "BCOM 계정 정보", 17 | "fields": [ 18 | ["BcomAcntId", "types.unsigned", "BCOM 계정 ID"] 19 | ] 20 | } 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /tests/tstest/main.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | Object.defineProperty(exports, "__esModule", { value: true }); 3 | var loglab_foo_1 = require("./loglab_foo"); 4 | // Login 이벤트 생성 5 | var loginEvent = new loglab_foo_1.Login(1, 1001, "ios"); 6 | console.log("Login Event:", loginEvent.serialize()); 7 | // Logout 이벤트 생성 (옵셔널 필드 포함) 8 | var logoutEvent = new loglab_foo_1.Logout(1, 1001); 9 | logoutEvent.PlayTime = 123.45; // 옵셔널 필드 설정 10 | console.log("Logout Event:", logoutEvent.serialize()); 11 | // 객체 재사용을 위한 리셋 12 | loginEvent.reset(2, 2002, "aos"); 13 | console.log("Reset Login Event:", loginEvent.serialize()); 14 | -------------------------------------------------------------------------------- /docs/index.rst: -------------------------------------------------------------------------------- 1 | .. LogLab documentation master file, created by 2 | sphinx-quickstart on Wed Jul 23 17:17:29 2025. 3 | You can adapt this file completely to your liking, but it should at least 4 | contain the root `toctree` directive. 5 | 6 | .. figure:: _static/loglab.png 7 | :alt: LogLab 로고 8 | :width: 150px 9 | 10 | LogLab 문서 11 | ==================== 12 | 13 | LogLab 문서에 오신 것을 환영합니다! 14 | 15 | .. toctree:: 16 | :maxdepth: 2 17 | :caption: Contents: 18 | 19 | introduction.rst 20 | installation.rst 21 | basic.rst 22 | advanced.rst 23 | etc.rst 24 | developer.rst 25 | -------------------------------------------------------------------------------- /tests/tstest/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2020", 4 | "module": "commonjs", 5 | "lib": ["ES2020", "DOM"], 6 | "outDir": "./dist", 7 | "rootDir": "./", 8 | "strict": false, 9 | "strictPropertyInitialization": false, 10 | "esModuleInterop": true, 11 | "skipLibCheck": true, 12 | "forceConsistentCasingInFileNames": true, 13 | "declaration": true, 14 | "declarationMap": true, 15 | "sourceMap": true 16 | }, 17 | "include": [ 18 | "*.ts" 19 | ], 20 | "exclude": [ 21 | "node_modules", 22 | "dist" 23 | ] 24 | } 25 | -------------------------------------------------------------------------------- /loglab/schema/models.py: -------------------------------------------------------------------------------- 1 | """Schema 생성에 필요한 모델 클래스들.""" 2 | 3 | from dataclasses import dataclass 4 | from typing import Any, Dict, List, Optional 5 | 6 | 7 | @dataclass 8 | class EventSchema: 9 | """이벤트 스키마 정보.""" 10 | 11 | name: str 12 | properties: List[str] # 문자열 리스트로 변경 (기존 방식과 호환) 13 | required_fields: List[str] 14 | description: str = "" 15 | 16 | 17 | @dataclass 18 | class PropertyInfo: 19 | """필드 속성 정보.""" 20 | 21 | name: str 22 | type: str 23 | description: str 24 | constraints: Dict[str, Any] 25 | is_optional: bool = False 26 | 27 | 28 | @dataclass 29 | class DomainInfo: 30 | """도메인 정보.""" 31 | 32 | name: str 33 | description: str 34 | -------------------------------------------------------------------------------- /loglab/schema/config.py: -------------------------------------------------------------------------------- 1 | """Schema 관련 설정.""" 2 | 3 | import dataclasses 4 | 5 | from loglab.util import get_schema_file_path 6 | 7 | 8 | @dataclasses.dataclass 9 | class SchemaConfig: 10 | """스키마 검증 및 생성 관련 설정.""" 11 | 12 | default_schema_path: str = dataclasses.field(default_factory=get_schema_file_path) 13 | 14 | datetime_pattern: str = ( 15 | r"^([0-9]+)-(0[1-9]|1[012])-(0[1-9]|[12][0-9]|3[01])[Tt]" 16 | r"([01][0-9]|2[0-3]):([0-5][0-9]):([0-5][0-9]|60)" 17 | r"(\\\\.[0-9]+)?(([Zz])|([\\\\+|\\\\-]([01][0-9]|2[0-3]):?[0-5][0-9]))$" 18 | ) 19 | 20 | json_schema_version: str = "https://json-schema.org/draft/2020-12/schema" 21 | 22 | encoding: str = "utf8" 23 | -------------------------------------------------------------------------------- /tests/files/boo.lab.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://raw.githubusercontent.com/haje01/loglab/master/loglab/schema/lab.schema.json", 3 | "domain": { 4 | "name": "boo", 5 | "desc": "최고의 PC 온라인 게임" 6 | }, 7 | "import": ["acme2"], 8 | "events": { 9 | "Login": { 10 | "desc": "계정 로그인", 11 | "mixins": ["acme.events.Login"], 12 | "fields": [ 13 | { 14 | "name": "Platform", 15 | "desc": "PC의 플랫폼", 16 | "type": "string", 17 | "enum": [ 18 | "win", "mac", "linux" 19 | ] 20 | } 21 | ] 22 | } 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /docs/Makefile: -------------------------------------------------------------------------------- 1 | # Minimal makefile for Sphinx documentation 2 | # 3 | 4 | # You can set these variables from the command line, and also 5 | # from the environment for the first two. 6 | SPHINXOPTS ?= 7 | SPHINXBUILD ?= sphinx-build 8 | SOURCEDIR = . 9 | BUILDDIR = _build 10 | 11 | # Put it first so that "make" without argument is like "make help". 12 | help: 13 | @$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) 14 | 15 | .PHONY: help Makefile 16 | 17 | # Catch-all target: route all unknown targets to Sphinx using the new 18 | # "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS). 19 | %: Makefile 20 | @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) 21 | -------------------------------------------------------------------------------- /tools/build.bat: -------------------------------------------------------------------------------- 1 | echo off 2 | pytest tests 3 | 4 | for /F "tokens=* USEBACKQ" %%F IN (`type loglab\version.py`) DO ( 5 | set ver=%%F 6 | ) 7 | set version=%ver:~9,5% 8 | echo -- Build version %version% -- 9 | pyinstaller loglab\cli.py --name loglab --hidden-import=click --icon image/loglab.ico --onefile --noconfirm ^ 10 | --add-data="schema/lab.schema.json;schema" ^ 11 | --add-data="template/tmpl_doc.html.jinja;template" ^ 12 | --add-data="template/tmpl_obj.cs.jinja;template" ^ 13 | --add-data="template/tmpl_obj.py.jinja;template" ^ 14 | | rem 15 | timeout 3 16 | set wver="%version%.0" 17 | tools\verpatch.exe dist\loglab.exe %wver% /va /pv %wver% /s description "Design & Validate Log Files." /s product "LogLab" /s copyright "(c) 2025" 18 | -------------------------------------------------------------------------------- /requirements-dev.txt: -------------------------------------------------------------------------------- 1 | # Development dependencies for LogLab 2 | 3 | # Testing 4 | pytest>=7.0.0 5 | pytest-cov>=4.0.0 6 | coverage>=7.0.0 7 | tox>=4.0.0 8 | 9 | # Code formatting and linting 10 | black>=23.0.0 11 | isort>=5.12.0 12 | flake8>=6.0.0 13 | flake8-docstrings>=1.7.0 14 | flake8-import-order>=0.18.2 15 | 16 | # Pre-commit hooks 17 | pre-commit>=3.0.0 18 | 19 | # Security 20 | bandit>=1.7.5 21 | safety>=2.3.0 22 | 23 | # Performance testing 24 | psutil>=5.9.0 25 | 26 | # Development tools 27 | ipython>=8.0.0 28 | ipdb>=0.13.0 29 | 30 | # Build tools 31 | pyinstaller>=5.0.0 32 | wheel>=0.40.0 33 | setuptools>=65.0.0 34 | 35 | # Type checking (optional) 36 | mypy>=1.0.0 37 | types-requests>=2.28.0 38 | 39 | # Documentation 40 | sphinx>=7.1.2 41 | sphinx-rtd-theme>=3.0.2 42 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [tox] 2 | envlist = py39,py310,py311,py312 3 | 4 | [testenv] 5 | # Use uv-venv-runner if available, fallback to default 6 | runner = uv-venv-runner 7 | deps = 8 | -r{toxinidir}/requirements-dev.txt 9 | commands = 10 | uv pip install --upgrade click>=8.0.0 11 | uv pip install -e . 12 | # Generate required test files 13 | python -m loglab object example/foo.lab.json py -o tests/loglab_foo.py 14 | coverage run --source loglab -m pytest tests -v --tb=short 15 | 16 | # Fallback environment for CI without uv 17 | [testenv:py{39,310,311,312}-ci] 18 | deps = 19 | -r{toxinidir}/requirements-dev.txt 20 | commands = 21 | pip install --upgrade click>=8.0.0 22 | pip install -e . 23 | # Generate required test files 24 | python -m loglab object example/foo.lab.json py -o tests/loglab_foo.py 25 | coverage run --source loglab -m pytest tests -v --tb=short 26 | -------------------------------------------------------------------------------- /CHANGELOG: -------------------------------------------------------------------------------- 1 | - 0.3.4: 2 | - 템플릿 파일 로드 실패 수정 3 | - 0.3.3: 4 | - Java 로그 객체 코드 생성 5 | - 0.3.2: 6 | - 로그 객체 이벤트 시간 UTC 설정 기능 7 | - 로그 객체 버그 수정 8 | - 0.3.1: 9 | - typescript 로그 객체 도입 10 | - 0.3.0: 11 | - Claude Code 도입 12 | - uv 툴로 배포 13 | - Sphinx 문서화 14 | - cli 에서 로깅 레벨 설정 가능 15 | - 0.2.4: 16 | - 구조 개선 및 테스트 추가 17 | - 일본어 번역 추가 18 | - 0.2.3: 19 | - C++ 로그 객체 이벤트 시간 정밀도 향상 20 | - 0.2.2: 21 | - C++ 로그 객체 메소드 이름 스타일 변경 22 | - 0.2.1: 23 | - 랩파일내에서 가져온 (import) 랩파일도 검증 24 | - 0.2.0: 25 | - C++ 로그 객체 지원 26 | - 0.1.9: 27 | - C# 로그 객체에서 이벤트 시간 포맷 버그 수정 28 | - 0.1.8: 29 | - C# 로그 객체에서 문자열 Escape 처리 30 | - 0.1.7: 31 | - C# 로그 객체에서 datetime 타입 버그 수정 32 | - 0.1.6: 33 | - 로그 객체 향상 34 | - utf-8 인코딩으로 파일 저장 35 | - 필드별 타입 지정 36 | - 인자 없는 생성자 지원 37 | - 0.1.5: 38 | - 로그 객체 reset 기능 39 | - domain 요소에 version 정보 추가 40 | - 0.1.4: 로그 객체 추가 41 | - 0.1.3: 현지화 기능 추가 42 | - 0.1.2: dummy 기능 제거 43 | - 0.1.1: 스키마 개선, 예제 추가 44 | - 0.1.0: 최초 릴리즈 45 | -------------------------------------------------------------------------------- /docs/make.bat: -------------------------------------------------------------------------------- 1 | @ECHO OFF 2 | 3 | pushd %~dp0 4 | 5 | REM Command file for Sphinx documentation 6 | 7 | if "%SPHINXBUILD%" == "" ( 8 | set SPHINXBUILD=sphinx-build 9 | ) 10 | set SOURCEDIR=. 11 | set BUILDDIR=_build 12 | 13 | %SPHINXBUILD% >NUL 2>NUL 14 | if errorlevel 9009 ( 15 | echo. 16 | echo.The 'sphinx-build' command was not found. Make sure you have Sphinx 17 | echo.installed, then set the SPHINXBUILD environment variable to point 18 | echo.to the full path of the 'sphinx-build' executable. Alternatively you 19 | echo.may add the Sphinx directory to PATH. 20 | echo. 21 | echo.If you don't have Sphinx installed, grab it from 22 | echo.https://www.sphinx-doc.org/ 23 | exit /b 1 24 | ) 25 | 26 | if "%1" == "" goto help 27 | 28 | %SPHINXBUILD% -M %1 %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O% 29 | goto end 30 | 31 | :help 32 | %SPHINXBUILD% -M help %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O% 33 | 34 | :end 35 | popd 36 | -------------------------------------------------------------------------------- /tests/cpptest/test_log_objects_cpp.cpp: -------------------------------------------------------------------------------- 1 | #include 2 | #include 3 | #include 4 | #include 5 | #include 6 | #include 7 | #include 8 | 9 | #include "loglab_foo.h" 10 | 11 | 12 | TEST(StringTest, Serialize) { 13 | auto login = loglab_foo::Login(1, 10000, "ios"); 14 | std::string a = "\"Event\":\"Login\",\"ServerNo\":1,\"AcntId\":10000,\"Platform\":\"ios\",\"Category\":1}"; 15 | std::string b = login.serialize(); 16 | std::cout << "Serialized Login: " << b << std::endl; 17 | EXPECT_NE(b.find(a), std::string::npos); // 문자열 포함 테스트 18 | } 19 | 20 | 21 | TEST(StringTest, SerializeAfterReset) { 22 | auto login = loglab_foo::Login(1, 10000, "ios"); 23 | login.reset(2, 20000, "aos"); 24 | std::string a = "\"Event\":\"Login\",\"ServerNo\":2,\"AcntId\":20000,\"Platform\":\"aos\",\"Category\":1}"; 25 | std::string b = login.serialize(); 26 | EXPECT_NE(b.find(a), std::string::npos); // 문자열 포함 테스트 27 | } 28 | -------------------------------------------------------------------------------- /example/boo.lab.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://raw.githubusercontent.com/haje01/loglab/master/loglab/schema/lab.schema.json", 3 | "domain": { 4 | "name": "boo", 5 | "desc": "최고의 PC 온라인 게임" 6 | }, 7 | "import": ["acme"], 8 | "events": { 9 | "Login": { 10 | "mixins": [ 11 | "acme.events.Login" 12 | ], 13 | "fields": [ 14 | { 15 | "name": "Platform", 16 | "desc": "PC의 플랫폼", 17 | "type": "string", 18 | "enum": [ 19 | "win", 20 | "mac", 21 | "linux" 22 | ] 23 | } 24 | ] 25 | }, 26 | "ServerMemory": { 27 | "desc": "서버 가용 메모리.", 28 | "mixins": ["acme.bases.Server"], 29 | "fields": [ 30 | ["AvailMemory", "acme.types.unsigned", "가용 메모리"] 31 | ] 32 | } 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /docs/conf.py: -------------------------------------------------------------------------------- 1 | # Configuration file for the Sphinx documentation builder. 2 | # 3 | # For the full list of built-in configuration values, see the documentation: 4 | # https://www.sphinx-doc.org/en/master/usage/configuration.html 5 | 6 | # -- Project information ----------------------------------------------------- 7 | # https://www.sphinx-doc.org/en/master/usage/configuration.html#project-information 8 | 9 | project = "LogLab" 10 | copyright = "2025, Jeong Ju, Kim" 11 | author = "Jeong Ju, Kim" 12 | release = "0.3.3" 13 | 14 | # -- General configuration --------------------------------------------------- 15 | # https://www.sphinx-doc.org/en/master/usage/configuration.html#general-configuration 16 | 17 | extensions = [] 18 | 19 | templates_path = ["_templates"] 20 | exclude_patterns = ["_build", "Thumbs.db", ".DS_Store"] 21 | 22 | language = "ko" 23 | 24 | # -- Options for HTML output ------------------------------------------------- 25 | # https://www.sphinx-doc.org/en/master/usage/configuration.html#options-for-html-output 26 | 27 | html_theme = "sphinx_rtd_theme" 28 | html_static_path = ["_static"] 29 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 Kim Jeong Ju 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /loglab/schema/__init__.py: -------------------------------------------------------------------------------- 1 | """Schema 모듈.""" 2 | 3 | # 하위 호환성을 위한 기존 함수들 4 | from .compatibility import log_schema_from_labfile, verify_labfile, verify_logfile 5 | from .config import SchemaConfig 6 | from .generator import LogSchemaGenerator 7 | from .implementations import ( 8 | DefaultErrorHandler, 9 | DefaultFileLoader, 10 | DefaultJsonValidator, 11 | ) 12 | from .interfaces import ErrorHandler, FileLoader, JsonValidator, ValidationResult 13 | from .log_validator import LogFileValidator 14 | from .models import DomainInfo, EventSchema, PropertyInfo 15 | from .property_builder import PropertyBuilder 16 | from .validator import SchemaValidator 17 | 18 | __all__ = [ 19 | "SchemaConfig", 20 | "ValidationResult", 21 | "FileLoader", 22 | "JsonValidator", 23 | "ErrorHandler", 24 | "DefaultFileLoader", 25 | "DefaultJsonValidator", 26 | "DefaultErrorHandler", 27 | "SchemaValidator", 28 | "LogSchemaGenerator", 29 | "LogFileValidator", 30 | "EventSchema", 31 | "PropertyInfo", 32 | "DomainInfo", 33 | "PropertyBuilder", 34 | # 하위 호환성 35 | "verify_labfile", 36 | "log_schema_from_labfile", 37 | "verify_logfile", 38 | ] 39 | -------------------------------------------------------------------------------- /tests/files/acme.lab.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://raw.githubusercontent.com/haje01/loglab/master/loglab/schema/lab.schema.json", 3 | "domain": { 4 | "name": "acme", 5 | "desc": "최고의 게임 회사" 6 | }, 7 | "types": { 8 | "unsigned": { 9 | "type": "integer", 10 | "desc": "0 이상의 정수", 11 | "minimum": 0 12 | } 13 | }, 14 | "bases": { 15 | "Server": { 16 | "desc": "서버 정보", 17 | "fields": [ 18 | { 19 | "name": "ServerNo", 20 | "desc": "서버 번호", 21 | "type": "integer", 22 | "minimum": 1, 23 | "exclusiveMaximum": 100 24 | } 25 | ] 26 | }, 27 | "Account": { 28 | "desc": "계정 정보", 29 | "mixins": ["bases.Server"], 30 | "fields": [ 31 | ["AcntId", "types.unsigned", "계정 ID"] 32 | ] 33 | } 34 | }, 35 | "events": { 36 | "Login": { 37 | "desc": "계정 로그인", 38 | "mixins": ["bases.Account"], 39 | "fields": [ 40 | { 41 | "name": "Platform", 42 | "desc": "디바이스의 플랫폼", 43 | "type": "string", 44 | "enum": [ 45 | "ios", "aos" 46 | ] 47 | } 48 | ] 49 | }, 50 | "Logout": { 51 | "desc": "계정 로그인", 52 | "mixins": ["bases.Account"], 53 | "fields": [ 54 | ["PlayTime", "number", "플레이 시간 (초)", true] 55 | ] 56 | } 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /example/acme.lab.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://raw.githubusercontent.com/haje01/loglab/master/loglab/schema/lab.schema.json", 3 | "domain": { 4 | "name": "acme", 5 | "desc": "최고의 게임 회사" 6 | }, 7 | "types": { 8 | "unsigned": { 9 | "type": "integer", 10 | "desc": "0 이상의 정수", 11 | "minimum": 0 12 | } 13 | }, 14 | "bases": { 15 | "Server": { 16 | "desc": "서버 정보", 17 | "fields": [ 18 | { 19 | "name": "ServerNo", 20 | "desc": "서버 번호", 21 | "type": "integer", 22 | "minimum": 1, 23 | "exclusiveMaximum": 100 24 | } 25 | ] 26 | }, 27 | "Account": { 28 | "desc": "계정 정보", 29 | "mixins": ["bases.Server"], 30 | "fields": [ 31 | ["AcntId", "types.unsigned", "계정 ID"] 32 | ] 33 | } 34 | }, 35 | "events": { 36 | "Login": { 37 | "desc": "ACME 계정 로그인", 38 | "mixins": ["bases.Account"], 39 | "fields": [ 40 | { 41 | "name": "Platform", 42 | "desc": "디바이스의 플랫폼", 43 | "type": "string", 44 | "enum": [ 45 | "ios", "aos" 46 | ] 47 | } 48 | ] 49 | }, 50 | "Logout": { 51 | "desc": "ACME 계정 로그아웃", 52 | "mixins": ["bases.Account"], 53 | "fields": [ 54 | ["PlayTime", "number", "플레이 시간 (초)", true] 55 | ] 56 | } 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /tests/files/acme2.lab.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://raw.githubusercontent.com/haje01/loglab/master/loglab/schema/lab.schema.json", 3 | "domain": { 4 | "name": "acme", 5 | "desc": "최고의 게임 회사" 6 | }, 7 | "import": ["bcom"], 8 | "types": { 9 | "unsigned": { 10 | "type": "integer", 11 | "desc": "0 이상의 정수", 12 | "minimum": 0 13 | } 14 | }, 15 | "bases": { 16 | "Server": { 17 | "desc": "서버 정보", 18 | "fields": [ 19 | { 20 | "name": "ServerNo", 21 | "desc": "서버 번호", 22 | "type": "integer", 23 | "minimum": 1, 24 | "exclusiveMaximum": 100 25 | } 26 | ] 27 | }, 28 | "Account": { 29 | "desc": "계정 정보", 30 | "mixins": ["bases.Server"], 31 | "fields": [ 32 | ["AcntId", "types.unsigned", "계정 ID"] 33 | ] 34 | } 35 | }, 36 | "events": { 37 | "Login": { 38 | "desc": "계정 로그인", 39 | "mixins": ["bcom.bases.BcomAcnt", "bases.Account"], 40 | "fields": [ 41 | { 42 | "name": "Platform", 43 | "desc": "디바이스의 플랫폼", 44 | "type": "string", 45 | "enum": [ 46 | "ios", "aos" 47 | ] 48 | } 49 | ] 50 | }, 51 | "Logout": { 52 | "desc": "계정 로그아웃", 53 | "mixins": ["bcom.bases.BcomAcnt", "bases.Account"], 54 | "fields": [ 55 | ["PlayTime", "number", "플레이 시간 (초)", true] 56 | ] 57 | } 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /loglab/schema/interfaces.py: -------------------------------------------------------------------------------- 1 | """Schema 모듈의 인터페이스 정의.""" 2 | 3 | from abc import ABC, abstractmethod 4 | from dataclasses import dataclass 5 | from typing import Any, Dict, List, Optional 6 | 7 | 8 | @dataclass 9 | class ValidationResult: 10 | """검증 결과를 담는 클래스.""" 11 | 12 | success: bool 13 | data: Optional[Dict[str, Any]] = None 14 | errors: List[str] = None 15 | 16 | def __post_init__(self): 17 | if self.errors is None: 18 | self.errors = [] 19 | 20 | 21 | class FileLoader(ABC): 22 | """파일 로딩 인터페이스.""" 23 | 24 | @abstractmethod 25 | def load(self, file_path: str) -> str: 26 | """파일을 로드하여 문자열로 반환.""" 27 | pass 28 | 29 | 30 | class JsonValidator(ABC): 31 | """JSON 스키마 검증 인터페이스.""" 32 | 33 | @abstractmethod 34 | def validate( 35 | self, data: Dict[str, Any], schema: Dict[str, Any] 36 | ) -> ValidationResult: 37 | """데이터를 스키마에 대해 검증.""" 38 | pass 39 | 40 | 41 | class ErrorHandler(ABC): 42 | """에러 처리 인터페이스.""" 43 | 44 | @abstractmethod 45 | def handle_validation_error( 46 | self, error: Exception, context: str = "" 47 | ) -> ValidationResult: 48 | """검증 에러 처리.""" 49 | pass 50 | 51 | @abstractmethod 52 | def handle_file_error( 53 | self, error: Exception, file_path: str = "" 54 | ) -> ValidationResult: 55 | """파일 에러 처리.""" 56 | pass 57 | -------------------------------------------------------------------------------- /locales/zh_CN/LC_MESSAGES/base.po: -------------------------------------------------------------------------------- 1 | # SOME DESCRIPTIVE TITLE. 2 | # Copyright (C) YEAR ORGANIZATION 3 | # FIRST AUTHOR , YEAR. 4 | # 5 | msgid "" 6 | msgstr "" 7 | "Project-Id-Version: PACKAGE VERSION\n" 8 | "POT-Creation-Date: 2021-10-26 18:45+0900\n" 9 | "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" 10 | "Last-Translator: FULL NAME \n" 11 | "Language-Team: LANGUAGE \n" 12 | "MIME-Version: 1.0\n" 13 | "Content-Type: text/plain; charset=UTF-8\n" 14 | "Content-Transfer-Encoding: 8bit\n" 15 | "Generated-By: pygettext.py 1.5\n" 16 | 17 | 18 | #: loglab/util.py:100 19 | msgid "이벤트 일시" 20 | msgstr "事件日期" 21 | 22 | #: loglab/util.py:126 loglab/util.py:175 23 | msgid "{} 중 하나" 24 | msgstr "{} 之一" 25 | 26 | #: loglab/util.py:135 27 | msgid "{} 이상" 28 | msgstr "{} 以上(含)" 29 | 30 | #: loglab/util.py:137 31 | msgid "{} 초과" 32 | msgstr "{} 以上" 33 | 34 | #: loglab/util.py:139 35 | msgid "{} 이하" 36 | msgstr "{} 以下(含)" 37 | 38 | #: loglab/util.py:141 39 | msgid "{} 미만" 40 | msgstr "{} 以下" 41 | 42 | #: loglab/util.py:162 43 | msgid "{} 자 이상" 44 | msgstr "{} 个字以上(含)" 45 | 46 | #: loglab/util.py:164 47 | msgid "{} 자 이하" 48 | msgstr "{} 个字以下(含)" 49 | 50 | #: loglab/util.py:177 51 | msgid "정규식 {} 매칭" 52 | msgstr "正则表达式 {}" 53 | 54 | #: loglab/util.py:179 55 | msgid "{} 형식" 56 | msgstr "{} 格式" 57 | 58 | #: loglab/util.py:33 59 | msgid "이 파일은 LogLab 에서 생성된 것입니다. 고치지 마세요!" 60 | msgstr "该文件由 LogLab 生成。 不要修理它!" 61 | 62 | #: util.py:207 63 | msgid "항상 {}" 64 | msgstr "始终 {}" 65 | -------------------------------------------------------------------------------- /locales/ja_JP/LC_MESSAGES/base.po: -------------------------------------------------------------------------------- 1 | # SOME DESCRIPTIVE TITLE. 2 | # Copyright (C) YEAR ORGANIZATION 3 | # FIRST AUTHOR , YEAR. 4 | # 5 | msgid "" 6 | msgstr "" 7 | "Project-Id-Version: PACKAGE VERSION\n" 8 | "POT-Creation-Date: 2021-10-26 18:45+0900\n" 9 | "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" 10 | "Last-Translator: FULL NAME \n" 11 | "Language-Team: LANGUAGE \n" 12 | "MIME-Version: 1.0\n" 13 | "Content-Type: text/plain; charset=UTF-8\n" 14 | "Content-Transfer-Encoding: 8bit\n" 15 | "Generated-By: pygettext.py 1.5\n" 16 | 17 | 18 | #: loglab/util.py:100 19 | msgid "이벤트 일시" 20 | msgstr "イベント日時" 21 | 22 | #: loglab/util.py:126 loglab/util.py:175 23 | msgid "{} 중 하나" 24 | msgstr "{} のいずれか" 25 | 26 | #: loglab/util.py:135 27 | msgid "{} 이상" 28 | msgstr "{} 以上" 29 | 30 | #: loglab/util.py:137 31 | msgid "{} 초과" 32 | msgstr "{} より大きい" 33 | 34 | #: loglab/util.py:139 35 | msgid "{} 이하" 36 | msgstr "{} 以下" 37 | 38 | #: loglab/util.py:141 39 | msgid "{} 미만" 40 | msgstr "{} 未満" 41 | 42 | #: loglab/util.py:162 43 | msgid "{} 자 이상" 44 | msgstr "{} 文字以上" 45 | 46 | #: loglab/util.py:164 47 | msgid "{} 자 이하" 48 | msgstr "{} 文字以下" 49 | 50 | #: loglab/util.py:177 51 | msgid "정규식 {} 매칭" 52 | msgstr "正規表現 {} にマッチ" 53 | 54 | #: loglab/util.py:179 55 | msgid "{} 형식" 56 | msgstr "{} 形式" 57 | 58 | #: loglab/util.py:33 59 | msgid "이 파일은 LogLab 에서 생성된 것입니다. 고치지 마세요!" 60 | msgstr "このファイルは LogLab によって生成されました。修正しないでください!" 61 | 62 | #: util.py:207 63 | msgid "항상 {}" 64 | msgstr "常に {}" 65 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | # Python dependencies 4 | - package-ecosystem: "pip" 5 | directory: "/" 6 | schedule: 7 | interval: "weekly" 8 | day: "monday" 9 | time: "09:00" 10 | open-pull-requests-limit: 10 11 | reviewers: 12 | - "haje01" 13 | assignees: 14 | - "haje01" 15 | commit-message: 16 | prefix: "deps" 17 | include: "scope" 18 | ignore: 19 | # Ignore major version updates for stable dependencies 20 | - dependency-name: "jsonschema" 21 | update-types: ["version-update:semver-major"] 22 | groups: 23 | # Group development dependencies 24 | dev-dependencies: 25 | patterns: 26 | - "pytest*" 27 | - "coverage" 28 | - "black" 29 | - "flake8" 30 | - "isort" 31 | - "pre-commit" 32 | - "tox" 33 | - "bandit" 34 | - "safety" 35 | # Group production dependencies 36 | production-dependencies: 37 | patterns: 38 | - "click" 39 | - "jsonschema" 40 | - "jinja2" 41 | - "tabulate" 42 | - "requests" 43 | 44 | # GitHub Actions 45 | - package-ecosystem: "github-actions" 46 | directory: "/.github/workflows" 47 | schedule: 48 | interval: "weekly" 49 | day: "monday" 50 | time: "09:00" 51 | open-pull-requests-limit: 5 52 | reviewers: 53 | - "haje01" 54 | assignees: 55 | - "haje01" 56 | commit-message: 57 | prefix: "ci" 58 | include: "scope" 59 | -------------------------------------------------------------------------------- /locales/en_US/LC_MESSAGES/base.po: -------------------------------------------------------------------------------- 1 | # SOME DESCRIPTIVE TITLE. 2 | # Copyright (C) YEAR ORGANIZATION 3 | # FIRST AUTHOR , YEAR. 4 | # 5 | msgid "" 6 | msgstr "" 7 | "Project-Id-Version: PACKAGE VERSION\n" 8 | "POT-Creation-Date: 2021-10-26 18:45+0900\n" 9 | "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" 10 | "Last-Translator: FULL NAME \n" 11 | "Language-Team: LANGUAGE \n" 12 | "MIME-Version: 1.0\n" 13 | "Content-Type: text/plain; charset=UTF-8\n" 14 | "Content-Transfer-Encoding: 8bit\n" 15 | "Generated-By: pygettext.py 1.5\n" 16 | 17 | 18 | #: loglab/util.py:100 19 | msgid "이벤트 일시" 20 | msgstr "Event date time" 21 | 22 | #: loglab/util.py:126 loglab/util.py:175 23 | msgid "{} 중 하나" 24 | msgstr "one of {}" 25 | 26 | #: loglab/util.py:135 27 | msgid "{} 이상" 28 | msgstr "{} or above" 29 | 30 | #: loglab/util.py:137 31 | msgid "{} 초과" 32 | msgstr "above {}" 33 | 34 | #: loglab/util.py:139 35 | msgid "{} 이하" 36 | msgstr "{} or below" 37 | 38 | #: loglab/util.py:141 39 | msgid "{} 미만" 40 | msgstr "below {}" 41 | 42 | #: loglab/util.py:162 43 | msgid "{} 자 이상" 44 | msgstr "{} or more characters" 45 | 46 | #: loglab/util.py:164 47 | msgid "{} 자 이하" 48 | msgstr "{} or less characters" 49 | 50 | #: loglab/util.py:177 51 | msgid "정규식 {} 매칭" 52 | msgstr "match regexp {}" 53 | 54 | #: loglab/util.py:179 55 | msgid "{} 형식" 56 | msgstr "format {}" 57 | 58 | #: loglab/util.py:33 59 | msgid "이 파일은 LogLab 에서 생성된 것입니다. 고치지 마세요!" 60 | msgstr "This file was generated by LogLab. DO NOT EDIT!" 61 | 62 | #: util.py:207 63 | msgid "항상 {}" 64 | msgstr "always {}" 65 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | import os 3 | 4 | with open(os.path.join("loglab", "version.py"), "rt") as f: 5 | version = f.read().strip() 6 | version = version.split("=")[1].strip().strip('"') 7 | 8 | __version__ = version 9 | 10 | from setuptools import find_packages, setup 11 | 12 | if os.name == "nt": 13 | SCRIPTS = ["bin/loglab.bat"] 14 | else: 15 | SCRIPTS = ["bin/loglab"] 16 | 17 | setup( 18 | name="loglab", 19 | version=__version__, 20 | author="JeongJu Kim", 21 | author_email="haje01@gmail.com", 22 | url="https://github.com/haje01/loglab", 23 | description="Tools that help you design and utilize log formats", 24 | platforms=["any"], 25 | python_requires=">=3.8", 26 | packages=find_packages(), 27 | package_data={ 28 | "loglab": [ 29 | "schema/lab.schema.json", 30 | "template/**/*", 31 | ], 32 | "": [ 33 | "locales/*/LC_MESSAGES/*.mo", 34 | "locales/*/LC_MESSAGES/*.po", 35 | ], 36 | }, 37 | include_package_data=True, 38 | scripts=SCRIPTS, 39 | license="MIT License", 40 | install_requires=[ 41 | "click>=8.0.0", 42 | "jsonschema==3.2.0", 43 | "jinja2", 44 | "tabulate[widechars]", 45 | "requests", 46 | ], 47 | extras_require={ 48 | "dev": ["pytest", "coverage", "pyinstaller", "tox"], 49 | }, 50 | classifiers=[ 51 | "License :: OSI Approved :: MIT License", 52 | "Intended Audience :: Developers", 53 | "Programming Language :: Python :: 3.9", 54 | ], 55 | ) 56 | -------------------------------------------------------------------------------- /docs/introduction.rst: -------------------------------------------------------------------------------- 1 | 2 | 소개 3 | ==== 4 | 5 | LogLab(로그랩) 은 로그를 효율적으로 설계하고 활용하기 위한 툴이다. 6 | 7 | 8 | 기능 9 | ---- 10 | 11 | 로그랩은 다음과 같은 기능을 가지고 있다. 12 | 13 | - 로그를 객체지향적이며 재활용 가능한 형태로 설계 14 | - 설계된 로그에 관한 문서 자동 생성 15 | - 실제 출력된 로그가 설계에 맞게 작성되었는지 검증 16 | 17 | 로그의 형식 18 | ---------- 19 | 20 | 로그랩은 `JSON Lines `_ 형식으로 파일에 남기는 로그를 대상으로 한다. JSON Lines 는 아래 예처럼 한 라인 한 라인이 유효한 JSON 객체가 되어야 한다. 21 | 22 | .. code-block:: 23 | 24 | {"DateTime": "2021-08-13T20:20:39+09:00", "Event": "Login", "ServerNo": 1, "AcntId": 1000} 25 | {"DateTime": "2021-08-13T20:21:01+09:00", "Event": "Logout", "ServerNo": 1, "AcntId": 1000} 26 | 27 | 28 | 가능한 질문 29 | ---------------- 30 | 31 | 다음과 같은 몇 가지 의문을 제기할 수 있겠다. 32 | 33 | **왜 자유로운 형식의 텍스트 로그가 아닌가?** 34 | 35 | 완전 비정형 텍스트 로그는 작성하는 측에서는 편하지만, 파싱 및 관리가 힘들기에 원하는 정보를 추출하는데 한계가 있다. 36 | 37 | **왜 CSV 가 아닌가?** 38 | 39 | CSV 는 서비스 중 필드의 수나 위치가 변할 때 대응하기 까다로우며, 필드 값에 개행 문자나 구분자가 포함되면 파싱에 상당한 어려움을 겪게 된다. JSON 은 `키:값` 의 형식으로 언뜻 장황 (Verbose) 해 보이지만, 알아보기 쉽고 로그 구조 변경이 용이한 장점이 있다. 또한, 키는 반복적으로 나타나기에 압축하면 상당히 용량이 줄어든다. 40 | 41 | **왜 DB에 남기지 않는가?** 42 | 43 | DB에 남기는 로그는 잘 구조화되어 정보 활용 측면에서 뛰어나지만 다음과 같은 문제점이 있다. 44 | 45 | - 로그 구조의 변경이 까다롭다. 46 | - 시스템이 불안정할 때는 파일 로그보다 안정성이 떨어진다. 47 | - 파일 로그는 별도의 장비없이 손쉽게 남길 수 있고, 관리 부담이 적다. 48 | 49 | **왜 로그를 설계해야 하는가?** 50 | 51 | DB 와 달리 로그는 설계없이 그때그때 자유롭게 남기는 것이 미덕일 수 있겠으나, 로그 종류가 늘어나고 로그에 담긴 정보가 분석의 대상이 되는 시점부터 체계화된 로그가 필요하게 된다. 52 | 53 | 대상 사용자 54 | ---------- 55 | 56 | 로그랩은 다음과 같은 입장의 사용자에게 도움이 될 수 있다. 57 | 58 | - 서비스를 위한 로그 설계가 필요한 개발자 59 | - 로그를 처리하고 분석하는 데이터 엔지니어/분석가 60 | - 조직에서 생성되는 로그의 형식을 일관되게 유지/공유 하고 싶은 관리자 61 | 62 | .. note:: 63 | 64 | 로그랩은 윈도우, Linux, MacOS 에서 사용할 수 있다. 이후 설명은 Linux 를 중심으로 하겠으나, 다른 OS 에서도 큰 차이없이 동작할 것이다. 65 | -------------------------------------------------------------------------------- /tests/test_log_objects_python.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | """ 3 | Basic tests for Python log objects generated from foo.lab.json 4 | """ 5 | import json 6 | import os 7 | import tempfile 8 | from datetime import datetime 9 | 10 | from loglab_foo import * 11 | 12 | 13 | def test_basic_log_objects(): 14 | """Test basic functionality of generated log objects""" 15 | 16 | # Test Login event 17 | login = Login(1, 10000, "ios") 18 | login.AcntId = 12345 19 | login.ServerNo = 1 20 | login.Platform = "ios" 21 | 22 | # Test serialization 23 | login_json = login.serialize() 24 | login_data = json.loads(login_json) 25 | 26 | assert login_data["AcntId"] == 12345 27 | assert login_data["ServerNo"] == 1 28 | assert login_data["Platform"] == "ios" 29 | assert "DateTime" in login_data 30 | assert login_data["Category"] == 1 31 | 32 | # Test Logout event 33 | logout = Logout(1, 10000) 34 | logout.AcntId = 12345 35 | logout.ServerNo = 1 36 | logout.PlayTime = 3600.5 37 | 38 | logout_json = logout.serialize() 39 | logout_data = json.loads(logout_json) 40 | 41 | assert logout_data["AcntId"] == 12345 42 | assert logout_data["PlayTime"] == 3600.5 43 | 44 | # Test KillMonster event 45 | kill_monster = KillMonster(1, 1234, 5678, 1001, 100.5, 200.7, 0.0, 5001, 999888) 46 | kill_json = kill_monster.serialize() 47 | kill_data = json.loads(kill_json) 48 | 49 | assert kill_data["CharId"] == 5678 50 | assert kill_data["MonsterCd"] == 5001 51 | assert kill_data["PosX"] == 100.5 52 | 53 | # Test reset functionality 54 | login.reset(2, 20000, "aos") 55 | login_reset_json = login.serialize() 56 | login_reset_data = json.loads(login_reset_json) 57 | 58 | assert login_reset_data["AcntId"] == 20000 59 | assert login_reset_data["ServerNo"] == 2 60 | assert login_reset_data["Platform"] == "aos" 61 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | repos: 2 | - repo: https://github.com/pre-commit/pre-commit-hooks 3 | rev: v5.0.0 4 | hooks: 5 | - id: trailing-whitespace 6 | - id: end-of-file-fixer 7 | - id: check-yaml 8 | - id: check-json 9 | - id: check-added-large-files 10 | - id: check-merge-conflict 11 | - id: debug-statements 12 | 13 | - repo: https://github.com/psf/black 14 | rev: 25.1.0 15 | hooks: 16 | - id: black 17 | language_version: python3 18 | args: [--line-length=88] 19 | 20 | - repo: https://github.com/pycqa/isort 21 | rev: 6.0.1 22 | hooks: 23 | - id: isort 24 | args: [--profile=black, --line-length=88] 25 | 26 | - repo: https://github.com/pycqa/flake8 27 | rev: 7.3.0 28 | hooks: 29 | - id: flake8 30 | 31 | - repo: local 32 | hooks: 33 | - id: pytest-check 34 | name: pytest-check 35 | entry: python -m pytest tests/ --tb=short 36 | language: system 37 | pass_filenames: false 38 | always_run: true 39 | stages: [pre-commit] 40 | 41 | - id: coverage-check 42 | name: coverage-check 43 | entry: bash -c 'coverage run --source loglab -m pytest tests/ && coverage report --fail-under=80' 44 | language: system 45 | pass_filenames: false 46 | always_run: true 47 | stages: [pre-commit] 48 | 49 | # Bandit disabled due to pre-commit integration issues 50 | # - repo: https://github.com/pycqa/bandit 51 | # rev: 1.8.6 52 | # hooks: 53 | # - id: bandit 54 | # args: [-r, loglab/, --exit-zero] 55 | # exclude: tests/ 56 | 57 | - repo: local 58 | hooks: 59 | - id: safety-check 60 | name: safety-check 61 | entry: bash -c 'safety check || true' 62 | language: system 63 | pass_filenames: false 64 | always_run: true 65 | stages: [pre-commit] 66 | -------------------------------------------------------------------------------- /tests/javatest/pom.xml: -------------------------------------------------------------------------------- 1 | 2 | 6 | 4.0.0 7 | 8 | loglab 9 | javatest 10 | 1.0.0 11 | jar 12 | 13 | LogLab Java Test 14 | Test project for LogLab Java log objects 15 | 16 | 17 | 8 18 | 8 19 | UTF-8 20 | 21 | 22 | 23 | 24 | com.fasterxml.jackson.core 25 | jackson-databind 26 | 2.15.2 27 | 28 | 29 | 30 | 31 | 32 | 33 | org.apache.maven.plugins 34 | maven-compiler-plugin 35 | 3.11.0 36 | 37 | 8 38 | 8 39 | 40 | 41 | 42 | org.codehaus.mojo 43 | exec-maven-plugin 44 | 3.1.0 45 | 46 | loglab_foo.TestLogObjects 47 | 48 | 49 | 50 | 51 | 52 | -------------------------------------------------------------------------------- /docs/installation.rst: -------------------------------------------------------------------------------- 1 | 설치 2 | ================ 3 | 4 | **uv 기반 설치 (권장)** 5 | 6 | 먼저 Python 용 패키지 매니저인 `uv` 의 설치가 필요하다. `uv 설치 페이지 `_ 를 참고하여 사용자의 환경에 맞게 설치하도록 한다. 7 | 8 | 이제 다음과 같은 `uv` 명령으로 LogLab 을 설치한다. 9 | 10 | .. code:: bash 11 | 12 | uv tool install --from git+https://github.com/haje01/loglab.git loglab 13 | 14 | .. note:: 15 | 16 | 만약 기존에 설치된 loglab 을 최신 버전으로 업그레이드하고 싶다면, 다음처럼 진행한다. 17 | 18 | .. code:: bash 19 | 20 | uv tool upgrade loglab 21 | 22 | 23 | **소스 코드로 설치** 24 | 25 | 최신 소스 코드를 기반으로 다음처럼 개발용으로 설치도 가능하다. 26 | 27 | .. code:: bash 28 | 29 | git clone https://github.com/haje01/loglab 30 | cd loglab 31 | uv venv 32 | source .venv/bin/activate 33 | uv pip install -e . 34 | 35 | 설치가 잘 되었다면 로그랩의 커맨드라인 툴인 `loglab` 을 이용할 수 있다. 다음과 같이 입력해보자. 36 | 37 | .. code:: bash 38 | 39 | $ loglab 40 | 41 | Usage: loglab [OPTIONS] COMMAND [ARGS]... 42 | 43 | LogLab 메인 CLI 그룹. 44 | 45 | LogLab의 모든 명령어들을 그룹화하는 메인 진입점. 46 | 47 | Options: 48 | -v, --verbose 디버깅 정보 출력 레벨 증가 (-v: INFO, -vv: DEBUG) 49 | --help Show this message and exit. 50 | 51 | Commands: 52 | html 랩 파일로부터 HTML 문서를 생성. 53 | object 랩 파일로부터 로그 작성용 코드 객체를 생성. 54 | schema 랩 파일로부터 로그 검증용 JSON 스키마를 생성. 55 | show 랩 파일의 내용을 텍스트 형태로 출력. 56 | verify 실제 로그 파일이 스키마에 맞는지 검증. 57 | version 로그랩의 현재 버전을 표시. 58 | 59 | .. code-block:: bash 60 | 61 | $ loglab 62 | Usage: loglab [OPTIONS] COMMAND [ARGS]... 63 | 64 | Options: 65 | -v, --verbose 디버깅 정보 출력 레벨 증가 (-v: INFO, -vv: DEBUG) 66 | --help Show this message and exit. 67 | 68 | Commands: 69 | html 랩 파일로부터 HTML 문서를 생성. 70 | object 랩 파일로부터 로그 작성용 코드 객체를 생성. 71 | schema 랩 파일로부터 로그 검증용 JSON 스키마를 생성. 72 | show 랩 파일의 내용을 텍스트 형태로 출력. 73 | verify 실제 로그 파일이 스키마에 맞는지 검증. 74 | version 로그랩의 현재 버전을 표시. 75 | 76 | 위에서 알 수 있듯 `loglab` 에는 다양한 명령어가 있는데 예제를 통해 하나씩 살펴보도록 하겠다. 먼저 간단히 버전을 확인해보자. 77 | 78 | .. code-block:: bash 79 | 80 | $ loglab version 81 | 0.3.0 82 | -------------------------------------------------------------------------------- /loglab/schema/implementations.py: -------------------------------------------------------------------------------- 1 | """Schema 인터페이스의 기본 구현체들.""" 2 | 3 | import json 4 | import os 5 | from typing import Any, Dict 6 | 7 | from jsonschema import ValidationError, validate 8 | 9 | from loglab.util import load_file_from 10 | 11 | from .interfaces import ErrorHandler, FileLoader, JsonValidator, ValidationResult 12 | 13 | 14 | class DefaultFileLoader(FileLoader): 15 | """기본 파일 로더 구현.""" 16 | 17 | def load(self, file_path: str) -> str: 18 | """파일을 로드하여 문자열로 반환.""" 19 | return load_file_from(file_path) 20 | 21 | 22 | class DefaultJsonValidator(JsonValidator): 23 | """기본 JSON 스키마 검증기 구현.""" 24 | 25 | def validate( 26 | self, data: Dict[str, Any], schema: Dict[str, Any] 27 | ) -> ValidationResult: 28 | """데이터를 스키마에 대해 검증.""" 29 | try: 30 | validate(data, schema) 31 | return ValidationResult(success=True, data=data) 32 | except ValidationError as e: 33 | return ValidationResult( 34 | success=False, errors=[f"Validation error: {e.message}"] 35 | ) 36 | except Exception as e: 37 | return ValidationResult( 38 | success=False, errors=[f"Unexpected validation error: {str(e)}"] 39 | ) 40 | 41 | 42 | class DefaultErrorHandler(ErrorHandler): 43 | """기본 에러 처리기 구현.""" 44 | 45 | def handle_validation_error( 46 | self, error: Exception, context: str = "" 47 | ) -> ValidationResult: 48 | """검증 에러 처리.""" 49 | error_msg = f"Validation error" 50 | if context: 51 | error_msg += f" in {context}" 52 | error_msg += f": {str(error)}" 53 | 54 | return ValidationResult(success=False, errors=[error_msg]) 55 | 56 | def handle_file_error( 57 | self, error: Exception, file_path: str = "" 58 | ) -> ValidationResult: 59 | """파일 에러 처리.""" 60 | if isinstance(error, FileNotFoundError): 61 | error_msg = f"File not found: {file_path or 'unknown'}" 62 | elif isinstance(error, json.JSONDecodeError): 63 | error_msg = f"Invalid JSON in file: {file_path or 'unknown'}" 64 | else: 65 | error_msg = f"File error: {str(error)}" 66 | 67 | return ValidationResult(success=False, errors=[error_msg]) 68 | -------------------------------------------------------------------------------- /loglab/schema/compatibility.py: -------------------------------------------------------------------------------- 1 | """하위 호환성을 위한 기존 함수들의 래퍼.""" 2 | 3 | import logging 4 | import sys 5 | from typing import Any, Dict, Optional 6 | 7 | from .generator import LogSchemaGenerator 8 | from .log_validator import LogFileValidator 9 | from .validator import SchemaValidator 10 | 11 | 12 | def verify_labfile( 13 | lab_path: str, scm_path: Optional[str] = None, err_exit: bool = True 14 | ) -> Optional[Dict[str, Any]]: 15 | """lab 파일의 구조와 내용을 JSON 스키마로 검증. 16 | 17 | 기존 함수와의 하위 호환성을 위한 래퍼 함수. 18 | 새로운 SchemaValidator 클래스를 사용하여 구현. 19 | 20 | Args: 21 | lab_path: 검증할 랩 파일 경로 22 | scm_path: 사용할 스키마 파일 경로. None이면 기본 스키마 사용 23 | err_exit: 에러 발생시 프로그램 종료 여부. 기본 True 24 | 25 | Returns: 26 | dict: 검증이 완료된 랩 파일 데이터 (성공시에만) 27 | 28 | Raises: 29 | SystemExit: err_exit=True이고 검증 실패시 30 | """ 31 | validator = SchemaValidator() 32 | result = validator.verify_labfile(lab_path, scm_path) 33 | 34 | if not result.success: 35 | logging.error("Error: 랩 파일 검증 에러") 36 | for error in result.errors: 37 | logging.error(error) 38 | if err_exit: 39 | sys.exit(1) 40 | return None 41 | 42 | return result.data 43 | 44 | 45 | def log_schema_from_labfile(data: Dict[str, Any]) -> str: 46 | """lab 파일 데이터로부터 실제 로그 검증용 JSON 스키마를 동적 생성. 47 | 48 | 기존 함수와의 하위 호환성을 위한 래퍼 함수. 49 | 새로운 LogSchemaGenerator 클래스를 사용하여 구현. 50 | 51 | Args: 52 | data: 빌드된 lab 모델 데이터 53 | 54 | Returns: 55 | str: JSON 형태의 로그 검증 스키마 문자열 56 | """ 57 | generator = LogSchemaGenerator() 58 | return generator.generate_schema(data) 59 | 60 | 61 | def verify_logfile(schema: str, logfile: str) -> None: 62 | """실제 로그 파일이 생성된 스키마에 맞는지 검증. 63 | 64 | 기존 함수와의 하위 호환성을 위한 래퍼 함수. 65 | 새로운 LogFileValidator 클래스를 사용하여 구현. 66 | 67 | Args: 68 | schema: 로그 검증용 JSON 스키마 파일 경로 69 | logfile: 검증할 로그 파일 경로 70 | 71 | Raises: 72 | SystemExit: 스키마 파일이 잘못되었거나 로그 검증 실패시 73 | """ 74 | validator = LogFileValidator() 75 | result = validator.validate_logfile(schema, logfile) 76 | 77 | if not result.success: 78 | for error in result.errors: 79 | error_msg = f"Error: {error}" 80 | logging.error(error_msg) 81 | print(error_msg) # stdout에도 출력하여 테스트에서 확인 가능하도록 82 | sys.exit(1) 83 | -------------------------------------------------------------------------------- /loglab/template/tmpl_obj.py.jinja: -------------------------------------------------------------------------------- 1 | """ 2 | ** {{ warn }} ** 3 | 4 | Domain: {{ domain.name }} 5 | Description: {{ domain.desc }} 6 | """ 7 | import json 8 | from datetime import datetime{% if utc %}, timezone{% endif %} 9 | 10 | from typing import Optional 11 | 12 | {% for ename in events.keys() %} 13 | 14 | class {{ename}}: 15 | """{{ events[ename][-1][1]['desc'] }}""" 16 | {% set fields = events[ename][-1][1]['fields'] %} 17 | {% set fields = events[ename][-1][1]['fields'] %} 18 | {% set rfields = fields | required %} 19 | {% set ofields = fields | optional %} 20 | {% set cfields = fields | const %} 21 | 22 | def __init__(self, {% for fname in rfields.keys() %}{% set field = fields[fname][-1][1] %}_{{ fname }}: {{ type(field) }}{% if not loop.last %}, {% endif %}{% endfor %}): 23 | self.reset({% for fname in rfields.keys() %}{% set field = fields[fname][-1][1] %}_{{ fname }}{% if not loop.last %}, {% endif %}{% endfor %}) 24 | 25 | def reset(self, {% for fname in rfields.keys() %}{% set field = fields[fname][-1][1] %}_{{ fname }}: {{ type(field) }}{% if not loop.last %}, {% endif %}{% endfor %}): 26 | {% for fname in fields.keys() %}{% if fname != 'DateTime'%} 27 | {% set field = fields[fname][-1][1] %} 28 | {% if fname not in cfields %} 29 | self.{{fname}} = {% if fname in ofields %}None 30 | {% else %}_{{ fname }} 31 | {% endif %}{% endif %} 32 | {% endif %} 33 | {% endfor %} 34 | 35 | def serialize(self): 36 | data = dict(DateTime={% if utc %}datetime.now(timezone.utc).isoformat(){% else %}datetime.now().astimezone().isoformat(){% endif %}, 37 | Event="{{ ename }}") 38 | {% for fname in fields %} 39 | {% if fname != 'DateTime' %} 40 | {% set field = fields[fname][-1][1] %} 41 | {% if fname in rfields %} 42 | data["{{ fname }}"] = self.{{ fname }}{% if type(field) == 'datetime' %}.isoformat(){% endif %} 43 | 44 | {% elif fname in ofields %} 45 | if self.{{ fname }} is not None: 46 | data["{{ fname }}"] = self.{{ fname }}{% if type(field) == 'datetime' %}.isoformat(){% endif %} 47 | 48 | {% elif fname in cfields %} 49 | {% set ftype, fval = cfields[fname] %} 50 | data["{{ fname }}"] = {% if ftype == 'string' %}"{% endif %}{{ fval }}{% if ftype == 'string' %}"{% endif %} 51 | 52 | {% endif %} 53 | {% endif %} 54 | {% endfor %} 55 | return json.dumps(data) 56 | 57 | {% endfor %} 58 | -------------------------------------------------------------------------------- /tests/cstest/test_log_objects_csharp.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.IO; 3 | using System.Diagnostics; 4 | using System.Text.Json; 5 | 6 | using loglab_foo; 7 | 8 | 9 | namespace LogLabTests 10 | { 11 | /// 12 | /// Basic tests for C# log objects generated from foo.lab.json 13 | /// 14 | public class LogObjectTests 15 | { 16 | public static void Main(string[] args) 17 | { 18 | Console.WriteLine("Testing C# Log Objects..."); 19 | 20 | // Note: This is a basic test structure 21 | // In a real scenario, you would generate the objects and compile them 22 | // For this basic test, we'll simulate the expected behavior 23 | 24 | try 25 | { 26 | // Test basic object creation and serialization 27 | TestBasicFunctionality(); 28 | 29 | Console.WriteLine("✓ All C# log object tests passed!"); 30 | } 31 | catch (Exception ex) 32 | { 33 | Console.WriteLine($"✗ Test failed: {ex.Message}"); 34 | Environment.Exit(1); 35 | } 36 | } 37 | 38 | private static void TestBasicFunctionality() 39 | { 40 | Console.WriteLine("Testing basic log object functionality..."); 41 | 42 | // This is a simplified test that demonstrates expected behavior 43 | // In practice, you would need to: 44 | // 1. Generate the actual C# objects from foo.lab.json 45 | // 2. Compile them into a library 46 | // 3. Reference and test the compiled objects 47 | 48 | // Simulate Login event data 49 | Login login = new Login(1, 12345, "ios"); 50 | string loginJson = login.Serialize(); 51 | Console.WriteLine($"Login JSON: {loginJson}"); 52 | Console.WriteLine("✓ Login event serialization test passed"); 53 | 54 | // Simulate Logout event data 55 | var logout = new Logout(1, 12345); 56 | string logoutJson = logout.Serialize(); 57 | Console.WriteLine($"Logout JSON: {logoutJson}"); 58 | Console.WriteLine("✓ Logout event serialization test passed"); 59 | 60 | // Simulate KillMonster event data 61 | var killMonster = new KillMonster(1, 12345, 67890, 1001, 100.5f, 200.7f, 0.0f, 5001, 999888); 62 | string killMonsterJson = killMonster.Serialize(); 63 | Console.WriteLine($"KillMonster JSON: {killMonsterJson}"); 64 | Console.WriteLine("✓ KillMonster event serialization test passed"); 65 | 66 | Console.WriteLine("✓ Basic functionality tests completed"); 67 | } 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .loglab/ 2 | htmlcov/ 3 | dist/ 4 | tests/cstest/bin 5 | tests/cstest/obj 6 | .vscode/ 7 | tests/*.cs 8 | *.html 9 | # Byte-compiled / optimized / DLL files 10 | __pycache__/ 11 | *.py[cod] 12 | *$py.class 13 | tests/loglab_foo.py 14 | tests/loglab_foo.cs 15 | tests/loglab_foo.h 16 | tests/test_log_objects_cpp 17 | 18 | # C extensions 19 | *.so 20 | 21 | # Distribution / packaging 22 | .Python 23 | build/ 24 | develop-eggs/ 25 | dist/ 26 | downloads/ 27 | eggs/ 28 | .eggs/ 29 | lib/ 30 | lib64/ 31 | parts/ 32 | sdist/ 33 | var/ 34 | wheels/ 35 | *.egg-info/ 36 | .installed.cfg 37 | *.egg 38 | MANIFEST 39 | 40 | # PyInstaller 41 | # Usually these files are written by a python script from a template 42 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 43 | *.manifest 44 | *.spec 45 | 46 | # Installer logs 47 | pip-log.txt 48 | pip-delete-this-directory.txt 49 | 50 | # Unit test / coverage reports 51 | htmlcov/ 52 | .tox/ 53 | .coverage 54 | .coverage.* 55 | .cache 56 | nosetests.xml 57 | coverage.xml 58 | *.cover 59 | .hypothesis/ 60 | .pytest_cache/ 61 | 62 | # Translations 63 | # *.mo 64 | *.pot 65 | 66 | # Django stuff: 67 | *.log 68 | local_settings.py 69 | db.sqlite3 70 | 71 | # Flask stuff: 72 | instance/ 73 | .webassets-cache 74 | 75 | # Scrapy stuff: 76 | .scrapy 77 | 78 | # Sphinx documentation 79 | docs/_build/ 80 | 81 | # PyBuilder 82 | target/ 83 | 84 | # Jupyter Notebook 85 | .ipynb_checkpoints 86 | 87 | # pyenv 88 | .python-version 89 | 90 | # celery beat schedule file 91 | celerybeat-schedule 92 | 93 | # SageMath parsed files 94 | *.sage.py 95 | 96 | # Environments 97 | .env 98 | .venv 99 | env/ 100 | venv/ 101 | ENV/ 102 | env.bak/ 103 | venv.bak/ 104 | 105 | # Spyder project settings 106 | .spyderproject 107 | .spyproject 108 | 109 | # Rope project settings 110 | .ropeproject 111 | 112 | # mkdocs documentation 113 | /site 114 | 115 | # mypy 116 | .mypy_cache/ 117 | .dmypy.json 118 | dmypy.json 119 | 120 | # Additional LogLab specific files 121 | *.schema.json 122 | !loglab/schema/lab.schema.json 123 | loglab_*.py 124 | loglab_*.cs 125 | loglab_*.h 126 | loglab_*.ts 127 | loglab_*.java 128 | tests/**/loglab_*.ts 129 | tests/**/loglab_*.java 130 | tests/**/loglab_*.cs 131 | tests/**/loglab_*.h 132 | tests/**/LogLabFoo.java 133 | tests/**/test_log_objects_cpp 134 | fakelog.txt 135 | 136 | # Node.js / TypeScript 137 | node_modules/ 138 | npm-debug.log* 139 | yarn-debug.log* 140 | yarn-error.log* 141 | package-lock.json 142 | *.tsbuildinfo 143 | dist/ 144 | 145 | # Java / Maven 146 | target/ 147 | *.class 148 | *.jar 149 | *.war 150 | *.ear 151 | *.nar 152 | hs_err_pid* 153 | 154 | # IDE files 155 | .idea/ 156 | *.swp 157 | *.swo 158 | *~ 159 | 160 | # OS files 161 | .DS_Store 162 | Thumbs.db 163 | 164 | # Temporary files 165 | *.tmp 166 | *.temp 167 | temp/ 168 | tmp/ 169 | 170 | # Pre-commit 171 | .pre-commit-config.yaml.backup 172 | -------------------------------------------------------------------------------- /loglab/template/tmpl_obj.ts.jinja: -------------------------------------------------------------------------------- 1 | /* 2 | 3 | ** {{ warn }} ** 4 | 5 | Domain: {{ domain.name }} 6 | Description: {{ domain.desc }} 7 | 8 | */ 9 | 10 | {% for ename in events.keys() %} 11 | 12 | /** 13 | * {{ events[ename][-1][1]['desc'] }} 14 | */ 15 | export class {{ename}} { 16 | {% set fields = events[ename][-1][1]['fields'] %} 17 | {% set rfields = fields | required %} 18 | {% set ofields = fields | optional %} 19 | {% set cfields = fields | const %} 20 | public readonly Event = "{{ ename }}"; 21 | {% for fname in fields.keys() %}{% if fname != 'DateTime' and fname not in cfields %} 22 | {% set field = fields[fname][-1][1] %} 23 | // {{ field['desc'] }} 24 | {% if fname in ofields %} 25 | public {{ fname }}: {{ type(field) }} | null = null; 26 | {% else %} 27 | public {{ fname }}: {{ type(field) }}; 28 | {% endif %}{% endif %}{% endfor %} 29 | 30 | constructor({% for fname in rfields.keys() %}{% set field = fields[fname][-1][1] %}_{{ fname }}: {{ type(field) }}{% if not loop.last %}, {% endif %}{% endfor %}) { 31 | this.reset({% for fname in rfields.keys() %}{% set field = fields[fname][-1][1] %}_{{ fname }}{% if not loop.last %}, {% endif %}{% endfor %}); 32 | } 33 | 34 | public reset({% for fname in rfields.keys() %}{% set field = fields[fname][-1][1] %}_{{ fname }}: {{ type(field) }}{% if not loop.last %}, {% endif %}{% endfor %}): void { 35 | {% for fname in fields.keys() %}{% if fname != 'DateTime'%} 36 | {% set field = fields[fname][-1][1] %} 37 | {% if fname not in cfields %} 38 | {% if fname in ofields %} 39 | this.{{ fname }} = null; 40 | {% else %} 41 | this.{{ fname }} = _{{ fname }}; 42 | {% endif %}{% endif %} 43 | {% endif %} 44 | {% endfor %} 45 | } 46 | 47 | public serialize(): string { 48 | const data: Record = { 49 | DateTime: {% if utc %}new Date().toISOString(){% else %}(() => { 50 | const now = new Date(); 51 | const offset = now.getTimezoneOffset(); 52 | const sign = offset > 0 ? '-' : '+'; 53 | const absOffset = Math.abs(offset); 54 | const hours = ('0' + Math.floor(absOffset / 60)).slice(-2); 55 | const minutes = ('0' + (absOffset % 60)).slice(-2); 56 | return now.toISOString().slice(0, -1) + sign + hours + ':' + minutes; 57 | })(){% endif %}, 58 | Event: "{{ ename }}" 59 | }; 60 | {% for fname in fields %} 61 | {% if fname != 'DateTime' %} 62 | {% set field = fields[fname][-1][1] %} 63 | {% if fname in rfields %} 64 | data["{{ fname }}"] = {% if type(field) == 'Date' %}this.{{ fname }}.toISOString(){% else %}this.{{ fname }}{% endif %}; 65 | {% elif fname in ofields %} 66 | if (this.{{ fname }} !== null) { 67 | data["{{ fname }}"] = {% if type(field) == 'Date' %}this.{{ fname }}.toISOString(){% else %}this.{{ fname }}{% endif %}; 68 | } 69 | {% elif fname in cfields %} 70 | {% set ftype, fval = cfields[fname] %} 71 | data["{{ fname }}"] = {% if ftype == 'string' %}"{% endif %}{{ fval }}{% if ftype == 'string' %}"{% endif %}; 72 | {% endif %} 73 | {% endif %} 74 | {% endfor %} 75 | return JSON.stringify(data); 76 | } 77 | } 78 | {% endfor %} 79 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # LogLab 2 | 3 | 4 | 5 | [![Tests](https://github.com/haje01/loglab/actions/workflows/test.yml/badge.svg)](https://github.com/haje01/loglab/actions/workflows/test.yml) 6 | [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) 7 | 8 | LogLab (로그랩) 은 JSON Lines 로그 형식을 설계, 문서화 및 검증하기 위한 툴입니다. 9 | 10 | ## ✨ 주요 기능 11 | 12 | - 로그를 객체지향적이며 재활용 가능한 형태로 설계 13 | - 설계된 로그에 관한 문서 자동 생성 14 | - 실제 출력된 로그가 설계에 맞게 작성되었는지 검증 15 | - Python, C#, C++, TypeScript 및 Java 로그 객체 코드 생성 16 | - Windows, Linux, macOS 에서 사용할 수 있습니다. 17 | 18 | ## ⚡ 빠른 시작 19 | 20 | ### 설치 21 | 22 | **uv 기반 설치 (권장)** 23 | 24 | 먼저 Python 용 패키지 매니저인 `uv` 의 설치가 필요합니다. [uv 설치 페이지](https://docs.astral.sh/uv/getting-started/installation/) 를 참고하여 사용자의 환경에 맞게 설치하도록 합니다. 25 | 26 | 이제 다음과 같은 `uv` 명령으로 LogLab 을 설치합니다. 27 | 28 | ```sh 29 | uv tool install --from git+https://github.com/haje01/loglab.git loglab 30 | ``` 31 | 32 | 설치가 잘 되었다면 로그랩의 커맨드라인 명령인 `loglab` 을 이용할 수 있습니다. 다음과 같이 입력하여 버전을 확인해봅시다. 33 | 34 | ```sh 35 | loglab version 36 | 0.3.3 37 | ``` 38 | 39 | > 만약 기존에 설치된 loglab 을 최신 버전으로 업그레이드하고 싶다면, 다음과 같은 `uv` 명령을 내리면 됩니다. 40 | > ```sh 41 | > uv tool upgrade loglab 42 | > ``` 43 | 44 | **소스 코드로 설치** 45 | 46 | 최신 소스 코드를 기반으로 다음처럼 개발용으로 설치도 가능합니다. 47 | 48 | ```bash 49 | git clone https://github.com/haje01/loglab 50 | cd loglab 51 | uv venv 52 | source .venv/bin/activate 53 | uv pip install -e . 54 | ``` 55 | 56 | ### 기본 사용법 예시 57 | 58 | ```bash 59 | # 로그 스키마 확인 60 | loglab show example/foo.lab.json 61 | 62 | # 로그 파일 검증 63 | loglab verify example/foo.lab.json example/foo.jsonl 64 | 65 | # HTML 문서 생성 66 | loglab html example/foo.lab.json -o docs.html 67 | 68 | # 로그 객체 코드 생성 (TypeScript) 69 | loglab object example/foo.lab.json ts -o loglab_foo.ts 70 | ``` 71 | 72 | ### 스키마와 로그 예제 73 | 74 | LogLab 은 지정된 JSON 형식으로 로그 스키마를 정의합니다. 75 | 76 | ```json 77 | { 78 | "domain": { 79 | "name": "foo", 80 | "desc": "최고의 모바일 게임" 81 | }, 82 | "events": { 83 | "Login": { 84 | "desc": "계정 로그인", 85 | "fields": [ 86 | ["ServerNo", "integer", "서버 번호"], 87 | ["AcntId", "integer", "계정 ID"] 88 | ] 89 | } 90 | } 91 | } 92 | ``` 93 | 94 | LogLab 으로 설계된 로그는 [JSON Lines](https://jsonlines.org/) 형식으로 출력됩니다: 95 | 96 | ```json 97 | {"DateTime": "2021-08-13T20:20:39+09:00", "Event": "Login", "ServerNo": 1, "AcntId": 1000} 98 | {"DateTime": "2021-08-13T20:21:01+09:00", "Event": "Logout", "ServerNo": 1, "AcntId": 1000} 99 | ``` 100 | 101 | ## 📖 문서 102 | 103 | - **[로그랩 문서](https://loglab.readthedocs.io/)** - 로그랩의 상세한 가이드와 튜토리얼 104 | - **[생성된 문서 예제](https://htmlpreview.github.io/?https://raw.githubusercontent.com/haje01/loglab/master/example/rpg.html)** - 로그랩으로 가상의 RPG 게임을 위한 로그를 설계한 후 자동 생성된 로그 명세 문서 105 | 106 | ## 🎯 대상 사용자 107 | 108 | - 서비스를 위한 로그 설계가 필요한 개발자 109 | - 로그를 처리하고 분석하는 데이터 엔지니어/분석가 110 | - 조직에서 생성되는 로그의 형식을 일관되게 유지/공유하고 싶은 관리자 111 | 112 | ## 🛠 개발 113 | 114 | ```bash 115 | # 개발 환경 설정 116 | git clone https://github.com/haje01/loglab.git 117 | cd loglab 118 | uv venv 119 | uv pip install -e . -r requirements-dev.txt 120 | 121 | # 테스트 실행 122 | pytest tests/ 123 | ``` 124 | 125 | ## 📄 라이선스 126 | 127 | MIT License - 자세한 내용은 [LICENSE](LICENSE) 파일 참조 128 | 129 | ## 🤝 기여 130 | 131 | 버그 리포트와 기능 제안은 [Issues](https://github.com/haje01/loglab/issues)에서 환영합니다. 132 | 133 | --- 134 | -------------------------------------------------------------------------------- /tests/files/foo.lab.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://raw.githubusercontent.com/haje01/loglab/master/loglab/schema/lab.schema.json", 3 | "domain": { 4 | "name": "foo", 5 | "desc": "위대한 모바일 게임", 6 | "version": "0.0.1" 7 | }, 8 | "types": { 9 | "unsigned": { 10 | "type": "integer", 11 | "desc": "0 이상 정수", 12 | "minimum": 0 13 | }, 14 | "ulong": { 15 | "type": "integer", 16 | "desc": "0 이상 정수 (C# 로그 객체에서 ulong)", 17 | "minimum": 0, 18 | "objtype": { 19 | "cs": "ulong" 20 | } 21 | } 22 | }, 23 | "bases": { 24 | "Server": { 25 | "desc": "서버 정보", 26 | "fields": [ 27 | { 28 | "name": "ServerNo", 29 | "desc": "서버 번호", 30 | "type": "integer", 31 | "minimum": 1, 32 | "exclusiveMaximum": 100 33 | } 34 | ] 35 | }, 36 | "Account": { 37 | "desc": "계정 정보", 38 | "mixins": ["bases.Server"], 39 | "fields": [ 40 | ["AcntId", "types.ulong", "계정 ID"] 41 | ] 42 | }, 43 | "Character": { 44 | "desc": "캐릭터 정보", 45 | "mixins": ["bases.Account"], 46 | "fields": [ 47 | ["CharId", "types.unsigned", "캐릭터 ID"] 48 | ] 49 | }, 50 | "Position": { 51 | "desc": "맵상의 위치 정보", 52 | "fields": [ 53 | ["MapId", "types.unsigned", "맵 번호"], 54 | ["PosX", "number", "맵상 X 위치"], 55 | ["PosY", "number", "맵상 Y 위치"], 56 | ["PosZ", "number", "맵상 Z 위치"] 57 | ] 58 | }, 59 | "Monster": { 60 | "desc": "몬스터 정보", 61 | "mixins": ["bases.Server"], 62 | "fields": [ 63 | ["MonsterCd", "types.unsigned", "몬스터 타입 코드"], 64 | ["MonsterId", "types.unsigned", "몬스터 개체 ID"] 65 | ] 66 | }, 67 | "Item": { 68 | "desc": "아이템 정보", 69 | "mixins": ["bases.Server"], 70 | "fields": [ 71 | { 72 | "name": "ItemCd", 73 | "type": "integer", 74 | "desc": "아이템 타입 코드", 75 | "enum": [ 76 | 0, 77 | [1, "칼"], 78 | [2, "방패"], 79 | [3, "물약"], 80 | 99 81 | ] 82 | }, 83 | ["ItemId", "types.unsigned", "아이템 개체 ID"], 84 | { 85 | "name": "ItemName", 86 | "type": "string", 87 | "desc": "아이템 이름", 88 | "maxLength": 7, 89 | "pattern": "^Itm.*" 90 | } 91 | ] 92 | } 93 | }, 94 | "events": { 95 | "Login": { 96 | "desc": "계정 로그인", 97 | "mixins": ["bases.Account"], 98 | "fields": [ 99 | { 100 | "name": "Platform", 101 | "desc": "디바이스의 플랫폼", 102 | "type": "string", 103 | "enum": [ 104 | "ios", "aos" 105 | ] 106 | } 107 | ] 108 | }, 109 | "Logout": { 110 | "desc": "계정 로그아웃", 111 | "mixins": ["bases.Account"], 112 | "fields": [ 113 | ["PlayTime", "number", "플레이 시간 (초)", true], 114 | ["Login", "datetime", "로그인 시간", true] 115 | ] 116 | }, 117 | "CharLogin": { 118 | "desc": "캐릭터 로그인", 119 | "mixins": ["bases.Character"] 120 | }, 121 | "CharLogout": { 122 | "desc": "캐릭터 로그아웃", 123 | "mixins": ["bases.Character", "events.Logout"] 124 | }, 125 | "KillMonster": { 126 | "desc": "몬스터를 잡음", 127 | "mixins": ["bases.Character", "bases.Position", "bases.Monster"] 128 | }, 129 | "MonsterDropItem": { 130 | "desc": "몬스터가 아이템을 떨어뜨림", 131 | "mixins": ["bases.Monster", "bases.Position", "bases.Item"], 132 | "option": true 133 | }, 134 | "GetItem": { 135 | "desc": "캐릭터의 아이템 습득", 136 | "mixins": ["bases.Character", "bases.Position", "bases.Item"] 137 | } 138 | } 139 | } 140 | -------------------------------------------------------------------------------- /CLAUDE.md: -------------------------------------------------------------------------------- 1 | # CLAUDE.md 2 | 3 | This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. 4 | 5 | ## Project Overview 6 | 7 | LogLab is a Python tool for designing and validating JSON Lines log formats. It provides: 8 | - Object-oriented, reusable log design capabilities 9 | - Automatic documentation generation from log schemas 10 | - Validation of actual log output against designed schemas 11 | - Code generation for Python, C#, and C++ log objects 12 | 13 | ## Architecture 14 | 15 | The codebase is organized into several core modules: 16 | 17 | - `loglab/cli.py` - Command-line interface using Click framework 18 | - `loglab/model.py` - Document Object Model for lab files (schema definitions) 19 | - `loglab/schema.py` - JSON Schema validation and generation 20 | - `loglab/doc.py` - Documentation generation (text and HTML) 21 | - `loglab/util.py` - Utility functions and built-in types 22 | 23 | Key concepts: 24 | - **Lab files** (*.lab.json): JSON schema definitions for log formats 25 | - **Domain**: Namespace for organizing log schemas 26 | - **Types**: Custom data types with validation rules 27 | - **Mixins**: Reusable field groups that can be combined 28 | - **Bases**: Base classes for events 29 | - **Events**: Specific log event types with fields 30 | 31 | ## Development Commands 32 | 33 | ### Installing Packages 34 | 35 | Use `uv pip install` to install additional Python packages. 36 | 37 | ### Testing 38 | ```bash 39 | # Run all tests 40 | pytest tests/ 41 | 42 | # Run with coverage 43 | coverage run --source loglab -m pytest tests 44 | 45 | # Run tests across multiple Python versions 46 | tox 47 | ``` 48 | 49 | ### Building 50 | ```bash 51 | # Build standalone executable 52 | ./tools/build.sh 53 | # This runs: pytest tests && pyinstaller ... 54 | ``` 55 | 56 | ### Code Generation Testing 57 | ```bash 58 | # Generate Python log objects 59 | loglab object example/foo.lab.json py -o tests/loglab_foo.py 60 | cd tests && pytest test_log_objects_python.py 61 | 62 | # Generate C# log objects 63 | loglab object example/foo.lab.json cs -o tests/cstest/loglab_foo.cs 64 | cd tests/cstest && dotnet run 65 | 66 | # Generate C++ log objects (requires libgtest-dev) 67 | loglab object example/foo.lab.json cpp -o tests/loglab_foo.h 68 | cd tests && g++ -std=c++17 -I. test_log_objects_cpp.cpp -lgtest -lgtest_main -lpthread -o test_log_objects_cpp && ./test_log_objects_cpp 69 | 70 | # Generate TypeScript log objects 71 | loglab object example/foo.lab.json ts -o tests/tstest/loglab_foo.ts 72 | cd tests/tstest && npm install && npx tsc --noEmit test_log_objects_typescript.ts && node test_log_objects_typescript.js 73 | ``` 74 | 75 | ## Key Files and Patterns 76 | 77 | - **Entry point**: `bin/loglab` script calls `loglab.cli:cli()` 78 | - **Lab file schema**: `loglab/schema/lab.schema.json` defines the structure of lab files. you can ignore `schema/lab.schema.json` file which is provided for compatibility. 79 | - **Examples**: `example/` directory contains sample lab files 80 | - **Templates**: `template/` directory contains Jinja2 templates for code generation 81 | - **Localization**: `locales/` for internationalization support 82 | 83 | ## CLI Commands 84 | 85 | - `loglab show ` - Display log schema components 86 | - `loglab html ` - Generate HTML documentation 87 | - `loglab verify ` - Validate lab file schema 88 | - `loglab schema ` - Generate JSON schema from lab file 89 | - `loglab check ` - Validate log file against schema 90 | - `loglab object ` - Generate log objects in target language (py, cs, cpp, ts) 91 | 92 | ## Dependencies 93 | 94 | Core: click, jsonschema==3.2.0, jinja2, tabulate[widechars], requests 95 | Dev: pytest, coverage, pyinstaller, tox 96 | -------------------------------------------------------------------------------- /example/foo.lab.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://raw.githubusercontent.com/haje01/loglab/master/loglab/schema/lab.schema.json", 3 | "domain": { 4 | "name": "foo", 5 | "desc": "위대한 모바일 게임" 6 | }, 7 | "types": { 8 | "unsigned": { 9 | "type": "integer", 10 | "desc": "0 이상의 정수", 11 | "minimum": 0 12 | } 13 | }, 14 | "bases": { 15 | "Server": { 16 | "desc": "서버 정보", 17 | "fields": [ 18 | { 19 | "name": "ServerNo", 20 | "desc": "서버 번호", 21 | "type": "integer", 22 | "minimum": 1, 23 | "exclusiveMaximum": 100 24 | } 25 | ] 26 | }, 27 | "Account": { 28 | "desc": "계정 정보", 29 | "mixins": ["bases.Server"], 30 | "fields": [ 31 | ["AcntId", "types.unsigned", "계정 ID"], 32 | { 33 | "name": "Category", 34 | "desc": "이벤트 분류", 35 | "type": "integer", 36 | "const": [1, "계정 이벤트"] 37 | } 38 | ] 39 | }, 40 | "Character": { 41 | "desc": "캐릭터 정보", 42 | "mixins": ["bases.Account"], 43 | "fields": [ 44 | ["CharId", "types.unsigned", "캐릭터 ID"], 45 | { 46 | "name": "Category", 47 | "desc": "이벤트 분류", 48 | "type": "integer", 49 | "const": [2, "캐릭터 이벤트"] 50 | } 51 | ] 52 | }, 53 | "System": { 54 | "desc": "시스템 이벤트", 55 | "mixins": ["bases.Server"], 56 | "fields": [ 57 | { 58 | "name": "Category", 59 | "desc": "이벤트 분류", 60 | "type": "integer", 61 | "const": [3, "시스템 이벤트"] 62 | } 63 | ] 64 | }, 65 | "Position": { 66 | "desc": "맵상의 위치 정보", 67 | "fields": [ 68 | ["MapCd", "types.unsigned", "맵 코드"], 69 | ["PosX", "number", "맵상 X 위치"], 70 | ["PosY", "number", "맵상 Y 위치"], 71 | ["PosZ", "number", "맵상 Z 위치"] 72 | ] 73 | }, 74 | "Monster": { 75 | "desc": "몬스터 정보", 76 | "fields": [ 77 | ["MonsterCd", "types.unsigned", "몬스터 타입 코드"], 78 | ["MonsterId", "types.unsigned", "몬스터 개체 ID"] 79 | ] 80 | }, 81 | "Item": { 82 | "desc": "아이템 정보", 83 | "fields": [ 84 | { 85 | "name": "ItemCd", 86 | "type": "integer", 87 | "desc": "아이템 타입 코드", 88 | "enum": [ 89 | [1, "칼"], 90 | [2, "방패"], 91 | [3, "물약"] 92 | ] 93 | }, 94 | ["ItemId", "types.unsigned", "아이템 개체 ID"] 95 | ] 96 | } 97 | }, 98 | "events": { 99 | "Login": { 100 | "desc": "계정 로그인", 101 | "mixins": ["bases.Account"], 102 | "fields": [ 103 | { 104 | "name": "Platform", 105 | "desc": "디바이스의 플랫폼", 106 | "type": "string", 107 | "enum": [ 108 | "ios", "aos" 109 | ] 110 | } 111 | ] 112 | }, 113 | "Logout": { 114 | "desc": "계정 로그아웃", 115 | "mixins": ["bases.Account"], 116 | "fields": [ 117 | ["PlayTime", "number", "플레이 시간 (초)", true] 118 | ] 119 | }, 120 | "CharLogin": { 121 | "desc": "캐릭터 로그인", 122 | "mixins": ["bases.Character"] 123 | }, 124 | "CharLogout": { 125 | "desc": "캐릭터 로그아웃", 126 | "mixins": ["bases.Character"] 127 | }, 128 | "KillMonster": { 129 | "desc": "몬스터를 잡음", 130 | "mixins": ["bases.Character", "bases.Position", "bases.Monster"] 131 | }, 132 | "MonsterDropItem": { 133 | "desc": "몬스터가 아이템을 떨어뜨림", 134 | "mixins": ["bases.System", "bases.Monster", "bases.Position", "bases.Item"] 135 | }, 136 | "GetItem": { 137 | "desc": "캐릭터의 아이템 습득", 138 | "mixins": ["bases.Character", "bases.Position", "bases.Item"] 139 | } 140 | } 141 | } 142 | -------------------------------------------------------------------------------- /tests/test_util_extended.py: -------------------------------------------------------------------------------- 1 | """확장된 util 모듈 테스트.""" 2 | 3 | import os 4 | import tempfile 5 | from pathlib import Path 6 | from unittest.mock import Mock, mock_open, patch 7 | 8 | import pytest 9 | 10 | from loglab.util import ( 11 | AttrDict, 12 | get_dt_desc, 13 | get_object_warn, 14 | get_translator, 15 | load_file_from, 16 | ) 17 | 18 | 19 | class TestTranslation: 20 | """번역 기능 테스트.""" 21 | 22 | def test_get_translator_none(self): 23 | """언어가 None일 때 원본 반환.""" 24 | trans = get_translator(None) 25 | assert trans("test") == "test" 26 | 27 | def test_get_dt_desc_korean(self): 28 | """한국어 DateTime 설명.""" 29 | desc = get_dt_desc(None) 30 | assert "이벤트 일시" in desc 31 | 32 | def test_get_object_warn_returns_warning(self): 33 | """객체 경고 메시지 반환.""" 34 | warn = get_object_warn(None) 35 | assert isinstance(warn, str) 36 | assert len(warn) > 0 37 | 38 | 39 | class TestFileOperations: 40 | """파일 관련 기능 테스트.""" 41 | 42 | def test_load_file_from_existing(self): 43 | """존재하는 파일 로드.""" 44 | with tempfile.NamedTemporaryFile(mode="w", delete=False, encoding="utf8") as f: 45 | f.write('{"test": "data"}') 46 | temp_path = f.name 47 | 48 | try: 49 | content = load_file_from(temp_path) 50 | assert '{"test": "data"}' in content 51 | finally: 52 | os.unlink(temp_path) 53 | 54 | def test_load_file_from_nonexistent(self): 55 | """존재하지 않는 파일 로드.""" 56 | with pytest.raises(FileNotFoundError): 57 | load_file_from("/nonexistent/file.json") 58 | 59 | def test_load_file_from_url(self): 60 | """URL에서 파일 로드.""" 61 | mock_response = Mock() 62 | mock_response.read.return_value = '{"url": "content"}' 63 | mock_response.__enter__ = Mock(return_value=mock_response) 64 | mock_response.__exit__ = Mock(return_value=None) 65 | 66 | with patch("loglab.util.urlopen", return_value=mock_response): 67 | content = load_file_from("http://example.com/test.json") 68 | assert '{"url": "content"}' in content 69 | 70 | 71 | class TestAttrDict: 72 | """AttrDict 클래스 테스트.""" 73 | 74 | def test_attrdict_basic(self): 75 | """기본 AttrDict 기능.""" 76 | data = AttrDict({"key": "value", "nested": {"inner": "data"}}) 77 | assert data.key == "value" 78 | assert data["key"] == "value" 79 | assert data.nested.inner == "data" 80 | 81 | def test_attrdict_assignment(self): 82 | """AttrDict 값 할당.""" 83 | data = AttrDict() 84 | data.new_key = "new_value" 85 | assert data["new_key"] == "new_value" 86 | 87 | def test_attrdict_nested_creation(self): 88 | """중첩된 AttrDict 생성.""" 89 | data = AttrDict({"level1": {"level2": {"level3": "deep"}}}) 90 | assert data.level1.level2.level3 == "deep" 91 | 92 | 93 | class TestFileLoading: 94 | """파일 로딩 추가 테스트.""" 95 | 96 | def test_load_file_with_url(self): 97 | """URL에서 파일 로드 (실제 테스트는 mock 사용).""" 98 | with patch("loglab.util.urlopen") as mock_urlopen: 99 | mock_response = Mock() 100 | mock_response.read.return_value = '{"test": "content"}' 101 | mock_response.__enter__ = Mock(return_value=mock_response) 102 | mock_response.__exit__ = Mock(return_value=None) 103 | mock_urlopen.return_value = mock_response 104 | 105 | content = load_file_from("http://example.com/test.json") 106 | assert '{"test": "content"}' in content 107 | 108 | 109 | class TestPathOperations: 110 | """경로 관련 기능 테스트.""" 111 | 112 | def test_builtin_types_constant(self): 113 | """내장 타입 상수 확인.""" 114 | from loglab.util import BUILTIN_TYPES 115 | 116 | expected_types = ("string", "integer", "number", "boolean", "datetime") 117 | assert BUILTIN_TYPES == expected_types 118 | 119 | def test_loglab_home_path(self): 120 | """LOGLAB_HOME 경로 확인.""" 121 | from loglab.util import LOGLAB_HOME 122 | 123 | assert isinstance(LOGLAB_HOME, Path) 124 | assert LOGLAB_HOME.exists() 125 | 126 | 127 | class TestErrorHandling: 128 | """에러 처리 테스트.""" 129 | 130 | def test_load_file_encoding_error(self): 131 | """인코딩 에러 처리.""" 132 | with tempfile.NamedTemporaryFile(mode="wb", delete=False) as f: 133 | f.write(b"\xff\xfe\x00\x00") # Invalid UTF-8 134 | temp_path = f.name 135 | 136 | try: 137 | with pytest.raises(UnicodeDecodeError): 138 | load_file_from(temp_path) 139 | finally: 140 | os.unlink(temp_path) 141 | -------------------------------------------------------------------------------- /loglab/template/tmpl_obj.java.jinja: -------------------------------------------------------------------------------- 1 | /* 2 | 3 | ** {{ warn }} ** 4 | 5 | Domain: {{ domain.name }} 6 | Description: {{ domain.desc }} 7 | 8 | */ 9 | 10 | package loglab_{{ domain.name }}; 11 | 12 | import java.time.Instant; 13 | {% if not utc %} 14 | import java.time.ZonedDateTime; 15 | import java.time.format.DateTimeFormatter; 16 | {% endif %} 17 | import com.fasterxml.jackson.databind.ObjectMapper; 18 | import com.fasterxml.jackson.databind.node.ObjectNode; 19 | import java.util.Optional; 20 | 21 | {% for ename in events.keys() %} 22 | 23 | /** 24 | * {{ events[ename][-1][1]['desc'] }} 25 | */ 26 | class {{ ename }} { 27 | {% set fields = events[ename][-1][1]['fields'] %} 28 | {% set rfields = fields | required %} 29 | {% set ofields = fields | optional %} 30 | {% set cfields = fields | const %} 31 | private static final String EVENT = "{{ ename }}"; 32 | private static final ObjectMapper mapper = new ObjectMapper(); 33 | 34 | {% for fname in fields.keys() %}{% if fname != 'DateTime' and fname not in cfields %} 35 | {% set field = fields[fname][-1][1] %} 36 | // {{ field['desc'] }} 37 | {% if fname in ofields %} 38 | private Optional<{{ type(field) | java_wrapper }}> {{ fname }} = Optional.empty(); 39 | {% else %} 40 | private {{ type(field) }} {{ fname }}; 41 | {% endif %}{% endif %}{% endfor %} 42 | 43 | public {{ ename }}() {} 44 | 45 | public {{ ename }}({% for fname in rfields.keys() %}{% set field = fields[fname][-1][1] %}{{ type(field) }} {{ fname }}{% if not loop.last %}, {% endif %}{% endfor %}) { 46 | reset({% for fname in rfields.keys() %}{{ fname }}{% if not loop.last %}, {% endif %}{% endfor %}); 47 | } 48 | 49 | public void reset({% for fname in rfields.keys() %}{% set field = fields[fname][-1][1] %}{{ type(field) }} {{ fname }}{% if not loop.last %}, {% endif %}{% endfor %}) { 50 | {% for fname in rfields.keys() %} 51 | this.{{ fname }} = {{ fname }}; 52 | {% endfor %} 53 | {% for fname in ofields.keys() %} 54 | this.{{ fname }} = Optional.empty(); 55 | {% endfor %} 56 | } 57 | 58 | {% for fname in fields.keys() %}{% if fname != 'DateTime' and fname not in cfields %} 59 | {% set field = fields[fname][-1][1] %} 60 | {% if fname in ofields %} 61 | public Optional<{{ type(field) | java_wrapper }}> get{{ fname }}() { 62 | return {{ fname }}; 63 | } 64 | 65 | public void set{{ fname }}({{ type(field) }} {{ fname }}) { 66 | this.{{ fname }} = Optional.of({{ fname }}); 67 | } 68 | {% else %} 69 | public {{ type(field) }} get{{ fname }}() { 70 | return {{ fname }}; 71 | } 72 | 73 | public void set{{ fname }}({{ type(field) }} {{ fname }}) { 74 | this.{{ fname }} = {{ fname }}; 75 | } 76 | {% endif %}{% endif %}{% endfor %} 77 | 78 | public String serialize() { 79 | ObjectNode data = mapper.createObjectNode(); 80 | 81 | // DateTime and Event 82 | {% if utc %} 83 | data.put("DateTime", Instant.now().toString()); 84 | {% else %} 85 | data.put("DateTime", ZonedDateTime.now().format(DateTimeFormatter.ISO_OFFSET_DATE_TIME)); 86 | {% endif %} 87 | data.put("Event", EVENT); 88 | 89 | // Required fields 90 | {% for fname in rfields.keys() %} 91 | {% set field = rfields[fname][-1][1] %} 92 | {% if type(field) == 'String' %} 93 | data.put("{{ fname }}", this.{{ fname }}); 94 | {% elif type(field) == 'boolean' %} 95 | data.put("{{ fname }}", this.{{ fname }}); 96 | {% else %} 97 | data.put("{{ fname }}", this.{{ fname }}); 98 | {% endif %} 99 | {% endfor %} 100 | 101 | // Optional fields 102 | {% for fname in ofields.keys() %} 103 | {% set field = ofields[fname][-1][1] %} 104 | if (this.{{ fname }}.isPresent()) { 105 | {% if type(field) == 'String' %} 106 | data.put("{{ fname }}", this.{{ fname }}.get()); 107 | {% elif type(field) == 'boolean' %} 108 | data.put("{{ fname }}", this.{{ fname }}.get()); 109 | {% else %} 110 | data.put("{{ fname }}", this.{{ fname }}.get()); 111 | {% endif %} 112 | } 113 | {% endfor %} 114 | 115 | // Const fields 116 | {% for fname, finfo in cfields.items() %} 117 | {% set ftype, fval = finfo %} 118 | {% if ftype == 'string' %} 119 | data.put("{{ fname }}", "{{ fval }}"); 120 | {% else %} 121 | data.put("{{ fname }}", {{ fval }}); 122 | {% endif %} 123 | {% endfor %} 124 | 125 | try { 126 | return mapper.writeValueAsString(data); 127 | } catch (Exception e) { 128 | throw new RuntimeException("Failed to serialize {{ ename }}", e); 129 | } 130 | } 131 | } 132 | {% endfor %} 133 | -------------------------------------------------------------------------------- /loglab/schema/generator.py: -------------------------------------------------------------------------------- 1 | """로그 스키마 생성기.""" 2 | 3 | import json 4 | from typing import Any, Dict, List 5 | 6 | from loglab.model import build_model 7 | from loglab.util import AttrDict 8 | 9 | from .config import SchemaConfig 10 | from .models import DomainInfo, EventSchema 11 | from .property_builder import PropertyBuilder 12 | 13 | 14 | class LogSchemaGenerator: 15 | """로그 스키마 생성기 클래스.""" 16 | 17 | def __init__( 18 | self, property_builder: PropertyBuilder = None, config: SchemaConfig = None 19 | ): 20 | """LogSchemaGenerator 초기화. 21 | 22 | Args: 23 | property_builder: 속성 빌더 24 | config: 설정 객체 25 | """ 26 | self.config = config or SchemaConfig() 27 | self.property_builder = property_builder or PropertyBuilder(self.config) 28 | 29 | def generate_schema(self, lab_data: Dict[str, Any]) -> str: 30 | """lab 파일 데이터로부터 실제 로그 검증용 JSON 스키마를 생성. 31 | 32 | Args: 33 | lab_data: 빌드된 lab 모델 데이터 34 | 35 | Returns: 36 | JSON 형태의 로그 검증 스키마 문자열 37 | """ 38 | # 모델 빌드 39 | domain_model = build_model(lab_data) 40 | 41 | # 도메인 정보 추출 42 | domain_info = DomainInfo( 43 | name=domain_model.domain.name, description=domain_model.domain.desc 44 | ) 45 | 46 | # 이벤트 스키마들 생성 47 | event_schemas = self._process_events(domain_model.events) 48 | 49 | # 최종 스키마 조립 50 | return self._assemble_schema(domain_info, event_schemas) 51 | 52 | def _process_events(self, events: Dict[str, Any]) -> List[EventSchema]: 53 | """이벤트들을 처리하여 EventSchema 리스트로 변환. 54 | 55 | Args: 56 | events: 이벤트 정보 딕셔너리 57 | 58 | Returns: 59 | EventSchema 리스트 60 | """ 61 | event_schemas = [] 62 | 63 | for event_name, event_list in events.items(): 64 | event_data = AttrDict(event_list[-1][1]) 65 | event_schema = self._build_event_schema(event_name, event_data) 66 | event_schemas.append(event_schema) 67 | 68 | return event_schemas 69 | 70 | def _build_event_schema(self, event_name: str, event_data: AttrDict) -> EventSchema: 71 | """단일 이벤트의 스키마를 생성. 72 | 73 | Args: 74 | event_name: 이벤트명 75 | event_data: 이벤트 데이터 76 | 77 | Returns: 78 | EventSchema 객체 79 | """ 80 | # 속성들 생성 (문자열 리스트로) 81 | property_strings = self.property_builder.build_properties_from_fields( 82 | event_data.fields 83 | ) 84 | 85 | # Event 속성 추가 (이벤트명을 상수로) 86 | event_property = f'"Event": {{"const": "{event_name}"}}' 87 | property_strings.append(event_property) 88 | 89 | # 필수 필드들 추출 90 | required_fields = self.property_builder.extract_required_fields( 91 | event_data.fields 92 | ) 93 | 94 | return EventSchema( 95 | name=event_name, 96 | properties=property_strings, # 문자열 리스트 97 | required_fields=required_fields, 98 | description=event_data.get("desc", ""), 99 | ) 100 | 101 | def _assemble_schema( 102 | self, domain_info: DomainInfo, event_schemas: List[EventSchema] 103 | ) -> str: 104 | """도메인 정보와 이벤트 스키마들로 최종 JSON 스키마를 조립. 105 | 106 | Args: 107 | domain_info: 도메인 정보 108 | event_schemas: 이벤트 스키마 리스트 109 | 110 | Returns: 111 | JSON 형태의 스키마 문자열 (기존 방식과 호환) 112 | """ 113 | # 이벤트별 스키마 문자열 생성 114 | events = [] 115 | items = [] 116 | 117 | for event_schema in event_schemas: 118 | # 속성들을 조합 119 | properties_str = ",".join(event_schema.properties) 120 | 121 | # 필수 필드들을 조합 122 | required_str = ", ".join( 123 | [f'"{field}"' for field in event_schema.required_fields] 124 | ) 125 | 126 | # 이벤트 본문 생성 127 | event_body = f""" 128 | "type": "object", 129 | "properties": {{ 130 | {properties_str} 131 | }}, 132 | "required": [{required_str}], 133 | "additionalProperties": false 134 | """ 135 | 136 | events.append(f'"{event_schema.name}" : {{\n {event_body}') 137 | items.append(f'{{"$ref": "#/$defs/{event_schema.name}"}}') 138 | 139 | events_str = "\n },\n ".join(events) + "}" 140 | items_str = ",\n ".join(items) 141 | 142 | return f""" 143 | {{ 144 | "$schema": "{self.config.json_schema_version}", 145 | "title": "{domain_info.name}", 146 | "description": "{domain_info.description}", 147 | "type": "array", 148 | "$defs": {{ 149 | {events_str} 150 | }}, 151 | "items": {{ 152 | "oneOf": [ 153 | {items_str} 154 | ] 155 | }} 156 | }} 157 | """ 158 | -------------------------------------------------------------------------------- /tests/test_log_objects_typescript.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | """ 3 | Basic tests for TypeScript log objects generated from foo.lab.json 4 | """ 5 | import json 6 | import os 7 | import subprocess 8 | import tempfile 9 | 10 | 11 | def test_typescript_code_generation(): 12 | """Test TypeScript code generation and basic syntax validation""" 13 | 14 | # Generate TypeScript code 15 | from click.testing import CliRunner 16 | 17 | from loglab.cli import cli 18 | 19 | runner = CliRunner() 20 | 21 | # Get the example lab file path 22 | example_path = os.path.join( 23 | os.path.dirname(__file__), "..", "example", "foo.lab.json" 24 | ) 25 | 26 | # Generate TypeScript code 27 | result = runner.invoke(cli, ["object", example_path, "ts"]) 28 | 29 | assert result.exit_code == 0 30 | assert "export class Login" in result.output 31 | assert "export class Logout" in result.output 32 | assert "export class KillMonster" in result.output 33 | 34 | # Check type annotations 35 | assert "public ServerNo: number;" in result.output 36 | assert "public Platform: string;" in result.output 37 | assert "public PlayTime: number | null = null;" in result.output 38 | 39 | # Check methods 40 | assert "constructor(" in result.output 41 | assert "public reset(" in result.output 42 | assert "public serialize(): string" in result.output 43 | 44 | # Check JSON serialization structure 45 | assert "DateTime:" in result.output 46 | assert "JSON.stringify(data)" in result.output 47 | 48 | 49 | def test_typescript_syntax_validation(): 50 | """Test if generated TypeScript code has valid syntax using TypeScript compiler if available""" 51 | 52 | from click.testing import CliRunner 53 | 54 | from loglab.cli import cli 55 | 56 | runner = CliRunner() 57 | example_path = os.path.join( 58 | os.path.dirname(__file__), "..", "example", "foo.lab.json" 59 | ) 60 | 61 | # Generate TypeScript code 62 | result = runner.invoke(cli, ["object", example_path, "ts"]) 63 | assert result.exit_code == 0 64 | 65 | # Try to validate syntax with TypeScript compiler if available 66 | try: 67 | # Create temporary TypeScript file 68 | with tempfile.NamedTemporaryFile(mode="w", suffix=".ts", delete=False) as f: 69 | f.write(result.output) 70 | temp_file = f.name 71 | 72 | try: 73 | # Try to compile with tsc if available 74 | subprocess.run( 75 | ["tsc", "--noEmit", "--skipLibCheck", temp_file], 76 | check=True, 77 | capture_output=True, 78 | ) 79 | except (subprocess.CalledProcessError, FileNotFoundError): 80 | # TypeScript compiler not available or compilation failed 81 | # Just do basic syntax checks 82 | pass 83 | finally: 84 | os.unlink(temp_file) 85 | 86 | except Exception: 87 | # If anything fails, just pass - this is optional validation 88 | pass 89 | 90 | 91 | def test_typescript_class_structure(): 92 | """Test the structure of generated TypeScript classes""" 93 | 94 | from click.testing import CliRunner 95 | 96 | from loglab.cli import cli 97 | 98 | runner = CliRunner() 99 | example_path = os.path.join( 100 | os.path.dirname(__file__), "..", "example", "foo.lab.json" 101 | ) 102 | 103 | result = runner.invoke(cli, ["object", example_path, "ts"]) 104 | assert result.exit_code == 0 105 | 106 | ts_code = result.output 107 | 108 | # Test Login class structure 109 | assert "export class Login {" in ts_code 110 | assert 'public readonly Event = "Login";' in ts_code 111 | 112 | # Test required fields 113 | assert "public ServerNo: number;" in ts_code 114 | assert "public AcntId: number;" in ts_code 115 | assert "public Platform: string;" in ts_code 116 | 117 | # Test constructor parameters 118 | assert ( 119 | "constructor(_ServerNo: number, _AcntId: number, _Platform: string)" in ts_code 120 | ) 121 | 122 | # Test optional field in Logout 123 | assert "public PlayTime: number | null = null;" in ts_code 124 | 125 | # Test const field handling (Category should not appear as public field) 126 | login_class_start = ts_code.find("export class Login {") 127 | login_class_end = ts_code.find("export class Logout {") 128 | login_class = ts_code[login_class_start:login_class_end] 129 | 130 | # Category should not be a public field since it's const 131 | assert "public Category:" not in login_class 132 | 133 | # But should appear in serialize method 134 | assert 'data["Category"] = 1;' in ts_code 135 | 136 | 137 | if __name__ == "__main__": 138 | test_typescript_code_generation() 139 | test_typescript_syntax_validation() 140 | test_typescript_class_structure() 141 | print("All TypeScript tests passed!") 142 | -------------------------------------------------------------------------------- /loglab/schema/property_builder.py: -------------------------------------------------------------------------------- 1 | """스키마 속성 빌더.""" 2 | 3 | import json 4 | from typing import Any, Dict, List 5 | 6 | from .config import SchemaConfig 7 | from .models import PropertyInfo 8 | 9 | 10 | class PropertyBuilder: 11 | """스키마 속성 빌더 클래스.""" 12 | 13 | def __init__(self, config: SchemaConfig = None): 14 | """PropertyBuilder 초기화. 15 | 16 | Args: 17 | config: 스키마 설정 객체 18 | """ 19 | self.config = config or SchemaConfig() 20 | 21 | def build_datetime_property(self, field_name: str, description: str) -> str: 22 | """datetime 타입 속성을 생성. 23 | 24 | Args: 25 | field_name: 필드명 26 | description: 필드 설명 27 | 28 | Returns: 29 | datetime 속성 문자열 (기존 방식과 호환) 30 | """ 31 | return f""" 32 | "{field_name}": {{ 33 | "type": "string", 34 | "description": "{description}", 35 | "pattern": "^([0-9]+)-(0[1-9]|1[012])-(0[1-9]|[12][0-9]|3[01])[Tt]([01][0-9]|2[0-3]):([0-5][0-9]):([0-5][0-9]|60)(\\\\.[0-9]+)?(([Zz])|([\\\\+|\\\\-]([01][0-9]|2[0-3]):?[0-5][0-9]))$" 36 | }}""" 37 | 38 | def build_typed_property(self, property_info: PropertyInfo) -> str: 39 | """타입 정보를 기반으로 속성을 생성. 40 | 41 | Args: 42 | property_info: 속성 정보 43 | 44 | Returns: 45 | 속성 문자열 (기존 방식과 호환) 46 | """ 47 | field_info = { 48 | "type": property_info.type, 49 | "description": property_info.description, 50 | } 51 | 52 | # 제약 조건들 추가 53 | for constraint_key, constraint_value in property_info.constraints.items(): 54 | if constraint_key in ("type", "desc"): 55 | continue 56 | 57 | if constraint_key == "enum" and len(constraint_value) > 0: 58 | # enum 값들 정리 59 | enum_values = [] 60 | for value in constraint_value: 61 | if isinstance(value, list): 62 | enum_values.append(value[0]) 63 | else: 64 | enum_values.append(value) 65 | field_info["enum"] = enum_values 66 | 67 | elif constraint_key == "const" and len(constraint_value) > 0: 68 | # const 값 정리 69 | if isinstance(constraint_value, list): 70 | field_info["const"] = constraint_value[0] 71 | else: 72 | field_info["const"] = constraint_value 73 | 74 | else: 75 | field_info[constraint_key] = constraint_value 76 | 77 | body = json.dumps(field_info, ensure_ascii=False) 78 | return f""" 79 | "{property_info.name}": {body}""" 80 | 81 | def build_properties_from_fields(self, fields: Dict[str, Any]) -> List[str]: 82 | """필드 정보로부터 속성들을 생성. 83 | 84 | Args: 85 | fields: 필드 정보 딕셔너리 86 | 87 | Returns: 88 | 속성 문자열 리스트 (기존 방식과 호환) 89 | """ 90 | properties = [] 91 | 92 | for field_key, field_value in fields.items(): 93 | field_data = field_value[-1] # 마지막 값 사용 94 | field_elements = field_key.split(".") 95 | field_name = field_elements[-1] 96 | field_type = field_data[1]["type"] 97 | field_desc = field_data[1]["desc"] 98 | 99 | if field_type == "datetime": 100 | property_str = self.build_datetime_property(field_name, field_desc) 101 | else: 102 | constraints = { 103 | k: v for k, v in field_data[1].items() if k not in ("type", "desc") 104 | } 105 | 106 | property_info = PropertyInfo( 107 | name=field_name, 108 | type=field_type, 109 | description=field_desc, 110 | constraints=constraints, 111 | is_optional=constraints.get("option", False), 112 | ) 113 | property_str = self.build_typed_property(property_info) 114 | 115 | properties.append(property_str) 116 | 117 | return properties 118 | 119 | def extract_required_fields(self, fields: Dict[str, Any]) -> List[str]: 120 | """필수 필드들을 추출. 121 | 122 | Args: 123 | fields: 필드 정보 딕셔너리 124 | 125 | Returns: 126 | 필수 필드명 리스트 127 | """ 128 | required_fields = [] 129 | 130 | for field_key, field_value in fields.items(): 131 | field_data = field_value[-1] 132 | field_elements = field_key.split(".") 133 | field_name = field_elements[-1] 134 | 135 | # option이 False이거나 없으면 필수 필드 136 | is_optional = field_data[1].get("option", False) 137 | if not is_optional: 138 | required_fields.append(field_name) 139 | 140 | return required_fields 141 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | .PHONY: help install test coverage lint format security clean build all pre-commit test-python test-csharp test-cpp test-typescript test-java test-codegen 2 | 3 | # Default target 4 | help: 5 | @echo "LogLab Development Commands" 6 | @echo "==========================" 7 | @echo "install - Install dependencies and setup development environment" 8 | @echo "test - Run all tests" 9 | @echo "coverage - Run tests with coverage report" 10 | @echo "lint - Run linting checks" 11 | @echo "format - Format code with black and isort" 12 | @echo "security - Run security checks" 13 | @echo "clean - Clean build artifacts and cache files" 14 | @echo "build - Build standalone executable" 15 | @echo "pre-commit - Install and run pre-commit hooks" 16 | @echo "all - Run format, lint, test, and coverage" 17 | @echo "" 18 | @echo "Multi-language tests:" 19 | @echo "test-python - Test Python code generation" 20 | @echo "test-csharp - Test C# code generation" 21 | @echo "test-cpp - Test C++ code generation" 22 | @echo "test-typescript - Test TypeScript code generation" 23 | @echo "test-java - Test Java code generation" 24 | @echo "test-codegen - Test all code generation" 25 | 26 | # Install dependencies 27 | install: 28 | uv pip install --upgrade pip || python -m pip install --upgrade pip || true 29 | uv pip install -e . || pip install -e . || true 30 | uv pip install -r requirements-dev.txt || pip install -r requirements-dev.txt || uv pip install pytest coverage tox black isort flake8 pre-commit safety bandit psutil 31 | pre-commit install || true 32 | 33 | # Run all tests 34 | test: 35 | pytest tests/ -v 36 | 37 | # Run tests with coverage 38 | coverage: 39 | coverage run --source loglab -m pytest tests/ 40 | coverage report --show-missing 41 | coverage html 42 | 43 | # Run linting checks 44 | lint: 45 | @echo "Running flake8..." 46 | flake8 loglab tests 47 | @echo "Checking black formatting..." 48 | black --check loglab tests 49 | @echo "Checking isort imports..." 50 | isort --check-only loglab tests 51 | 52 | # Format code 53 | format: 54 | @echo "Formatting with black..." 55 | black loglab tests 56 | @echo "Sorting imports with isort..." 57 | isort loglab tests 58 | 59 | # Security checks 60 | security: 61 | @echo "Running safety check..." 62 | safety check || echo "Safety check completed with warnings" 63 | @echo "Running bandit security scan..." 64 | bandit -r loglab/ -f txt || echo "Bandit scan completed with warnings" 65 | 66 | # Clean build artifacts 67 | clean: 68 | find . -type f -name "*.pyc" -delete 69 | find . -type d -name "__pycache__" -delete 70 | find . -type d -name "*.egg-info" -exec rm -rf {} + 71 | rm -rf build/ dist/ .coverage htmlcov/ .pytest_cache/ .tox/ 72 | rm -f *.html *.schema.json loglab_*.py loglab_*.cs loglab_*.h loglab_*.ts loglab_*.java 73 | cd tests && rm -f *.html *.schema.json loglab_*.py loglab_*.cs loglab_*.h 74 | cd tests/cpptest && rm -f loglab_*.h test_log_objects_cpp || true 75 | cd tests/tstest && rm -f loglab_*.ts loglab_*.js && rm -rf dist/ node_modules package-lock.json || true 76 | cd tests/javatest && mvn clean || true 77 | 78 | # Build standalone executable 79 | build: test 80 | ./tools/build.sh 81 | 82 | # Install and run pre-commit hooks 83 | pre-commit: 84 | pre-commit install 85 | pre-commit run --all-files 86 | 87 | # Test Python code generation 88 | test-python: 89 | @echo "Testing Python code generation..." 90 | python -m loglab object example/foo.lab.json py -o tests/loglab_foo.py 91 | cd tests && python -m pytest test_log_objects_python.py -v 92 | 93 | # Test C# code generation (requires .NET SDK) 94 | test-csharp: 95 | @echo "Testing C# code generation..." 96 | python -m loglab object example/foo.lab.json cs -o tests/cstest/loglab_foo.cs 97 | cd tests/cstest && dotnet run 98 | 99 | # Test C++ code generation (requires libgtest-dev) 100 | test-cpp: 101 | @echo "Testing C++ code generation..." 102 | python -m loglab object example/foo.lab.json cpp -o tests/cpptest/loglab_foo.h 103 | cd tests/cpptest && g++ -std=c++17 -I. test_log_objects_cpp.cpp -lgtest -lgtest_main -lpthread -o test_log_objects_cpp && ./test_log_objects_cpp 104 | 105 | # Test TypeScript code generation (requires Node.js) 106 | test-typescript: 107 | @echo "Testing TypeScript code generation..." 108 | python -m loglab object example/foo.lab.json ts -o tests/tstest/loglab_foo.ts 109 | cd tests/tstest && npm install 110 | cd tests/tstest && npx tsc 111 | cd tests/tstest && node dist/test_log_objects_typescript.js 112 | 113 | # Test Java code generation (requires Maven) 114 | test-java: 115 | @echo "Testing Java code generation..." 116 | python -m loglab object example/foo.lab.json java -o tests/javatest/src/main/java/loglab_foo/LogLabFoo.java 117 | cd tests/javatest && mvn compile exec:java 118 | 119 | # Test all code generation 120 | test-codegen: test-python test-csharp test-cpp test-typescript test-java 121 | 122 | # Run tox for multiple Python versions 123 | test-tox: 124 | tox 125 | 126 | # Full development workflow 127 | all: format lint test coverage 128 | 129 | # Development setup for new contributors 130 | setup: install pre-commit 131 | @echo "Development environment setup complete!" 132 | @echo "Run 'make help' to see available commands." 133 | -------------------------------------------------------------------------------- /loglab/template/tmpl_obj.cs.jinja: -------------------------------------------------------------------------------- 1 | /* 2 | 3 | ** {{ warn }} ** 4 | 5 | Domain: {{ domain.name }} 6 | Description: {{ domain.desc }} 7 | 8 | */ 9 | 10 | using System; 11 | using System.Text.Json; 12 | using System.Text.Json.Serialization; 13 | using System.Text.Encodings.Web; 14 | using System.Text.Unicode; 15 | using System.Collections.Generic; 16 | using System.Diagnostics; 17 | 18 | namespace loglab_{{ domain.name }} 19 | { 20 | {% for ename in events.keys() %} 21 | /// 22 | /// {{ events[ename][-1][1]['desc'] }} 23 | /// 24 | public class {{ ename }} 25 | { 26 | {% set fields = events[ename][-1][1]['fields'] %} 27 | {% set rfields = fields | required %} 28 | {% set ofields = fields | optional %} 29 | {% set cfields = fields | const %} 30 | public const string Event = "{{ ename }}"; 31 | {% for fname in fields.keys() %}{% if fname != 'DateTime' and fname not in cfields %} // {{ events[ename][-1][1]['fields'][fname][-1][1]['desc'] }} 32 | {% set field = fields[fname][-1][1] %} 33 | {% if type(field) in ('DateTime') %} 34 | public {{ type(field) }} {{ fname }}; 35 | {% elif fname not in cfields %} 36 | public {{ type(field) }}? {{ fname }} = null; 37 | {% endif %}{% endif %}{% endfor %} 38 | public static JsonSerializerOptions options = new JsonSerializerOptions 39 | { 40 | Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping 41 | }; 42 | 43 | public {{ ename }}({% for fname in rfields.keys() %}{% set field = fields[fname][-1][1] %}{{ type(field) }} _{{ fname }}{% if not loop.last %}, {% endif %}{% endfor %}) 44 | { 45 | Reset({% for fname in rfields.keys() %}{% set field = fields[fname][-1][1] %}_{{ fname }}{% if not loop.last %}, {% endif %}{% endfor %}); 46 | } 47 | public void Reset({% for fname in rfields.keys() %}{% set field = fields[fname][-1][1] %}{{ type(field) }} _{{ fname }}{% if not loop.last %}, {% endif %}{% endfor %}) 48 | { 49 | {% for fname in rfields.keys() %} 50 | {% set field = rfields[fname][-1][1] %} 51 | {{ fname }} = _{{ fname }}; 52 | {% endfor %} 53 | {% for fname in ofields.keys() %} 54 | {% if fname not in cfields %} 55 | {% set field = fields[fname][-1][1] %} 56 | {% if type(field) == 'string' %} 57 | {{ fname }} = default(string); 58 | {% elif type(field) == 'DateTime' %} 59 | {{ fname }} = DateTime.MinValue; 60 | {% else %} 61 | {{ fname }} = null; 62 | {% endif %}{% endif %} 63 | {% endfor %} 64 | } 65 | public string Serialize() 66 | { 67 | List fields = new List(); 68 | {% for fname in fields %} 69 | {% if fname != 'DateTime' %} 70 | {% set field = fields[fname][-1][1] %} 71 | {% if fname in cfields %} 72 | fields.Add($"\"{{ fname }}\": {{ cfields[fname][1] }}"); 73 | {% elif fname in rfields %} 74 | {% if type(field) == 'string' %} 75 | Debug.Assert({{ fname }} != null); 76 | {{ fname }} = JsonSerializer.Serialize({{ fname }}, {{ename}}.options); 77 | {% elif type(field) == 'DateTime' %} 78 | Debug.Assert({{ fname }} != DateTime.MinValue); 79 | string {{ fname }}_ = {{ fname }}.ToString("yyyy-MM-ddTHH:mm:ss.fffzzz"); 80 | {% else %} 81 | Debug.Assert({{ fname }}.HasValue); 82 | {% endif %} 83 | fields.Add($"\"{{ fname }}\": {% if field['type'] == 'datetime' %}\"{% endif %}{% raw %}{{% endraw %}{{ fname }}{% if field['type'] == 'datetime' %}_{% endif %}{% raw %}}{% endraw %}{% if field['type'] == 'datetime' %}\"{% endif %}"); 84 | {% else %} 85 | {% if type(field) == 'string' %} 86 | if ({{ fname }} != null) { 87 | {{ fname }} = JsonSerializer.Serialize({{ fname }}, {{ename}}.options); 88 | {% elif type(field) == 'DateTime' %} 89 | if ({{ fname }} != DateTime.MinValue) { 90 | string {{ fname }}_ = {{ fname }}.ToString("yyyy-MM-ddTHH:mm:ss.fffzzz"); 91 | {% else %} 92 | if ({{ fname }}.HasValue) 93 | {% endif %} 94 | fields.Add($"\"{{ fname }}\": {% if field['type'] == 'datetime' %}\"{% endif %}{% raw %}{{% endraw %}{{ fname }}{% if field['type'] == 'datetime' %}_{% endif %}{% raw %}}{% endraw %}{% if field['type'] == 'datetime' %}\"{% endif %}"); 95 | {% if type(field) in ('string', 'DateTime') %} 96 | {% raw %} } 97 | {% endraw %}{% endif %} 98 | {% endif %} 99 | {% endif %} 100 | {% endfor %} 101 | string sfields = String.Join(", ", fields); 102 | string dt = {% if utc %}DateTime.UtcNow.ToString("yyyy-MM-ddTHH:mm:ss.fffZ"){% else %}DateTime.Now.ToString("yyyy-MM-ddTHH:mm:ss.fffzzz"){% endif %}; 103 | string sjson = $"{% raw %}{{{% endraw %}\"DateTime\": \"{dt}\", \"Event\": \"{Event}\", {sfields}{% raw %}}}{% endraw %}"; 104 | return sjson; 105 | } 106 | } 107 | {% endfor %} 108 | } 109 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: Tests 2 | 3 | on: 4 | push: 5 | branches: [ master, main ] 6 | pull_request: 7 | branches: [ master, main ] 8 | 9 | jobs: 10 | test: 11 | runs-on: ubuntu-latest 12 | strategy: 13 | matrix: 14 | python-version: ["3.9", "3.10", "3.11", "3.12"] 15 | 16 | steps: 17 | - uses: actions/checkout@v4 18 | 19 | - name: Set up Python ${{ matrix.python-version }} 20 | uses: actions/setup-python@v5 21 | with: 22 | python-version: ${{ matrix.python-version }} 23 | 24 | - name: Install system dependencies 25 | run: | 26 | sudo apt-get update 27 | sudo apt-get install -y libgtest-dev 28 | cd /usr/src/gtest 29 | sudo cmake CMakeLists.txt 30 | sudo make 31 | sudo cp lib/*.a /usr/lib 32 | sudo ln -s /usr/lib/libgtest.a /usr/local/lib/libgtest.a 33 | sudo ln -s /usr/lib/libgtest_main.a /usr/local/lib/libgtest_main.a 34 | 35 | - name: Install .NET SDK (for C# tests) 36 | uses: actions/setup-dotnet@v4 37 | with: 38 | dotnet-version: '8.0.x' 39 | 40 | - name: Set up Node.js (for TypeScript tests) 41 | uses: actions/setup-node@v4 42 | with: 43 | node-version: '18' 44 | 45 | - name: Set up Java (for Java tests) 46 | uses: actions/setup-java@v3 47 | with: 48 | distribution: 'temurin' 49 | java-version: '11' 50 | 51 | - name: Install Python dependencies 52 | run: | 53 | python -m pip install --upgrade pip 54 | pip install tox tox-uv coverage pytest 55 | if [ -f requirements.txt ]; then pip install -r requirements.txt; fi 56 | if [ -f requirements-dev.txt ]; then pip install -r requirements-dev.txt; fi 57 | 58 | - name: Run tests with tox 59 | run: tox -e py$(echo ${{ matrix.python-version }} | tr -d .)-ci 60 | 61 | - name: Run additional code generation tests 62 | run: | 63 | # Check current directory and files 64 | pwd 65 | ls -la 66 | ls -la example/ || echo "example directory not found" 67 | 68 | # Install package in development mode for code generation 69 | pip install -e . 70 | 71 | # Python code generation test 72 | python -m loglab object example/foo.lab.json py -o tests/loglab_foo.py 73 | cd tests && python -m pytest test_log_objects_python.py -v 74 | 75 | # C# code generation test 76 | cd ${{ github.workspace }} 77 | python -m loglab object example/foo.lab.json cs -o tests/cstest/loglab_foo.cs 78 | cd tests/cstest && dotnet run 79 | 80 | # C++ code generation test 81 | cd ${{ github.workspace }} 82 | python -m loglab object example/foo.lab.json cpp -o tests/cpptest/loglab_foo.h 83 | cd tests/cpptest && g++ -std=c++17 -I. test_log_objects_cpp.cpp -lgtest -lgtest_main -lpthread -o test_log_objects_cpp && ./test_log_objects_cpp 84 | 85 | # TypeScript code generation test 86 | cd ${{ github.workspace }} 87 | python -m loglab object example/foo.lab.json ts -o tests/tstest/loglab_foo.ts 88 | cd tests/tstest && npm install 89 | cd tests/tstest && npx tsc --noEmit test_log_objects_typescript.ts && node test_log_objects_typescript.js 90 | 91 | # Java code generation test 92 | cd ${{ github.workspace }} 93 | python -m loglab object example/foo.lab.json java -o tests/javatest/src/main/java/loglab_foo/LogLabFoo.java 94 | cd tests/javatest && mvn compile exec:java 95 | 96 | - name: Generate coverage report 97 | run: | 98 | coverage run --source loglab -m pytest tests/ 99 | coverage xml 100 | 101 | - name: Upload coverage to Codecov 102 | uses: codecov/codecov-action@v3 103 | with: 104 | file: ./coverage.xml 105 | flags: unittests 106 | name: codecov-umbrella 107 | fail_ci_if_error: false 108 | 109 | lint: 110 | runs-on: ubuntu-latest 111 | steps: 112 | - uses: actions/checkout@v4 113 | 114 | - name: Set up Python 115 | uses: actions/setup-python@v5 116 | with: 117 | python-version: "3.10" 118 | 119 | - name: Install dependencies 120 | run: | 121 | python -m pip install --upgrade pip 122 | pip install flake8 black isort 123 | 124 | - name: Run linting 125 | run: | 126 | # Check code formatting with black (uses pyproject.toml configuration) 127 | black --check --diff loglab tests 128 | 129 | # Check import sorting with isort (uses pyproject.toml configuration) 130 | isort --check-only --diff loglab tests 131 | 132 | # Run flake8 linting (uses setup.cfg configuration) 133 | flake8 loglab tests 134 | 135 | security: 136 | runs-on: ubuntu-latest 137 | steps: 138 | - uses: actions/checkout@v4 139 | 140 | - name: Set up Python 141 | uses: actions/setup-python@v5 142 | with: 143 | python-version: "3.10" 144 | 145 | - name: Install dependencies 146 | run: | 147 | python -m pip install --upgrade pip 148 | pip install safety bandit 149 | 150 | - name: Run safety check 151 | run: safety check 152 | 153 | - name: Run bandit security scan 154 | run: bandit -r loglab/ -f json -o bandit-report.json || true 155 | 156 | - name: Upload bandit report 157 | uses: actions/upload-artifact@v4 158 | if: always() 159 | with: 160 | name: bandit-report 161 | path: bandit-report.json 162 | -------------------------------------------------------------------------------- /loglab/template/tmpl_obj.cpp.jinja: -------------------------------------------------------------------------------- 1 | /* 2 | 3 | {{ warn }} 4 | 5 | Domain: {{ domain.name }} 6 | {% if 'desc' in domain %} 7 | Description: {{ domain.desc }} 8 | {% endif %} 9 | */ 10 | 11 | #pragma once 12 | 13 | #include 14 | #include 15 | #include 16 | #include 17 | #include 18 | #include 19 | #include 20 | 21 | namespace loglab_{{ domain.name }} 22 | { 23 | class LogSerializer { 24 | public: 25 | 26 | static std::string FormatDateTime() { 27 | auto now = std::chrono::system_clock::now(); 28 | auto in_time_t = std::chrono::system_clock::to_time_t(now); 29 | auto microseconds = std::chrono::duration_cast(now.time_since_epoch()) % 1000000; 30 | 31 | char datetime_buffer[64]; 32 | {% if utc %} 33 | std::tm* tm_time = std::gmtime(&in_time_t); 34 | std::sprintf(datetime_buffer, "%04d-%02d-%02dT%02d:%02d:%02d.%06ldZ", 35 | tm_time->tm_year + 1900, tm_time->tm_mon + 1, tm_time->tm_mday, 36 | tm_time->tm_hour, tm_time->tm_min, tm_time->tm_sec, 37 | microseconds.count()); 38 | {% else %} 39 | std::tm* tm_time = std::localtime(&in_time_t); 40 | int len = std::sprintf(datetime_buffer, "%04d-%02d-%02dT%02d:%02d:%02d.%06ld", 41 | tm_time->tm_year + 1900, tm_time->tm_mon + 1, tm_time->tm_mday, 42 | tm_time->tm_hour, tm_time->tm_min, tm_time->tm_sec, 43 | microseconds.count()); 44 | 45 | // Add timezone offset for local time 46 | long offset = tm_time->tm_gmtoff; 47 | int hours = offset / 3600; 48 | int minutes = abs(offset % 3600) / 60; 49 | std::sprintf(datetime_buffer + len, "%+03d:%02d", hours, minutes); 50 | {% endif %} 51 | 52 | return std::string(datetime_buffer); 53 | } 54 | }; 55 | 56 | {% for name, elst in events.items() %} 57 | {% set edata = elst[-1] %} 58 | {% set edef = edata[1] %} 59 | /// 60 | /// {{ edef.desc }} 61 | /// 62 | class {{ name }} 63 | { 64 | public: 65 | static constexpr const char* Event = "{{ name }}"; 66 | 67 | // Required fields 68 | {% for fname, flst in (edef.fields|required).items() %} 69 | {% set fdata = flst[-1] %} 70 | {% set fdef = fdata[1] %} 71 | // {{ fdef.desc }} 72 | {{ type(fdef) }} {{ fname }}; 73 | {% endfor %} 74 | 75 | // Optional fields 76 | {% for fname, flst in (edef.fields|optional).items() %} 77 | {% set fdata = flst[-1] %} 78 | {% set fdef = fdata[1] %} 79 | // {{ fdef.desc }} 80 | std::optional<{{ type(fdef) }}> {{ fname }}; 81 | {% endfor %} 82 | 83 | {{ name }}() {} 84 | 85 | {{ name }}({% for fname, flst in (edef.fields|required).items() %}{% set fdata = flst[-1] %}{% set fdef = fdata[1] %}{{ type(fdef) }} _{{ fname }}{% if not loop.last %}, {% endif %}{% endfor %}) 86 | { 87 | reset({% for fname, flst in (edef.fields|required).items() %}_{{ fname }}{% if not loop.last %}, {% endif %}{% endfor %}); 88 | } 89 | 90 | void reset({% for fname, flst in (edef.fields|required).items() %}{% set fdata = flst[-1] %}{% set fdef = fdata[1] %}{{ type(fdef) }} _{{ fname }}{% if not loop.last %}, {% endif %}{% endfor %}) 91 | { 92 | {% for fname, flst in (edef.fields|required).items() %} 93 | {{ fname }} = _{{ fname }}; 94 | {% endfor %} 95 | {% for fname, flst in (edef.fields|optional).items() %} 96 | {{ fname }}.reset(); 97 | {% endfor %} 98 | } 99 | 100 | std::string serialize() 101 | { 102 | std::stringstream ss; 103 | ss << "{"; 104 | 105 | // DateTime and Event 106 | ss << "\"DateTime\":\"" << LogSerializer::FormatDateTime() << "\","; 107 | ss << "\"Event\":\"" << Event << "\""; 108 | 109 | // Required fields 110 | {% for fname, flst in (edef.fields|required).items() %} 111 | {% set fdef = flst[-1][1] %} 112 | ss << ","; 113 | ss << "\"{{ fname }}\":"; 114 | {% if type(fdef) == 'std::string' %} 115 | ss << "\"" << {{ fname }} << "\""; 116 | {% elif type(fdef) == 'bool' %} 117 | ss << ({{ fname }} ? "true" : "false"); 118 | {% else %} 119 | ss << {{ fname }}; 120 | {% endif %} 121 | {% endfor %} 122 | 123 | // Optional fields 124 | {% for fname, flst in (edef.fields|optional).items() %} 125 | {% set fdef = flst[-1][1] %} 126 | if ({{ fname }}.has_value()) 127 | { 128 | ss << ","; 129 | ss << "\"{{ fname }}\":"; 130 | {% if type(fdef) == 'std::string' %} 131 | ss << "\"" << {{ fname }}.value() << "\""; 132 | {% elif type(fdef) == 'bool' %} 133 | ss << ({{ fname }}.value() ? "true" : "false"); 134 | {% else %} 135 | ss << {{ fname }}.value(); 136 | {% endif %} 137 | } 138 | {% endfor %} 139 | 140 | // Const fields 141 | {% for fname, finfo in (edef.fields|const).items() %} 142 | ss << ","; 143 | ss << "\"{{ fname }}\":"; 144 | {% set ftype = finfo[0] %} 145 | {% set fval = finfo[1] %} 146 | {% if ftype == 'string' %} 147 | ss << "\"" << {{ fval }} << "\""; 148 | {% else %} 149 | ss << {{ fval }}; 150 | {% endif %} 151 | {% endfor %} 152 | 153 | ss << "}"; 154 | return ss.str(); 155 | } 156 | }; 157 | {% endfor %} 158 | } 159 | -------------------------------------------------------------------------------- /tests/javatest/src/main/java/loglab_foo/TestLogObjects.java: -------------------------------------------------------------------------------- 1 | package loglab_foo; 2 | 3 | import com.fasterxml.jackson.databind.JsonNode; 4 | import com.fasterxml.jackson.databind.ObjectMapper; 5 | 6 | /** 7 | * Basic tests for Java log objects generated from foo.lab.json 8 | */ 9 | public class TestLogObjects { 10 | private static final ObjectMapper mapper = new ObjectMapper(); 11 | 12 | public static void main(String[] args) { 13 | System.out.println("Testing Java Log Objects..."); 14 | 15 | try { 16 | // Test basic object creation and serialization 17 | testBasicFunctionality(); 18 | 19 | System.out.println("✓ All Java log object tests passed!"); 20 | } catch (Exception ex) { 21 | System.err.println("✗ Test failed: " + ex.getMessage()); 22 | ex.printStackTrace(); 23 | System.exit(1); 24 | } 25 | } 26 | 27 | private static void testBasicFunctionality() throws Exception { 28 | System.out.println("Testing basic log object functionality..."); 29 | 30 | // Test Login event 31 | Login login = new Login(1, 12345, "ios"); 32 | String loginJson = login.serialize(); 33 | System.out.println("Login JSON: " + loginJson); 34 | 35 | // Validate JSON structure 36 | JsonNode loginNode = mapper.readTree(loginJson); 37 | validateBasicStructure(loginNode, "Login"); 38 | assert loginNode.get("ServerNo").asInt() == 1; 39 | assert loginNode.get("AcntId").asInt() == 12345; 40 | assert loginNode.get("Platform").asText().equals("ios"); 41 | assert loginNode.get("Category").asInt() == 1; 42 | System.out.println("✓ Login event serialization test passed"); 43 | 44 | // Test Logout event 45 | Logout logout = new Logout(1, 12345); 46 | String logoutJson = logout.serialize(); 47 | System.out.println("Logout JSON: " + logoutJson); 48 | 49 | JsonNode logoutNode = mapper.readTree(logoutJson); 50 | validateBasicStructure(logoutNode, "Logout"); 51 | assert logoutNode.get("ServerNo").asInt() == 1; 52 | assert logoutNode.get("AcntId").asInt() == 12345; 53 | assert logoutNode.get("Category").asInt() == 1; 54 | // PlayTime should not be present since it's not set 55 | assert !logoutNode.has("PlayTime"); 56 | System.out.println("✓ Logout event serialization test passed"); 57 | 58 | // Test Logout with optional field 59 | logout.setPlayTime(123.45f); 60 | String logoutWithPlayTimeJson = logout.serialize(); 61 | System.out.println("Logout with PlayTime JSON: " + logoutWithPlayTimeJson); 62 | 63 | JsonNode logoutWithPlayTimeNode = mapper.readTree(logoutWithPlayTimeJson); 64 | assert logoutWithPlayTimeNode.has("PlayTime"); 65 | assert Math.abs(logoutWithPlayTimeNode.get("PlayTime").asDouble() - 123.45) < 0.001; 66 | System.out.println("✓ Optional field test passed"); 67 | 68 | // Test KillMonster event 69 | KillMonster killMonster = new KillMonster(1, 12345, 67890, 1001, 100.5f, 200.7f, 0.0f, 5001, 999888); 70 | String killMonsterJson = killMonster.serialize(); 71 | System.out.println("KillMonster JSON: " + killMonsterJson); 72 | 73 | JsonNode killMonsterNode = mapper.readTree(killMonsterJson); 74 | validateBasicStructure(killMonsterNode, "KillMonster"); 75 | assert killMonsterNode.get("ServerNo").asInt() == 1; 76 | assert killMonsterNode.get("AcntId").asInt() == 12345; 77 | assert killMonsterNode.get("CharId").asInt() == 67890; 78 | assert killMonsterNode.get("MapCd").asInt() == 1001; 79 | assert Math.abs(killMonsterNode.get("PosX").asDouble() - 100.5) < 0.001; 80 | assert Math.abs(killMonsterNode.get("PosY").asDouble() - 200.7) < 0.001; 81 | assert Math.abs(killMonsterNode.get("PosZ").asDouble() - 0.0) < 0.001; 82 | assert killMonsterNode.get("MonsterCd").asInt() == 5001; 83 | assert killMonsterNode.get("MonsterId").asInt() == 999888; 84 | assert killMonsterNode.get("Category").asInt() == 2; 85 | System.out.println("✓ KillMonster event serialization test passed"); 86 | 87 | // Test object reset functionality 88 | login.reset(2, 54321, "aos"); 89 | String resetLoginJson = login.serialize(); 90 | System.out.println("Reset Login JSON: " + resetLoginJson); 91 | 92 | JsonNode resetLoginNode = mapper.readTree(resetLoginJson); 93 | assert resetLoginNode.get("ServerNo").asInt() == 2; 94 | assert resetLoginNode.get("AcntId").asInt() == 54321; 95 | assert resetLoginNode.get("Platform").asText().equals("aos"); 96 | System.out.println("✓ Object reset test passed"); 97 | 98 | System.out.println("✓ Basic functionality tests completed"); 99 | } 100 | 101 | private static void validateBasicStructure(JsonNode node, String expectedEvent) { 102 | // Every log object should have DateTime and Event fields 103 | assert node.has("DateTime") : "DateTime field is missing"; 104 | assert node.has("Event") : "Event field is missing"; 105 | assert node.get("Event").asText().equals(expectedEvent) : 106 | "Expected event " + expectedEvent + " but got " + node.get("Event").asText(); 107 | 108 | // DateTime should be in ISO format 109 | String dateTime = node.get("DateTime").asText(); 110 | assert dateTime.contains("T") : "DateTime should be in ISO format"; 111 | assert dateTime.length() > 19 : "DateTime should include timezone information"; 112 | } 113 | } 114 | -------------------------------------------------------------------------------- /tests/tstest/test_log_objects_typescript.ts: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | /** 3 | * Basic tests for TypeScript log objects generated from foo.lab.json 4 | */ 5 | 6 | import { Login, Logout, KillMonster, CharLogin, GetItem } from './loglab_foo'; 7 | 8 | function testBasicLogObjects(): void { 9 | console.log('Testing basic log object functionality...'); 10 | 11 | // Test Login event 12 | const login = new Login(1, 10000, "ios"); 13 | login.AcntId = 12345; 14 | login.ServerNo = 1; 15 | login.Platform = "ios"; 16 | 17 | // Test serialization 18 | const loginJson = login.serialize(); 19 | const loginData = JSON.parse(loginJson); 20 | 21 | console.assert(loginData.AcntId === 12345, 'Login AcntId should be 12345'); 22 | console.assert(loginData.ServerNo === 1, 'Login ServerNo should be 1'); 23 | console.assert(loginData.Platform === "ios", 'Login Platform should be ios'); 24 | console.assert('DateTime' in loginData, 'Login should have DateTime field'); 25 | console.assert(loginData.Category === 1, 'Login Category should be 1'); 26 | console.assert(loginData.Event === "Login", 'Event should be Login'); 27 | 28 | // Test Logout event with optional field 29 | const logout = new Logout(1, 10000); 30 | logout.AcntId = 12345; 31 | logout.ServerNo = 1; 32 | logout.PlayTime = 3600.5; 33 | 34 | const logoutJson = logout.serialize(); 35 | const logoutData = JSON.parse(logoutJson); 36 | 37 | console.assert(logoutData.AcntId === 12345, 'Logout AcntId should be 12345'); 38 | console.assert(logoutData.PlayTime === 3600.5, 'Logout PlayTime should be 3600.5'); 39 | console.assert(logoutData.Event === "Logout", 'Event should be Logout'); 40 | 41 | // Test KillMonster event with many parameters 42 | const killMonster = new KillMonster(1, 1234, 5678, 1001, 100.5, 200.7, 0.0, 5001, 999888); 43 | const killJson = killMonster.serialize(); 44 | const killData = JSON.parse(killJson); 45 | 46 | console.assert(killData.CharId === 5678, 'KillMonster CharId should be 5678'); 47 | console.assert(killData.MonsterCd === 5001, 'KillMonster MonsterCd should be 5001'); 48 | console.assert(killData.PosX === 100.5, 'KillMonster PosX should be 100.5'); 49 | console.assert(killData.Event === "KillMonster", 'Event should be KillMonster'); 50 | 51 | // Test reset functionality 52 | login.reset(2, 20000, "aos"); 53 | const loginResetJson = login.serialize(); 54 | const loginResetData = JSON.parse(loginResetJson); 55 | 56 | console.assert(loginResetData.AcntId === 20000, 'Reset Login AcntId should be 20000'); 57 | console.assert(loginResetData.ServerNo === 2, 'Reset Login ServerNo should be 2'); 58 | console.assert(loginResetData.Platform === "aos", 'Reset Login Platform should be aos'); 59 | 60 | console.log('✓ Basic log object tests passed'); 61 | } 62 | 63 | function testOptionalFields(): void { 64 | console.log('Testing optional field handling...'); 65 | 66 | // Test Logout without optional PlayTime 67 | const logout = new Logout(1, 10000); 68 | const logoutJson = logout.serialize(); 69 | const logoutData = JSON.parse(logoutJson); 70 | 71 | console.assert(logout.PlayTime === null, 'PlayTime should be null initially'); 72 | console.assert(!('PlayTime' in logoutData), 'PlayTime should not be in serialized data when null'); 73 | 74 | // Test Logout with optional PlayTime 75 | logout.PlayTime = 1234.5; 76 | const logoutWithTimeJson = logout.serialize(); 77 | const logoutWithTimeData = JSON.parse(logoutWithTimeJson); 78 | 79 | console.assert('PlayTime' in logoutWithTimeData, 'PlayTime should be in serialized data when not null'); 80 | console.assert(logoutWithTimeData.PlayTime === 1234.5, 'PlayTime should be 1234.5'); 81 | 82 | console.log('✓ Optional field tests passed'); 83 | } 84 | 85 | function testTypeScriptFeatures(): void { 86 | console.log('Testing TypeScript-specific features...'); 87 | 88 | // Test readonly Event property 89 | const login = new Login(1, 10000, "ios"); 90 | console.assert(login.Event === "Login", 'Event property should be "Login"'); 91 | 92 | // Test type checking (this will be caught at compile time) 93 | // login.Event = "SomeOtherEvent"; // This should cause a TypeScript error 94 | 95 | // Test null union types for optional fields 96 | const logout = new Logout(1, 10000); 97 | logout.PlayTime = null; // This should be valid 98 | logout.PlayTime = 123.45; // This should also be valid 99 | // logout.PlayTime = "invalid"; // This should cause a TypeScript error 100 | 101 | console.log('✓ TypeScript feature tests passed'); 102 | } 103 | 104 | function testJsonOutput(): void { 105 | console.log('Testing JSON output format...'); 106 | 107 | const login = new Login(1, 12345, "ios"); 108 | const json = login.serialize(); 109 | const data = JSON.parse(json); 110 | 111 | // Check required fields 112 | const requiredFields = ['DateTime', 'Event', 'ServerNo', 'AcntId', 'Category', 'Platform']; 113 | for (const field of requiredFields) { 114 | console.assert(field in data, `Required field ${field} should be in JSON output`); 115 | } 116 | 117 | // Check DateTime format (ISO string) 118 | const dateTime = new Date(data.DateTime); 119 | console.assert(!isNaN(dateTime.getTime()), 'DateTime should be a valid date string'); 120 | 121 | console.log('✓ JSON output tests passed'); 122 | } 123 | 124 | function main(): void { 125 | try { 126 | testBasicLogObjects(); 127 | testOptionalFields(); 128 | testTypeScriptFeatures(); 129 | testJsonOutput(); 130 | console.log('\n🎉 All TypeScript tests passed!'); 131 | } catch (error) { 132 | console.error('\n❌ Test failed:', error); 133 | process.exit(1); 134 | } 135 | } 136 | 137 | if (require.main === module) { 138 | main(); 139 | } 140 | -------------------------------------------------------------------------------- /loglab/schema/validator.py: -------------------------------------------------------------------------------- 1 | """Schema 검증기 구현.""" 2 | 3 | import json 4 | import logging 5 | import os 6 | from typing import Any, Dict, Optional 7 | 8 | from loglab.util import get_schema_file_content 9 | 10 | from .config import SchemaConfig 11 | from .implementations import ( 12 | DefaultErrorHandler, 13 | DefaultFileLoader, 14 | DefaultJsonValidator, 15 | ) 16 | from .interfaces import ErrorHandler, FileLoader, JsonValidator, ValidationResult 17 | 18 | 19 | class SchemaValidator: 20 | """Lab 파일 스키마 검증기.""" 21 | 22 | def __init__( 23 | self, 24 | file_loader: Optional[FileLoader] = None, 25 | json_validator: Optional[JsonValidator] = None, 26 | error_handler: Optional[ErrorHandler] = None, 27 | config: Optional[SchemaConfig] = None, 28 | ): 29 | """SchemaValidator 초기화. 30 | 31 | Args: 32 | file_loader: 파일 로딩 구현체 33 | json_validator: JSON 검증 구현체 34 | error_handler: 에러 처리 구현체 35 | config: 설정 객체 36 | """ 37 | self.file_loader = file_loader or DefaultFileLoader() 38 | self.json_validator = json_validator or DefaultJsonValidator() 39 | self.error_handler = error_handler or DefaultErrorHandler() 40 | self.config = config or SchemaConfig() 41 | 42 | def verify_labfile( 43 | self, lab_path: str, schema_path: Optional[str] = None 44 | ) -> ValidationResult: 45 | """Lab 파일의 구조와 내용을 JSON 스키마로 검증. 46 | 47 | Args: 48 | lab_path: 검증할 랩 파일 경로 49 | schema_path: 사용할 스키마 파일 경로. None이면 기본 스키마 사용 50 | 51 | Returns: 52 | ValidationResult: 검증 결과 53 | """ 54 | if schema_path is None: 55 | schema_path = self.config.default_schema_path 56 | logging.info(f"Verifying lab file: {lab_path} with schema: {schema_path}") 57 | 58 | try: 59 | # 스키마 파일 로드 60 | try: 61 | if schema_path == self.config.default_schema_path: 62 | # 기본 스키마의 경우 패키지 리소스에서 직접 로드 63 | logging.debug(f"Loading default schema using package resources") 64 | schema_content = get_schema_file_content() 65 | else: 66 | # 사용자 지정 스키마의 경우 파일 시스템에서 로드 67 | logging.debug(f"Loading schema from: {schema_path}") 68 | schema_content = self.file_loader.load(schema_path) 69 | 70 | schema_data = json.loads(schema_content) 71 | logging.debug("Schema loaded successfully") 72 | except FileNotFoundError as e: 73 | return self.error_handler.handle_file_error(e, schema_path) 74 | except json.JSONDecodeError as e: 75 | logging.error(f"Invalid JSON in schema file: {schema_path}") 76 | return self.error_handler.handle_file_error(e, schema_path) 77 | except Exception as e: 78 | logging.error(f"Failed to load schema file: {schema_path}") 79 | return self.error_handler.handle_validation_error(e, schema_path) 80 | 81 | # Lab 파일 로드 및 검증 82 | try: 83 | logging.debug(f"Loading lab file from: {lab_path}") 84 | lab_content = self.file_loader.load(lab_path) 85 | lab_data = json.loads(lab_content) 86 | logging.debug("Lab file loaded successfully") 87 | except FileNotFoundError as e: 88 | return self.error_handler.handle_file_error(e, lab_path) 89 | except json.JSONDecodeError as e: 90 | logging.error(f"Invalid JSON in lab file: {lab_path}") 91 | return self.error_handler.handle_file_error(e, lab_path) 92 | 93 | # 재귀적 검증 수행 94 | return self._recursive_validate(lab_data, schema_data, lab_path) 95 | 96 | except Exception as e: 97 | return self.error_handler.handle_validation_error(e, lab_path) 98 | 99 | def _recursive_validate( 100 | self, lab_data: Dict[str, Any], schema_data: Dict[str, Any], lab_path: str 101 | ) -> ValidationResult: 102 | """Lab 파일과 그것이 import하는 모든 파일들을 재귀적으로 검증. 103 | 104 | Args: 105 | lab_data: 검증할 lab 파일 데이터 106 | schema_data: 검증에 사용할 JSON 스키마 107 | lab_path: lab 파일의 경로 (에러 메시지용) 108 | 109 | Returns: 110 | ValidationResult: 검증 결과 111 | """ 112 | try: 113 | # import하는 파일들도 검증 114 | if "import" in lab_data: 115 | basedir = os.path.dirname(lab_path) 116 | for imp in lab_data["import"]: 117 | import_path = os.path.join(basedir, f"{imp}.lab.json") 118 | 119 | try: 120 | logging.debug(f"Importing file: {import_path}") 121 | import_content = self.file_loader.load(import_path) 122 | import_data = json.loads(import_content) 123 | 124 | # 재귀적으로 import된 파일도 검증 125 | result = self._recursive_validate( 126 | import_data, schema_data, import_path 127 | ) 128 | if not result.success: 129 | # import된 파일에서 에러가 발생하면 컨텍스트 추가 130 | result.errors = [ 131 | f"In imported file {import_path}: {error}" 132 | for error in result.errors 133 | ] 134 | return result 135 | 136 | except Exception as e: 137 | return self.error_handler.handle_validation_error( 138 | e, f"importing {import_path} from {lab_path}" 139 | ) 140 | 141 | # 현재 파일 검증 142 | return self.json_validator.validate(lab_data, schema_data) 143 | 144 | except Exception as e: 145 | return self.error_handler.handle_validation_error(e, lab_path) 146 | -------------------------------------------------------------------------------- /loglab/template/tmpl_doc.html.jinja: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | {{ domain.name }}{% if 'version' in domain %} ({{ domain.version }}){% endif %} 5 | 6 | 7 | 8 | 133 | 134 | 135 | 136 | 151 | 152 |
153 | 154 |

{{ domain.name }}{% if 'desc' in domain %} - {{ domain.desc }}{% endif %}{% if 'version' in domain %} ({{ domain.version }}){% endif %}

155 | 156 | {% if types is defined %} 157 |
158 | {% for key in model.types.keys() %} 159 |
160 |
161 |

{{ types[loop.index0][0] }} - {{ types[loop.index0][1] }}

162 |
163 | {{ types[loop.index0][2] }} 164 |
165 | {% endfor %} 166 |
167 | {% endif %} 168 | 169 |
170 | {% for key in model.events.keys() %} 171 |
172 |
173 |

{{ events[loop.index0][0] }} - {{ events[loop.index0][1] }}

174 |
175 | {{ events[loop.index0][2] }} 176 |
177 | {% endfor %} 178 |
179 | 180 |
181 | 182 | 210 | 211 | 212 | 213 | -------------------------------------------------------------------------------- /docs/developer.rst: -------------------------------------------------------------------------------- 1 | LogLab 개발자 참고 2 | ================== 3 | 4 | 여기에는 로그랩을 개발하는 사람들을 위한 설명을 기술한다. 일반 사용자는 5 | 읽지 않아도 문제 없을 것이다. 6 | 7 | 실행 파일 이용과 빌드 8 | --------------------- 9 | 10 | 로그랩 코드에서 직접 실행파일을 빌드하고 싶다면 11 | `PyInstaller `__ 가 필요하다. PyInstaller 12 | 홈페이지를 참고하여 설치하자. 13 | 14 | .. note:: 15 | 16 | PyEnv를 사용하는 경우 빌드시 동적 라이브러리를 찾지 못해 에러가 나올 17 | 수 있다. 이때는 macOS의 경우 ``--enable-framework`` 옵션으로 파이썬을 18 | 빌드하여 설치해야 한다. 자세한 것은 `이 19 | 글 `__ 을 참고하자. 20 | 리눅스의 경우 ``--enable-shared`` 옵션으로 빌드한다. 21 | 22 | 윈도우에서 빌드는 로그랩이 별도 ``venv`` 없이 글로벌하게 설치된 것으로 23 | 전제한다. 설치 디렉토리에서 다음과 같이 한다. 24 | 25 | :: 26 | 27 | > tools\build.bat 28 | 29 | 리눅스/macOS 에서는 다음과 같이 빌드한다. 30 | 31 | :: 32 | 33 | $ sh tools/build.sh 34 | 35 | 정상적으로 빌드가 되면, ``dist/`` 디렉토리 아래 ``loglab.exe`` (윈도우) 36 | 또는 ``loglab`` (리눅스/macOS) 실행 파일이 만들어진다. 이것을 배포하면 37 | 되겠다. 38 | 39 | 테스트 실행 40 | ----------- 41 | 42 | 자동화된 개발 환경 설정 (권장) 43 | ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 44 | 45 | 새로 개선된 자동화된 테스트 환경을 사용하려면: 46 | 47 | .. code:: sh 48 | 49 | # 개발 환경 원클릭 설정 (의존성 설치 + pre-commit hooks 설정) 50 | make setup 51 | 52 | # 전체 테스트 및 품질 검사 실행 (포맷팅, 린팅, 테스트, 커버리지) 53 | make all 54 | 55 | 개별 테스트 명령어 56 | ~~~~~~~~~~~~~~~~~~ 57 | 58 | .. code:: sh 59 | 60 | # 기본 테스트 실행 61 | make test 62 | 63 | # 커버리지 포함 테스트 64 | make coverage 65 | 66 | # 코드 포맷팅 (black, isort) 67 | make format 68 | 69 | # 린팅 검사 (flake8) 70 | make lint 71 | 72 | # 보안 검사 (bandit, safety) 73 | make security 74 | 75 | # 다언어 코드 생성 테스트 76 | make test-python # Python 코드 생성 테스트 77 | make test-csharp # C# 코드 생성 테스트 (dotnet 필요) 78 | make test-cpp # C++ 코드 생성 테스트 (g++ 필요) 79 | make test-typescript # TypeScript 코드 생성 테스트 (Node.js 필요) 80 | make test-java # Java 코드 생성 테스트 (Maven 필요) 81 | 82 | # 전체 코드 생성 테스트 83 | make test-codegen # 모든 언어 코드 생성 테스트 84 | 85 | 기존 방식 (수동) 86 | ~~~~~~~~~~~~~~~~ 87 | 88 | 다음처럼 개발을 위한 추가 의존 패키지를 설치하고, 89 | 90 | .. code:: sh 91 | 92 | uv pip install -e ".[dev]" 93 | 94 | ``pytest`` 로 테스트를 수행한다. 95 | 96 | .. code:: sh 97 | 98 | pytest tests/ 99 | 100 | 101 | 로그 객체 테스트 102 | ----------------------- 103 | 104 | 여기서는 로그랩을 통해 성성된 언어별 로그 객체 코드를 테스트하는 방법을 설명한다. 105 | 106 | Python 로그 객체 테스트 107 | ~~~~~~~~~~~~~~~~~~~~~~~~~ 108 | 109 | 로그 객체를 위한 파이썬 파일을 생성하고 110 | 111 | .. code:: sh 112 | 113 | loglab object example/foo.lab.json py -o tests/loglab_foo.py 114 | 115 | ``tests/`` 디렉토리로 가서 테스트를 실행한다. 116 | 117 | .. code:: sh 118 | 119 | pytest test_log_objects_python.py 120 | 121 | C# 로그 객체 테스트 122 | ~~~~~~~~~~~~~~~~~~~ 123 | 124 | C# 코드 실행을 위한 설치가 필요하다. 125 | 126 | .. code:: sh 127 | 128 | sudo apt update 129 | sudo apt install -y wget apt-transport-https software-properties-common 130 | 131 | wget https://packages.microsoft.com/config/ubuntu/$(lsb_release -rs)/packages-microsoft-prod.deb 132 | sudo dpkg -i packages-microsoft-prod.deb 133 | sudo apt update 134 | 135 | sudo apt install -y dotnet-sdk-8.0 136 | 137 | 다음으로 로그 객체 파일을 생성하고 138 | 139 | .. code:: sh 140 | 141 | loglab object example/foo.lab.json cs -o tests/cstest/loglab_foo.cs 142 | 143 | ``tests/cstest/`` 디렉토리로 이동 후 실행한다. 144 | 145 | :: 146 | 147 | dotnet run 148 | 149 | .. _c-로그-객체-테스트-1: 150 | 151 | C++ 로그 객체 테스트 152 | ~~~~~~~~~~~~~~~~~~~~~~~~~ 153 | 154 | 테스트를 위해 먼저 ``gtest`` 를 설치가 필요하다. 155 | 156 | .. code:: sh 157 | 158 | sudo apt install libgtest-dev 159 | 160 | 다음으로 로그 객체를 위한 헤더 파일을 생성하고 161 | 162 | .. code:: sh 163 | 164 | loglab object example/foo.lab.json cpp -o tests/cpptest/loglab_foo.h 165 | 166 | ``tests/cpptest/`` 디렉토리로 가서 테스트 코드를 빌드하고 167 | 168 | .. code:: sh 169 | 170 | cd tests/cpptest 171 | g++ -std=c++17 -I. test_log_objects_cpp.cpp -lgtest -lgtest_main -lpthread -o test_log_objects_cpp 172 | 173 | 다음처럼 실행한다. 174 | 175 | .. code:: sh 176 | 177 | ./test_log_objects_cpp 178 | 179 | Running main() from ./googletest/src/gtest_main.cc 180 | [==========] Running 2 tests from 1 test suite. 181 | [----------] Global test environment set-up. 182 | [----------] 2 tests from StringTest 183 | [ RUN ] StringTest.Serialize 184 | [ OK ] StringTest.Serialize (0 ms) 185 | [ RUN ] StringTest.SerializeAfterReset 186 | [ OK ] StringTest.SerializeAfterReset (0 ms) 187 | [----------] 2 tests from StringTest (0 ms total) 188 | 189 | [----------] Global test environment tear-down 190 | [==========] 2 tests from 1 test suite ran. (0 ms total) 191 | [ PASSED ] 2 tests. 192 | 193 | Java 로그 객체 테스트 194 | ~~~~~~~~~~~~~~~~~~~ 195 | 196 | Java 코드 실행을 위해 Maven이 필요하다. 197 | 198 | .. code:: sh 199 | 200 | sudo apt update 201 | sudo apt install maven 202 | 203 | 다음으로 로그 객체를 위한 Java 파일을 생성한다. 204 | 205 | .. code:: sh 206 | 207 | loglab object example/foo.lab.json java -o tests/javatest/src/main/java/loglab_foo/LogLabFoo.java 208 | 209 | ``tests/javatest/`` 디렉토리로 이동 후 Maven을 사용하여 컴파일하고 실행한다. 210 | 211 | .. code:: sh 212 | 213 | cd tests/javatest 214 | mvn compile exec:java 215 | 216 | 성공적으로 실행되면 다음과 같은 출력을 볼 수 있다:: 217 | 218 | Testing Java Log Objects... 219 | Testing basic log object functionality... 220 | Login JSON: {"DateTime":"2025-07-29T10:02:19.804056945+09:00","Event":"Login","ServerNo":1,"AcntId":12345,"Platform":"ios","Category":1} 221 | ✓ Login event serialization test passed 222 | Logout JSON: {"DateTime":"2025-07-29T10:02:19.813285353+09:00","Event":"Logout","ServerNo":1,"AcntId":12345,"Category":1} 223 | ✓ Logout event serialization test passed 224 | ✓ All Java log object tests passed! 225 | 226 | 자동화된 테스트 및 CI/CD 227 | ------------------------ 228 | 229 | LogLab은 포괄적인 테스트 자동화 시스템을 갖추고 있다: 230 | 231 | GitHub Actions CI/CD 232 | ~~~~~~~~~~~~~~~~~~~~ 233 | 234 | - **자동 테스트**: 모든 push 및 pull request에서 자동 실행 235 | - **다중 Python 버전**: 3.9, 3.10, 3.11, 3.12 지원 236 | - **크로스 언어 테스트**: Python, C#, C++, TypeScript, Java 코드 생성 검증 237 | - **품질 검사**: 린팅, 보안 검사, 커버리지 리포팅 238 | 239 | Pre-commit Hooks 240 | ~~~~~~~~~~~~~~~~~ 241 | 242 | 개발 중 코드 품질을 자동으로 보장: 243 | 244 | .. code:: sh 245 | 246 | # pre-commit hooks 설치 (make setup에 포함됨) 247 | pre-commit install 248 | 249 | # 모든 파일에 대해 수동 실행 250 | pre-commit run --all-files 251 | 252 | 의존성 자동 관리 253 | ~~~~~~~~~~~~~~~~ 254 | 255 | - **Dependabot**: 주간 의존성 업데이트 자동 PR 256 | - **보안 업데이트**: 취약점 발견 시 자동 알림 257 | - **그룹화된 업데이트**: 개발/프로덕션 의존성 별도 관리 258 | 259 | 성능 및 통합 테스트 260 | ~~~~~~~~~~~~~~~~~~~ 261 | 262 | .. code:: sh 263 | 264 | # 성능 테스트 실행 265 | pytest tests/test_performance.py -v 266 | 267 | # 전체 통합 테스트 268 | pytest tests/test_integration.py -v 269 | 270 | 추가 문자열 현지화 271 | ------------------ 272 | 273 | 개발이 진행됨에 따라 새로이 추가된 문자열들 중 현지화 대상인 것들은 274 | 다음처럼 처리한다. 275 | 276 | ``xgettext`` 가 설치되어 있지 않으면 다음처럼 설치 후, 277 | 278 | :: 279 | 280 | sudo apt install gettext 281 | 282 | 다국어 문자열을 출력하는 것은 ``util.py`` 에 정의된 함수를 이용하는 것이 관례이다. 다음 명령어로 새로 추가된 문자열을 추출한다. 283 | 284 | .. code:: bash 285 | 286 | xgettext -o messages.pot util.py 287 | 288 | 이 ``messages.pot`` 파일에서 새로 추가된 텍스트를 참고하여 언어별 289 | ``.po`` 파일 (예: ``locales/en_US/LC_MESSAGES/messages.po``) 에 번역하여 290 | 추가한다. 291 | 292 | 이후 언어별로 다음처럼 ``.mo`` 파일로 컴파일한다. 293 | 294 | .. code:: bash 295 | 296 | msgfmt locales/en_US/LC_MESSAGES/base.po -o locales/en_US/LC_MESSAGES/base.mo 297 | 298 | 299 | 버전 업데이트 300 | ---------------- 301 | 302 | 일정 분량 이상의 새로운 기능이 추가되거나 버그가 수정되면 버전을 업데이트해야 한다. 업데이트는 다음과 같은 절차로 진행된다. 303 | 304 | 1. **변경 사항 기록**: `CHANGELOG.md` 파일에 변경 사항을 기록한다. 305 | 2. **버전 번호 업데이트**: ``version.py``, ``docs/conf.py`` 및 ``README.md`` 파일내 버전 번호를 업데이트한다. 306 | 3. **버전 태깅**: Git 에서 새로운 버전을 태깅하고 원격 저장소에도 ``push`` 한다. 307 | -------------------------------------------------------------------------------- /loglab/schema/log_validator.py: -------------------------------------------------------------------------------- 1 | """로그 파일 검증기.""" 2 | 3 | import copy 4 | import json 5 | from collections import defaultdict 6 | from typing import Any, Dict, List 7 | 8 | from jsonschema import ValidationError, validate 9 | 10 | from .config import SchemaConfig 11 | from .implementations import DefaultErrorHandler 12 | from .interfaces import ErrorHandler, ValidationResult 13 | 14 | 15 | class LogFileValidator: 16 | """로그 파일 검증기 클래스.""" 17 | 18 | def __init__(self, error_handler: ErrorHandler = None, config: SchemaConfig = None): 19 | """LogFileValidator 초기화. 20 | 21 | Args: 22 | error_handler: 에러 처리기 23 | config: 설정 객체 24 | """ 25 | self.error_handler = error_handler or DefaultErrorHandler() 26 | self.config = config or SchemaConfig() 27 | 28 | def validate_logfile(self, schema_path: str, log_path: str) -> ValidationResult: 29 | """실제 로그 파일이 생성된 스키마에 맞는지 검증. 30 | 31 | Args: 32 | schema_path: 로그 검증용 JSON 스키마 파일 경로 33 | log_path: 검증할 로그 파일 경로 34 | 35 | Returns: 36 | ValidationResult: 검증 결과 37 | """ 38 | try: 39 | # 스키마 로드 및 파싱 40 | schema_data = self._load_schema(schema_path) 41 | if not schema_data: 42 | return ValidationResult( 43 | success=False, errors=["Failed to load schema file"] 44 | ) 45 | 46 | # 이벤트별 스키마 생성 47 | event_schemas = self._create_event_schemas(schema_data) 48 | 49 | # 로그 파일 검증 50 | return self._validate_log_entries(log_path, event_schemas) 51 | 52 | except Exception as e: 53 | return self.error_handler.handle_validation_error( 54 | e, f"validating {log_path}" 55 | ) 56 | 57 | def _load_schema(self, schema_path: str) -> Dict[str, Any]: 58 | """스키마 파일을 로드하고 파싱. 59 | 60 | Args: 61 | schema_path: 스키마 파일 경로 62 | 63 | Returns: 64 | 파싱된 스키마 데이터 65 | """ 66 | try: 67 | with open(schema_path, "rt", encoding=self.config.encoding) as f: 68 | content = f.read() 69 | return json.loads(content) 70 | except json.JSONDecodeError as e: 71 | print("Error: 로그랩이 생성한 JSON 스키마 에러. 로그랩 개발자에 문의 요망.") 72 | print(e) 73 | return None 74 | except Exception as e: 75 | print(f"Error loading schema: {e}") 76 | return None 77 | 78 | def _create_event_schemas( 79 | self, schema_data: Dict[str, Any] 80 | ) -> Dict[str, Dict[str, Any]]: 81 | """전체 스키마에서 이벤트별 스키마를 생성. 82 | 83 | Args: 84 | schema_data: 전체 스키마 데이터 85 | 86 | Returns: 87 | 이벤트별 스키마 딕셔너리 88 | """ 89 | event_schemas = {} 90 | 91 | for ref in schema_data["items"]["oneOf"]: 92 | event_name = ref["$ref"].split("/")[-1] 93 | 94 | # 각 이벤트용 스키마 생성 (다른 이벤트는 제거) 95 | event_schema = copy.deepcopy(schema_data) 96 | event_schema["$defs"] = {event_name: schema_data["$defs"][event_name]} 97 | event_schema["items"] = ref 98 | 99 | event_schemas[event_name] = event_schema 100 | 101 | return event_schemas 102 | 103 | def _validate_log_entries( 104 | self, log_path: str, event_schemas: Dict[str, Dict[str, Any]] 105 | ) -> ValidationResult: 106 | """로그 엔트리들을 검증. 107 | 108 | Args: 109 | log_path: 로그 파일 경로 110 | event_schemas: 이벤트별 스키마 111 | 112 | Returns: 113 | ValidationResult: 검증 결과 114 | """ 115 | try: 116 | # 로그 파일 파싱 및 이벤트별 분류 117 | log_entries = self._parse_log_file(log_path, event_schemas) 118 | if not log_entries.success: 119 | return log_entries 120 | 121 | # 이벤트별로 검증 수행 122 | return self._validate_by_events(log_entries.data, event_schemas) 123 | 124 | except Exception as e: 125 | return self.error_handler.handle_validation_error(e, f"parsing {log_path}") 126 | 127 | def _parse_log_file( 128 | self, log_path: str, event_schemas: Dict[str, Dict[str, Any]] 129 | ) -> ValidationResult: 130 | """로그 파일을 파싱하고 이벤트별로 분류. 131 | 132 | Args: 133 | log_path: 로그 파일 경로 134 | event_schemas: 이벤트별 스키마 135 | 136 | Returns: 137 | ValidationResult: 파싱 결과 138 | """ 139 | event_line_numbers = defaultdict(list) 140 | event_logs = defaultdict(list) 141 | 142 | try: 143 | with open(log_path, "rt", encoding=self.config.encoding) as f: 144 | for line_no, line in enumerate(f): 145 | try: 146 | log_entry = json.loads(line) 147 | except json.JSONDecodeError as e: 148 | return ValidationResult( 149 | success=False, 150 | errors=[ 151 | f"[Line: {line_no + 1}] 유효한 JSON 형식이 아닙니다: {e}" 152 | ], 153 | ) 154 | 155 | # 이벤트 타입 확인 156 | if ( 157 | "Event" not in log_entry 158 | or log_entry["Event"] not in event_schemas 159 | ): 160 | return ValidationResult( 161 | success=False, 162 | errors=[ 163 | f"[Line: {line_no + 1}] 스키마에서 이벤트를 찾을 수 없습니다: {line.strip()}" 164 | ], 165 | ) 166 | 167 | event_name = log_entry["Event"] 168 | event_line_numbers[event_name].append(line_no) 169 | event_logs[event_name].append(log_entry) 170 | 171 | return ValidationResult( 172 | success=True, 173 | data={ 174 | "event_logs": event_logs, 175 | "event_line_numbers": event_line_numbers, 176 | }, 177 | ) 178 | 179 | except Exception as e: 180 | return ValidationResult( 181 | success=False, errors=[f"Error reading log file: {e}"] 182 | ) 183 | 184 | def _validate_by_events( 185 | self, log_data: Dict[str, Any], event_schemas: Dict[str, Dict[str, Any]] 186 | ) -> ValidationResult: 187 | """이벤트별로 로그 엔트리들을 검증. 188 | 189 | Args: 190 | log_data: 이벤트별로 분류된 로그 데이터 191 | event_schemas: 이벤트별 스키마 192 | 193 | Returns: 194 | ValidationResult: 검증 결과 195 | """ 196 | event_logs = log_data["event_logs"] 197 | event_line_numbers = log_data["event_line_numbers"] 198 | 199 | for event_name, logs in event_logs.items(): 200 | schema = event_schemas[event_name] 201 | 202 | try: 203 | validate(logs, schema=schema) 204 | except ValidationError as e: 205 | # 에러가 발생한 로그의 라인 번호 찾기 206 | error_index = list(e.absolute_path)[0] 207 | line_no = event_line_numbers[event_name][error_index] 208 | error_log = logs[error_index] 209 | 210 | return ValidationResult( 211 | success=False, 212 | errors=[ 213 | f"[Line: {line_no + 1}] {e.message}: {json.dumps(error_log, ensure_ascii=False)}" 214 | ], 215 | ) 216 | 217 | return ValidationResult(success=True) 218 | -------------------------------------------------------------------------------- /tests/test_implementations_extended.py: -------------------------------------------------------------------------------- 1 | """확장된 schema implementations 테스트.""" 2 | 3 | import json 4 | import os 5 | import tempfile 6 | from unittest.mock import Mock, mock_open, patch 7 | 8 | import pytest 9 | 10 | from loglab.schema.implementations import ( 11 | DefaultErrorHandler, 12 | DefaultFileLoader, 13 | DefaultJsonValidator, 14 | ) 15 | from loglab.schema.interfaces import ValidationResult 16 | 17 | 18 | class TestDefaultFileLoader: 19 | """DefaultFileLoader 확장 테스트.""" 20 | 21 | def test_load_large_file(self): 22 | """대용량 파일 로드.""" 23 | large_content = '{"data": "' + "x" * 10000 + '"}' 24 | 25 | with tempfile.NamedTemporaryFile(mode="w", delete=False, encoding="utf8") as f: 26 | f.write(large_content) 27 | temp_path = f.name 28 | 29 | try: 30 | loader = DefaultFileLoader() 31 | content = loader.load(temp_path) 32 | assert len(content) > 10000 33 | assert '"data":' in content 34 | finally: 35 | os.unlink(temp_path) 36 | 37 | def test_load_empty_file(self): 38 | """빈 파일 로드.""" 39 | with tempfile.NamedTemporaryFile(mode="w", delete=False, encoding="utf8") as f: 40 | temp_path = f.name 41 | 42 | try: 43 | loader = DefaultFileLoader() 44 | content = loader.load(temp_path) 45 | assert content == "" 46 | finally: 47 | os.unlink(temp_path) 48 | 49 | def test_load_permission_error(self): 50 | """권한 에러 처리.""" 51 | loader = DefaultFileLoader() 52 | 53 | with patch("builtins.open", side_effect=PermissionError("Access denied")): 54 | with pytest.raises(PermissionError): 55 | loader.load("/restricted/file.json") 56 | 57 | 58 | class TestDefaultJsonValidator: 59 | """DefaultJsonValidator 확장 테스트.""" 60 | 61 | def test_validate_complex_schema(self): 62 | """복잡한 스키마 검증.""" 63 | validator = DefaultJsonValidator() 64 | 65 | complex_schema = { 66 | "type": "object", 67 | "properties": { 68 | "events": { 69 | "type": "array", 70 | "items": { 71 | "type": "object", 72 | "properties": { 73 | "name": {"type": "string"}, 74 | "timestamp": {"type": "string", "format": "date-time"}, 75 | "data": {"type": "object"}, 76 | }, 77 | "required": ["name", "timestamp"], 78 | }, 79 | } 80 | }, 81 | "required": ["events"], 82 | } 83 | 84 | valid_data = { 85 | "events": [ 86 | { 87 | "name": "test_event", 88 | "timestamp": "2023-01-01T00:00:00Z", 89 | "data": {"key": "value"}, 90 | } 91 | ] 92 | } 93 | 94 | result = validator.validate(valid_data, complex_schema) 95 | assert result.success is True 96 | assert result.data == valid_data 97 | 98 | def test_validate_nested_validation_error(self): 99 | """중첩된 검증 에러.""" 100 | validator = DefaultJsonValidator() 101 | 102 | schema = { 103 | "type": "object", 104 | "properties": { 105 | "nested": { 106 | "type": "object", 107 | "properties": {"required_field": {"type": "string"}}, 108 | "required": ["required_field"], 109 | } 110 | }, 111 | "required": ["nested"], 112 | } 113 | 114 | invalid_data = {"nested": {"wrong_field": "value"}} 115 | 116 | result = validator.validate(invalid_data, schema) 117 | assert result.success is False 118 | assert len(result.errors) > 0 119 | assert "required_field" in result.errors[0] 120 | 121 | def test_validate_type_coercion_error(self): 122 | """타입 강제 변환 에러.""" 123 | validator = DefaultJsonValidator() 124 | 125 | schema = { 126 | "type": "object", 127 | "properties": { 128 | "number_field": {"type": "number"}, 129 | "string_field": {"type": "string"}, 130 | }, 131 | } 132 | 133 | invalid_data = {"number_field": "not_a_number", "string_field": 123} 134 | 135 | result = validator.validate(invalid_data, schema) 136 | assert result.success is False 137 | assert "not_a_number" in result.errors[0] 138 | 139 | 140 | class TestDefaultErrorHandler: 141 | """DefaultErrorHandler 확장 테스트.""" 142 | 143 | def test_handle_validation_error_with_context(self): 144 | """컨텍스트가 있는 검증 에러 처리.""" 145 | handler = DefaultErrorHandler() 146 | error = ValueError("Invalid value") 147 | 148 | result = handler.handle_validation_error(error, "user_input") 149 | 150 | assert result.success is False 151 | assert "user_input" in result.errors[0] 152 | assert "Invalid value" in result.errors[0] 153 | 154 | def test_handle_validation_error_without_context(self): 155 | """컨텍스트가 없는 검증 에러 처리.""" 156 | handler = DefaultErrorHandler() 157 | error = ValueError("Invalid value") 158 | 159 | result = handler.handle_validation_error(error) 160 | 161 | assert result.success is False 162 | assert "Invalid value" in result.errors[0] 163 | 164 | def test_handle_file_not_found_error(self): 165 | """파일 찾을 수 없음 에러 처리.""" 166 | handler = DefaultErrorHandler() 167 | error = FileNotFoundError("File not found") 168 | 169 | result = handler.handle_file_error(error, "/path/to/file.json") 170 | 171 | assert result.success is False 172 | assert "File not found" in result.errors[0] 173 | assert "/path/to/file.json" in result.errors[0] 174 | 175 | def test_handle_json_decode_error(self): 176 | """JSON 디코드 에러 처리.""" 177 | handler = DefaultErrorHandler() 178 | error = json.JSONDecodeError("Invalid JSON", "doc", 10) 179 | 180 | result = handler.handle_file_error(error, "config.json") 181 | 182 | assert result.success is False 183 | assert "Invalid JSON" in result.errors[0] 184 | assert "config.json" in result.errors[0] 185 | 186 | def test_handle_generic_file_error(self): 187 | """일반 파일 에러 처리.""" 188 | handler = DefaultErrorHandler() 189 | error = PermissionError("Access denied") 190 | 191 | result = handler.handle_file_error(error, "restricted.json") 192 | 193 | assert result.success is False 194 | assert "Access denied" in result.errors[0] 195 | 196 | def test_handle_file_error_without_path(self): 197 | """경로 없는 파일 에러 처리.""" 198 | handler = DefaultErrorHandler() 199 | error = FileNotFoundError("File not found") 200 | 201 | result = handler.handle_file_error(error) 202 | 203 | assert result.success is False 204 | assert "unknown" in result.errors[0] 205 | 206 | 207 | class TestValidationResult: 208 | """ValidationResult 확장 테스트.""" 209 | 210 | def test_validation_result_success_properties(self): 211 | """성공 결과 속성 확인.""" 212 | result = ValidationResult(success=True, data={"key": "value"}) 213 | 214 | assert result.success is True 215 | assert result.data == {"key": "value"} 216 | assert result.errors == [] 217 | 218 | def test_validation_result_failure_properties(self): 219 | """실패 결과 속성 확인.""" 220 | errors = ["Error 1", "Error 2"] 221 | result = ValidationResult(success=False, errors=errors) 222 | 223 | assert result.success is False 224 | assert result.errors == errors 225 | assert result.data is None 226 | 227 | def test_validation_result_default_values(self): 228 | """기본값 확인.""" 229 | result = ValidationResult(success=True) 230 | 231 | assert result.success is True 232 | assert result.data is None 233 | assert result.errors == [] 234 | -------------------------------------------------------------------------------- /tests/tstest/loglab_foo.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | /* 3 | 4 | ** 이 파일은 LogLab 에서 생성된 것입니다. 고치지 마세요! ** 5 | 6 | Domain: foo 7 | Description: 위대한 모바일 게임 8 | 9 | */ 10 | Object.defineProperty(exports, "__esModule", { value: true }); 11 | exports.GetItem = exports.MonsterDropItem = exports.KillMonster = exports.CharLogout = exports.CharLogin = exports.Logout = exports.Login = void 0; 12 | /** 13 | * 계정 로그인 14 | */ 15 | var Login = /** @class */ (function () { 16 | function Login(_ServerNo, _AcntId, _Platform) { 17 | this.Event = "Login"; 18 | this.reset(_ServerNo, _AcntId, _Platform); 19 | } 20 | Login.prototype.reset = function (_ServerNo, _AcntId, _Platform) { 21 | this.ServerNo = _ServerNo; 22 | this.AcntId = _AcntId; 23 | this.Platform = _Platform; 24 | }; 25 | Login.prototype.serialize = function () { 26 | var data = { 27 | DateTime: new Date().toISOString(), 28 | Event: "Login" 29 | }; 30 | data["ServerNo"] = this.ServerNo; 31 | data["AcntId"] = this.AcntId; 32 | data["Category"] = 1; 33 | data["Platform"] = this.Platform; 34 | return JSON.stringify(data); 35 | }; 36 | return Login; 37 | }()); 38 | exports.Login = Login; 39 | /** 40 | * 계정 로그아웃 41 | */ 42 | var Logout = /** @class */ (function () { 43 | function Logout(_ServerNo, _AcntId) { 44 | this.Event = "Logout"; 45 | // 플레이 시간 (초) 46 | this.PlayTime = null; 47 | this.reset(_ServerNo, _AcntId); 48 | } 49 | Logout.prototype.reset = function (_ServerNo, _AcntId) { 50 | this.ServerNo = _ServerNo; 51 | this.AcntId = _AcntId; 52 | this.PlayTime = null; 53 | }; 54 | Logout.prototype.serialize = function () { 55 | var data = { 56 | DateTime: new Date().toISOString(), 57 | Event: "Logout" 58 | }; 59 | data["ServerNo"] = this.ServerNo; 60 | data["AcntId"] = this.AcntId; 61 | data["Category"] = 1; 62 | if (this.PlayTime !== null) { 63 | data["PlayTime"] = this.PlayTime; 64 | } 65 | return JSON.stringify(data); 66 | }; 67 | return Logout; 68 | }()); 69 | exports.Logout = Logout; 70 | /** 71 | * 캐릭터 로그인 72 | */ 73 | var CharLogin = /** @class */ (function () { 74 | function CharLogin(_ServerNo, _AcntId, _CharId) { 75 | this.Event = "CharLogin"; 76 | this.reset(_ServerNo, _AcntId, _CharId); 77 | } 78 | CharLogin.prototype.reset = function (_ServerNo, _AcntId, _CharId) { 79 | this.ServerNo = _ServerNo; 80 | this.AcntId = _AcntId; 81 | this.CharId = _CharId; 82 | }; 83 | CharLogin.prototype.serialize = function () { 84 | var data = { 85 | DateTime: new Date().toISOString(), 86 | Event: "CharLogin" 87 | }; 88 | data["ServerNo"] = this.ServerNo; 89 | data["AcntId"] = this.AcntId; 90 | data["Category"] = 2; 91 | data["CharId"] = this.CharId; 92 | return JSON.stringify(data); 93 | }; 94 | return CharLogin; 95 | }()); 96 | exports.CharLogin = CharLogin; 97 | /** 98 | * 캐릭터 로그아웃 99 | */ 100 | var CharLogout = /** @class */ (function () { 101 | function CharLogout(_ServerNo, _AcntId, _CharId) { 102 | this.Event = "CharLogout"; 103 | this.reset(_ServerNo, _AcntId, _CharId); 104 | } 105 | CharLogout.prototype.reset = function (_ServerNo, _AcntId, _CharId) { 106 | this.ServerNo = _ServerNo; 107 | this.AcntId = _AcntId; 108 | this.CharId = _CharId; 109 | }; 110 | CharLogout.prototype.serialize = function () { 111 | var data = { 112 | DateTime: new Date().toISOString(), 113 | Event: "CharLogout" 114 | }; 115 | data["ServerNo"] = this.ServerNo; 116 | data["AcntId"] = this.AcntId; 117 | data["Category"] = 2; 118 | data["CharId"] = this.CharId; 119 | return JSON.stringify(data); 120 | }; 121 | return CharLogout; 122 | }()); 123 | exports.CharLogout = CharLogout; 124 | /** 125 | * 몬스터를 잡음 126 | */ 127 | var KillMonster = /** @class */ (function () { 128 | function KillMonster(_ServerNo, _AcntId, _CharId, _MapCd, _PosX, _PosY, _PosZ, _MonsterCd, _MonsterId) { 129 | this.Event = "KillMonster"; 130 | this.reset(_ServerNo, _AcntId, _CharId, _MapCd, _PosX, _PosY, _PosZ, _MonsterCd, _MonsterId); 131 | } 132 | KillMonster.prototype.reset = function (_ServerNo, _AcntId, _CharId, _MapCd, _PosX, _PosY, _PosZ, _MonsterCd, _MonsterId) { 133 | this.ServerNo = _ServerNo; 134 | this.AcntId = _AcntId; 135 | this.CharId = _CharId; 136 | this.MapCd = _MapCd; 137 | this.PosX = _PosX; 138 | this.PosY = _PosY; 139 | this.PosZ = _PosZ; 140 | this.MonsterCd = _MonsterCd; 141 | this.MonsterId = _MonsterId; 142 | }; 143 | KillMonster.prototype.serialize = function () { 144 | var data = { 145 | DateTime: new Date().toISOString(), 146 | Event: "KillMonster" 147 | }; 148 | data["ServerNo"] = this.ServerNo; 149 | data["AcntId"] = this.AcntId; 150 | data["Category"] = 2; 151 | data["CharId"] = this.CharId; 152 | data["MapCd"] = this.MapCd; 153 | data["PosX"] = this.PosX; 154 | data["PosY"] = this.PosY; 155 | data["PosZ"] = this.PosZ; 156 | data["MonsterCd"] = this.MonsterCd; 157 | data["MonsterId"] = this.MonsterId; 158 | return JSON.stringify(data); 159 | }; 160 | return KillMonster; 161 | }()); 162 | exports.KillMonster = KillMonster; 163 | /** 164 | * 몬스터가 아이템을 떨어뜨림 165 | */ 166 | var MonsterDropItem = /** @class */ (function () { 167 | function MonsterDropItem(_ServerNo, _MonsterCd, _MonsterId, _MapCd, _PosX, _PosY, _PosZ, _ItemCd, _ItemId) { 168 | this.Event = "MonsterDropItem"; 169 | this.reset(_ServerNo, _MonsterCd, _MonsterId, _MapCd, _PosX, _PosY, _PosZ, _ItemCd, _ItemId); 170 | } 171 | MonsterDropItem.prototype.reset = function (_ServerNo, _MonsterCd, _MonsterId, _MapCd, _PosX, _PosY, _PosZ, _ItemCd, _ItemId) { 172 | this.ServerNo = _ServerNo; 173 | this.MonsterCd = _MonsterCd; 174 | this.MonsterId = _MonsterId; 175 | this.MapCd = _MapCd; 176 | this.PosX = _PosX; 177 | this.PosY = _PosY; 178 | this.PosZ = _PosZ; 179 | this.ItemCd = _ItemCd; 180 | this.ItemId = _ItemId; 181 | }; 182 | MonsterDropItem.prototype.serialize = function () { 183 | var data = { 184 | DateTime: new Date().toISOString(), 185 | Event: "MonsterDropItem" 186 | }; 187 | data["ServerNo"] = this.ServerNo; 188 | data["Category"] = 3; 189 | data["MonsterCd"] = this.MonsterCd; 190 | data["MonsterId"] = this.MonsterId; 191 | data["MapCd"] = this.MapCd; 192 | data["PosX"] = this.PosX; 193 | data["PosY"] = this.PosY; 194 | data["PosZ"] = this.PosZ; 195 | data["ItemCd"] = this.ItemCd; 196 | data["ItemId"] = this.ItemId; 197 | return JSON.stringify(data); 198 | }; 199 | return MonsterDropItem; 200 | }()); 201 | exports.MonsterDropItem = MonsterDropItem; 202 | /** 203 | * 캐릭터의 아이템 습득 204 | */ 205 | var GetItem = /** @class */ (function () { 206 | function GetItem(_ServerNo, _AcntId, _CharId, _MapCd, _PosX, _PosY, _PosZ, _ItemCd, _ItemId) { 207 | this.Event = "GetItem"; 208 | this.reset(_ServerNo, _AcntId, _CharId, _MapCd, _PosX, _PosY, _PosZ, _ItemCd, _ItemId); 209 | } 210 | GetItem.prototype.reset = function (_ServerNo, _AcntId, _CharId, _MapCd, _PosX, _PosY, _PosZ, _ItemCd, _ItemId) { 211 | this.ServerNo = _ServerNo; 212 | this.AcntId = _AcntId; 213 | this.CharId = _CharId; 214 | this.MapCd = _MapCd; 215 | this.PosX = _PosX; 216 | this.PosY = _PosY; 217 | this.PosZ = _PosZ; 218 | this.ItemCd = _ItemCd; 219 | this.ItemId = _ItemId; 220 | }; 221 | GetItem.prototype.serialize = function () { 222 | var data = { 223 | DateTime: new Date().toISOString(), 224 | Event: "GetItem" 225 | }; 226 | data["ServerNo"] = this.ServerNo; 227 | data["AcntId"] = this.AcntId; 228 | data["Category"] = 2; 229 | data["CharId"] = this.CharId; 230 | data["MapCd"] = this.MapCd; 231 | data["PosX"] = this.PosX; 232 | data["PosY"] = this.PosY; 233 | data["PosZ"] = this.PosZ; 234 | data["ItemCd"] = this.ItemCd; 235 | data["ItemId"] = this.ItemId; 236 | return JSON.stringify(data); 237 | }; 238 | return GetItem; 239 | }()); 240 | exports.GetItem = GetItem; 241 | -------------------------------------------------------------------------------- /tests/test_performance.py: -------------------------------------------------------------------------------- 1 | """성능 테스트.""" 2 | 3 | import json 4 | import os 5 | import tempfile 6 | import time 7 | from unittest.mock import patch 8 | 9 | import psutil 10 | import pytest 11 | from click.testing import CliRunner 12 | 13 | from loglab.cli import cli 14 | from loglab.model import build_model 15 | from loglab.schema.generator import LogSchemaGenerator 16 | 17 | 18 | class TestPerformance: 19 | """성능 테스트 클래스.""" 20 | 21 | @pytest.fixture 22 | def large_lab_data(self): 23 | """대용량 lab 데이터 생성.""" 24 | lab_data = { 25 | "domain": { 26 | "name": "performance_test", 27 | "desc": "성능 테스트용 대용량 도메인", 28 | }, 29 | "types": {}, 30 | "bases": {}, 31 | "events": {}, 32 | } 33 | 34 | # 대량의 커스텀 타입 생성 35 | for i in range(50): 36 | lab_data["types"][f"CustomType{i}"] = { 37 | "type": "integer", 38 | "desc": f"커스텀 타입 {i}", 39 | "minimum": 0, 40 | "maximum": 1000, 41 | } 42 | 43 | # 대량의 베이스 생성 44 | for i in range(20): 45 | lab_data["bases"][f"Base{i}"] = { 46 | "desc": f"베이스 클래스 {i}", 47 | "fields": [ 48 | [f"Field{j}", f"types.CustomType{j % 50}", f"필드 {j}"] 49 | for j in range(10) 50 | ], 51 | } 52 | 53 | # 대량의 이벤트 생성 54 | for i in range(200): 55 | event_fields = [ 56 | ["DateTime", "datetime", "이벤트 일시"], 57 | ["EventId", "integer", "이벤트 ID"], 58 | ["UserId", f"types.CustomType{i % 50}", "사용자 ID"], 59 | ] 60 | 61 | # 일부 이벤트에 베이스 믹스인 추가 62 | mixins = [] 63 | if i % 10 == 0: 64 | mixins.append(f"bases.Base{i % 20}") 65 | 66 | # 추가 필드 67 | for j in range(15): 68 | event_fields.append( 69 | [ 70 | f"Field{j}", 71 | ["string", "integer", "number", "boolean"][j % 4], 72 | f"필드 {j} 설명", 73 | ] 74 | ) 75 | 76 | lab_data["events"][f"Event{i}"] = { 77 | "desc": f"이벤트 {i} 설명", 78 | "fields": event_fields, 79 | } 80 | 81 | if mixins: 82 | lab_data["events"][f"Event{i}"]["mixins"] = mixins 83 | 84 | return lab_data 85 | 86 | def test_model_build_performance(self, large_lab_data): 87 | """모델 빌드 성능 테스트.""" 88 | start_time = time.time() 89 | 90 | model = build_model(large_lab_data) 91 | 92 | end_time = time.time() 93 | build_time = end_time - start_time 94 | 95 | # 5초 이내에 빌드되어야 함 96 | assert build_time < 5.0, f"Model build took {build_time:.2f}s, expected < 5.0s" 97 | 98 | # 모델이 올바르게 생성되었는지 확인 99 | assert hasattr(model, "domain") 100 | assert hasattr(model, "events") 101 | assert len(model.events) == 200 102 | 103 | def test_schema_generation_performance(self, large_lab_data): 104 | """스키마 생성 성능 테스트.""" 105 | generator = LogSchemaGenerator() 106 | 107 | start_time = time.time() 108 | 109 | schema = generator.generate_schema(large_lab_data) 110 | 111 | end_time = time.time() 112 | generation_time = end_time - start_time 113 | 114 | # 10초 이내에 생성되어야 함 115 | assert ( 116 | generation_time < 10.0 117 | ), f"Schema generation took {generation_time:.2f}s, expected < 10.0s" 118 | 119 | # 스키마가 올바르게 생성되었는지 확인 120 | assert isinstance(schema, str) 121 | assert "performance_test" in schema 122 | assert "$defs" in schema 123 | 124 | def test_memory_usage(self, large_lab_data): 125 | """메모리 사용량 테스트.""" 126 | process = psutil.Process() 127 | 128 | # 초기 메모리 사용량 129 | initial_memory = process.memory_info().rss / 1024 / 1024 # MB 130 | 131 | # 모델 빌드 132 | model = build_model(large_lab_data) 133 | 134 | # 스키마 생성 135 | generator = LogSchemaGenerator() 136 | schema = generator.generate_schema(large_lab_data) 137 | 138 | # 최종 메모리 사용량 139 | final_memory = process.memory_info().rss / 1024 / 1024 # MB 140 | memory_increase = final_memory - initial_memory 141 | 142 | # 메모리 증가량이 100MB 이하여야 함 143 | assert ( 144 | memory_increase < 100 145 | ), f"Memory increase: {memory_increase:.2f}MB, expected < 100MB" 146 | 147 | def test_cli_performance(self, large_lab_data): 148 | """CLI 성능 테스트.""" 149 | with tempfile.NamedTemporaryFile( 150 | mode="w", delete=False, suffix=".lab.json" 151 | ) as f: 152 | json.dump(large_lab_data, f, ensure_ascii=False) 153 | lab_path = f.name 154 | 155 | runner = CliRunner() 156 | 157 | try: 158 | # show 명령 성능 159 | start_time = time.time() 160 | result = runner.invoke(cli, ["show", lab_path]) 161 | show_time = time.time() - start_time 162 | 163 | assert result.exit_code == 0 164 | assert show_time < 3.0, f"CLI show took {show_time:.2f}s, expected < 3.0s" 165 | 166 | # schema 명령 성능 167 | start_time = time.time() 168 | result = runner.invoke(cli, ["schema", lab_path]) 169 | schema_time = time.time() - start_time 170 | 171 | assert result.exit_code == 0 172 | assert ( 173 | schema_time < 8.0 174 | ), f"CLI schema took {schema_time:.2f}s, expected < 8.0s" 175 | 176 | finally: 177 | os.unlink(lab_path) 178 | # 생성된 스키마 파일 정리 179 | schema_file = lab_path.replace(".lab.json", ".schema.json") 180 | if os.path.exists(schema_file): 181 | os.unlink(schema_file) 182 | 183 | 184 | class TestScalability: 185 | """확장성 테스트.""" 186 | 187 | @pytest.mark.parametrize("event_count", [10, 50, 100, 500]) 188 | def test_scaling_with_event_count(self, event_count): 189 | """이벤트 수에 따른 확장성 테스트.""" 190 | lab_data = { 191 | "domain": { 192 | "name": f"scale_test_{event_count}", 193 | "desc": f"{event_count}개 이벤트 확장성 테스트", 194 | }, 195 | "events": {}, 196 | } 197 | 198 | # 지정된 수만큼 이벤트 생성 199 | for i in range(event_count): 200 | lab_data["events"][f"Event{i}"] = { 201 | "desc": f"이벤트 {i}", 202 | "fields": [ 203 | ["DateTime", "datetime", "이벤트 일시"], 204 | ["EventId", "integer", "이벤트 ID"], 205 | ["Data", "string", "데이터"], 206 | ], 207 | } 208 | 209 | start_time = time.time() 210 | model = build_model(lab_data) 211 | build_time = time.time() - start_time 212 | 213 | # 빌드 시간이 이벤트 수에 비례해서 선형적으로 증가하는지 확인 214 | # 대략 이벤트당 0.01초 이하여야 함 215 | expected_max_time = event_count * 0.01 + 1.0 # 기본 오버헤드 1초 216 | assert ( 217 | build_time < expected_max_time 218 | ), f"Build time {build_time:.2f}s exceeded expected {expected_max_time:.2f}s for {event_count} events" 219 | 220 | assert len(model.events) == event_count 221 | 222 | def test_deep_inheritance_performance(self): 223 | """깊은 상속 구조 성능 테스트.""" 224 | lab_data = { 225 | "domain": { 226 | "name": "deep_inheritance_test", 227 | "desc": "깊은 상속 구조 테스트", 228 | }, 229 | "bases": {}, 230 | "events": {}, 231 | } 232 | 233 | # 깊은 상속 체인 생성 (10단계) 234 | for i in range(10): 235 | mixins = [] 236 | if i > 0: 237 | mixins.append(f"bases.Base{i-1}") 238 | 239 | lab_data["bases"][f"Base{i}"] = { 240 | "desc": f"베이스 {i}", 241 | "fields": [[f"Field{i}", "string", f"필드 {i}"]], 242 | } 243 | 244 | if mixins: 245 | lab_data["bases"][f"Base{i}"]["mixins"] = mixins 246 | 247 | # 최종 베이스를 사용하는 이벤트 248 | lab_data["events"]["DeepEvent"] = { 249 | "desc": "깊은 상속 이벤트", 250 | "mixins": ["bases.Base9"], 251 | "fields": [["EventData", "string", "이벤트 데이터"]], 252 | } 253 | 254 | start_time = time.time() 255 | model = build_model(lab_data) 256 | build_time = time.time() - start_time 257 | 258 | # 복잡한 상속 구조도 합리적인 시간 내에 처리되어야 함 259 | assert ( 260 | build_time < 2.0 261 | ), f"Deep inheritance build took {build_time:.2f}s, expected < 2.0s" 262 | 263 | # 모든 필드가 올바르게 상속되었는지 확인 264 | deep_event_fields = model.events["DeepEvent"][-1][1]["fields"] 265 | assert len(deep_event_fields) >= 10 # DateTime + EventData + 상속받은 필드들 266 | -------------------------------------------------------------------------------- /tests/test_cli_integration.py: -------------------------------------------------------------------------------- 1 | """CLI 통합 테스트 (Integration Tests).""" 2 | 3 | import json 4 | import os 5 | import tempfile 6 | from unittest.mock import Mock, patch 7 | 8 | import pytest 9 | from click.testing import CliRunner 10 | 11 | from loglab.cli import cli, html, object, schema, show, verify 12 | from loglab.util import test_reset 13 | 14 | 15 | class TestCLIErrorHandling: 16 | """CLI 에러 처리 테스트.""" 17 | 18 | @pytest.fixture 19 | def clear(self): 20 | test_reset() 21 | 22 | def test_cli_invalid_command(self): 23 | """잘못된 명령어 처리.""" 24 | runner = CliRunner() 25 | result = runner.invoke(cli, ["invalid_command"]) 26 | assert result.exit_code == 2 27 | assert "No such command" in result.output 28 | 29 | def test_show_missing_file(self): 30 | """존재하지 않는 파일 show 명령.""" 31 | runner = CliRunner() 32 | result = runner.invoke(show, ["nonexistent.lab.json"]) 33 | assert result.exit_code != 0 34 | 35 | def test_schema_invalid_lab_file(self): 36 | """잘못된 lab 파일로 스키마 생성.""" 37 | with tempfile.NamedTemporaryFile( 38 | mode="w", delete=False, suffix=".lab.json" 39 | ) as f: 40 | f.write('{"invalid": "json"') # 잘못된 JSON 41 | temp_path = f.name 42 | 43 | try: 44 | runner = CliRunner() 45 | result = runner.invoke(schema, [temp_path]) 46 | assert result.exit_code != 0 47 | finally: 48 | os.unlink(temp_path) 49 | 50 | def test_verify_with_invalid_schema(self): 51 | """잘못된 스키마로 검증.""" 52 | with tempfile.NamedTemporaryFile( 53 | mode="w", delete=False, suffix=".schema.json" 54 | ) as f: 55 | f.write("invalid json") 56 | schema_path = f.name 57 | 58 | with tempfile.NamedTemporaryFile(mode="w", delete=False, suffix=".log") as f: 59 | f.write('{"test": "log"}') 60 | log_path = f.name 61 | 62 | try: 63 | runner = CliRunner() 64 | result = runner.invoke(verify, [schema_path, log_path]) 65 | assert result.exit_code != 0 66 | finally: 67 | os.unlink(schema_path) 68 | os.unlink(log_path) 69 | 70 | 71 | class TestCLIPerformance: 72 | """CLI 성능 테스트.""" 73 | 74 | @pytest.fixture 75 | def large_lab_file(self): 76 | """대용량 lab 파일 생성.""" 77 | lab_data = { 78 | "domain": {"name": "performance_test", "desc": "성능 테스트용 도메인"}, 79 | "events": {}, 80 | } 81 | 82 | # 많은 이벤트 추가 83 | for i in range(100): 84 | lab_data["events"][f"Event{i}"] = { 85 | "desc": f"이벤트 {i}", 86 | "fields": ( 87 | [ 88 | ["DateTime", "datetime", "이벤트 일시"], 89 | ["EventId", "integer", "이벤트 ID"], 90 | ] 91 | + [[f"Field{j}", "string", f"필드 {j}"] for j in range(10)] 92 | ), 93 | } 94 | 95 | with tempfile.NamedTemporaryFile( 96 | mode="w", delete=False, suffix=".lab.json" 97 | ) as f: 98 | json.dump(lab_data, f, ensure_ascii=False, indent=2) 99 | return f.name 100 | 101 | def test_show_large_file_performance(self, large_lab_file): 102 | """대용량 파일 show 성능.""" 103 | runner = CliRunner() 104 | 105 | import time 106 | 107 | start_time = time.time() 108 | result = runner.invoke(show, [large_lab_file]) 109 | end_time = time.time() 110 | 111 | assert result.exit_code == 0 112 | assert end_time - start_time < 5.0 # 5초 이내 완료 113 | 114 | os.unlink(large_lab_file) 115 | 116 | def test_schema_generation_performance(self, large_lab_file): 117 | """스키마 생성 성능.""" 118 | runner = CliRunner() 119 | 120 | import time 121 | 122 | start_time = time.time() 123 | result = runner.invoke(schema, [large_lab_file]) 124 | end_time = time.time() 125 | 126 | assert result.exit_code == 0 127 | assert end_time - start_time < 10.0 # 10초 이내 완료 128 | 129 | # 생성된 스키마 파일 정리 130 | schema_file = large_lab_file.replace(".lab.json", ".schema.json") 131 | if os.path.exists(schema_file): 132 | os.unlink(schema_file) 133 | 134 | os.unlink(large_lab_file) 135 | 136 | 137 | class TestCLIIntegration: 138 | """CLI 통합 테스트.""" 139 | 140 | @pytest.fixture 141 | def clear(self): 142 | test_reset() 143 | 144 | @pytest.mark.skip(reason="Complex integration test - skip for now") 145 | def test_full_workflow(self, clear): 146 | """전체 워크플로우 테스트.""" 147 | # 1. lab 파일 생성 148 | lab_data = { 149 | "domain": {"name": "integration_test", "desc": "통합 테스트"}, 150 | "events": { 151 | "TestEvent": { 152 | "desc": "테스트 이벤트", 153 | "fields": [ 154 | ["DateTime", "datetime", "이벤트 일시"], 155 | ["UserId", "integer", "사용자 ID"], 156 | ["Message", "string", "메시지"], 157 | ], 158 | } 159 | }, 160 | } 161 | 162 | with tempfile.NamedTemporaryFile( 163 | mode="w", delete=False, suffix=".lab.json" 164 | ) as f: 165 | json.dump(lab_data, f, ensure_ascii=False) 166 | lab_path = f.name 167 | 168 | runner = CliRunner() 169 | 170 | try: 171 | # 2. show 명령 테스트 172 | result = runner.invoke(show, [lab_path]) 173 | assert result.exit_code == 0 174 | assert "integration_test" in result.output 175 | 176 | # 3. 스키마 생성 테스트 177 | result = runner.invoke(schema, [lab_path]) 178 | assert result.exit_code == 0 179 | 180 | # CLI에서는 현재 디렉토리에 스키마 파일을 생성하므로 경로 수정 181 | schema_filename = os.path.basename(lab_path).replace( 182 | ".lab.json", ".schema.json" 183 | ) 184 | schema_path = schema_filename 185 | assert os.path.exists(schema_path) 186 | 187 | # 4. HTML 문서 생성 테스트 188 | result = runner.invoke(html, [lab_path]) 189 | assert result.exit_code == 0 190 | 191 | html_filename = os.path.basename(lab_path).replace(".lab.json", ".html") 192 | html_path = html_filename 193 | assert os.path.exists(html_path) 194 | 195 | # 5. Python 객체 생성 테스트 196 | result = runner.invoke(object, [lab_path, "py"]) 197 | assert result.exit_code == 0 198 | assert "class TestEvent" in result.output 199 | 200 | # 6. 로그 검증 테스트 201 | log_data = { 202 | "DateTime": "2023-01-01T00:00:00+09:00", 203 | "Event": "TestEvent", 204 | "UserId": 123, 205 | "Message": "테스트 메시지", 206 | } 207 | 208 | with tempfile.NamedTemporaryFile( 209 | mode="w", delete=False, suffix=".log" 210 | ) as f: 211 | json.dump(log_data, f) 212 | log_path = f.name 213 | 214 | try: 215 | result = runner.invoke(verify, [schema_path, log_path]) 216 | assert result.exit_code == 0 217 | finally: 218 | os.unlink(log_path) 219 | 220 | finally: 221 | # 정리 222 | files_to_clean = [lab_path, schema_path, html_path] 223 | for file_path in files_to_clean: 224 | if os.path.exists(file_path): 225 | os.unlink(file_path) 226 | 227 | 228 | class TestCLIEdgeCases: 229 | """CLI 엣지 케이스 테스트.""" 230 | 231 | def test_empty_lab_file(self): 232 | """빈 lab 파일 처리.""" 233 | with tempfile.NamedTemporaryFile( 234 | mode="w", delete=False, suffix=".lab.json" 235 | ) as f: 236 | f.write("{}") 237 | temp_path = f.name 238 | 239 | try: 240 | runner = CliRunner() 241 | result = runner.invoke(show, [temp_path]) 242 | # 최소한 에러가 발생하더라도 크래시되지 않아야 함 243 | assert result.exit_code is not None 244 | finally: 245 | os.unlink(temp_path) 246 | 247 | @pytest.mark.skip(reason="Unicode handling test - skip for now") 248 | def test_unicode_content(self): 249 | """유니코드 콘텐츠 처리.""" 250 | lab_data = { 251 | "domain": {"name": "unicode_test", "desc": "한글 테스트 🚀"}, 252 | "events": { 253 | "유니코드이벤트": { 254 | "desc": "한글 이벤트 설명 🎉", 255 | "fields": [ 256 | ["DateTime", "datetime", "이벤트 일시"], 257 | ["한글필드", "string", "한글 필드 설명 ✨"], 258 | ], 259 | } 260 | }, 261 | } 262 | 263 | with tempfile.NamedTemporaryFile( 264 | mode="w", delete=False, suffix=".lab.json", encoding="utf-8" 265 | ) as f: 266 | json.dump(lab_data, f, ensure_ascii=False) 267 | temp_path = f.name 268 | 269 | try: 270 | runner = CliRunner() 271 | result = runner.invoke(show, [temp_path]) 272 | assert result.exit_code == 0 273 | assert "unicode_test" in result.output 274 | finally: 275 | os.unlink(temp_path) 276 | -------------------------------------------------------------------------------- /loglab/cli.py: -------------------------------------------------------------------------------- 1 | """LogLab 커맨드라인 툴.""" 2 | 3 | import codecs 4 | import json 5 | import logging 6 | import re 7 | import sys 8 | 9 | import click 10 | 11 | from loglab.doc import html_from_labfile, object_from_labfile, text_from_labfile 12 | from loglab.model import handle_import 13 | from loglab.schema import log_schema_from_labfile, verify_labfile, verify_logfile 14 | 15 | # from loglab.util import download 16 | from loglab.version import VERSION 17 | 18 | 19 | @click.group(no_args_is_help=True) 20 | @click.option( 21 | "-v", 22 | "--verbose", 23 | count=True, 24 | help="디버깅 정보 출력 레벨 증가 (-v: INFO, -vv: DEBUG)", 25 | ) 26 | @click.pass_context 27 | def cli(ctx, verbose): 28 | """""" 29 | # 글로벌 컨텍스트 설정 30 | ctx.ensure_object(dict) 31 | ctx.obj["verbose"] = verbose 32 | 33 | # 로깅 레벨 설정 - 실제 로그 레벨에 따라 동적으로 표시 34 | if verbose >= 2: 35 | logging.basicConfig(level=logging.DEBUG, format="[%(levelname)s] %(message)s") 36 | elif verbose >= 1: 37 | logging.basicConfig(level=logging.INFO, format="[%(levelname)s] %(message)s") 38 | else: 39 | logging.basicConfig(level=logging.WARNING, format="[%(levelname)s] %(message)s") 40 | 41 | 42 | @cli.command() 43 | def version(): 44 | """로그랩의 현재 버전을 표시. 45 | 46 | 설치된 LogLab의 버전 정보를 출력함. 47 | """ 48 | print(VERSION) 49 | 50 | 51 | @cli.command() 52 | @click.argument("labfile", type=click.Path(exists=True)) 53 | @click.option("-c", "--custom-type", is_flag=True, help="커스텀 타입 그대로 출력") 54 | @click.option("-n", "--name", help="출력할 요소 이름 패턴") 55 | @click.option( 56 | "-k", "--keep-text", is_flag=True, default=False, help="긴 문자열 그대로 출력" 57 | ) 58 | @click.option("-l", "--lang", help="로그랩 메시지 언어") 59 | @click.pass_context 60 | def show(ctx, labfile, custom_type, name, keep_text, lang): 61 | """랩 파일의 내용을 텍스트 형태로 출력. 62 | 63 | lab 파일에 정의된 도메인, 타입, 베이스, 이벤트 등의 정보를 64 | 사람이 읽기 쉬운 테이블 형태로 출력함. 65 | 66 | Args: 67 | labfile: 출력할 lab 파일 경로 68 | custom_type: 커스텀 타입 정보를 포함할지 여부 69 | name: 출력할 요소를 필터링할 정규식 패턴 70 | keep_text: 긴 문자열을 줄바꿈 없이 그대로 출력할지 여부 71 | lang: 출력 언어 코드 72 | """ 73 | logger = logging.getLogger(__name__) 74 | 75 | logger.info(f"lab 파일 처리 시작: {labfile}") 76 | logger.debug( 77 | f"옵션: custom_type={custom_type}, name={name}, keep_text={keep_text}, lang={lang}" 78 | ) 79 | 80 | data = verify_labfile(labfile) 81 | logger.info("lab 파일 검증 완료") 82 | 83 | try: 84 | handle_import(labfile, data) 85 | logger.info("가져오기 처리 완료") 86 | except FileNotFoundError as e: 87 | logger.error(f"가져올 파일 '{e}' 을 찾을 수 없습니다.") 88 | print(f"Error: 가져올 파일 '{e}' 을 찾을 수 없습니다.") 89 | sys.exit(1) 90 | 91 | if name is not None: 92 | name = re.compile(name) 93 | logger.debug(f"이름 필터 패턴 적용: {name.pattern}") 94 | 95 | logger.info("텍스트 출력 생성 시작") 96 | result = text_from_labfile(data, custom_type, name, keep_text, lang) 97 | logger.info("텍스트 출력 생성 완료") 98 | print(result) 99 | 100 | 101 | @cli.command() 102 | @click.argument("labfile", type=click.Path(exists=True)) 103 | @click.option("-c", "--custom-type", is_flag=True, help="커스텀 타입 그대로 출력") 104 | @click.option("-o", "--output", help="출력 파일명") 105 | @click.option("-l", "--lang", help="로그랩 메시지 언어") 106 | @click.pass_context 107 | def html(ctx, labfile, custom_type, output, lang): 108 | """랩 파일로부터 HTML 문서를 생성. 109 | 110 | lab 파일의 내용을 웹브라우저에서 보기 쉬운 HTML 형태로 변환하여 111 | 파일로 저장. 생성된 HTML은 대화형 문서로 활용 가능. 112 | 113 | Args: 114 | labfile: 변환할 lab 파일 경로 115 | custom_type: 커스텀 타입 정보를 포함할지 여부 116 | output: 저장할 HTML 파일명. 지정하지 않으면 도메인명.html 117 | lang: 출력 언어 코드 118 | """ 119 | # ctx.obj가 없거나 verbose 키가 없을 때 기본값 0 사용 (pytest 등) 120 | verbose = ctx.obj.get("verbose", 0) if ctx.obj else 0 121 | logger = logging.getLogger(__name__) 122 | 123 | logger.info(f"HTML 문서 생성 시작: {labfile}") 124 | logger.debug(f"옵션: custom_type={custom_type}, output={output}, lang={lang}") 125 | 126 | data = verify_labfile(labfile) 127 | logger.info("lab 파일 검증 완료") 128 | 129 | try: 130 | handle_import(labfile, data) 131 | logger.info("가져오기 처리 완료") 132 | except FileNotFoundError as e: 133 | logger.error(f"가져올 파일 '{e}' 을 찾을 수 없습니다.") 134 | print(f"Error: 가져올 파일 '{e}' 을 찾을 수 없습니다.") 135 | sys.exit(1) 136 | 137 | domain = data["domain"] 138 | kwargs = dict(domain=domain) 139 | logger.debug(f"도메인 정보: {domain['name']}") 140 | 141 | logger.info("HTML 문서 생성 시작") 142 | doc = html_from_labfile(data, kwargs, custom_type, lang) 143 | 144 | if output is None: 145 | output = f"{domain['name']}.html" 146 | logger.debug(f"기본 출력 파일명 사용: {output}") 147 | 148 | logger.info(f"HTML 문서를 '{output}'에 저장") 149 | with open(output, "wt", encoding="utf8") as f: 150 | f.write(doc) 151 | logger.info("HTML 문서 저장 완료") 152 | print(f"'{output}' 에 HTML 문서 저장.") 153 | 154 | 155 | @cli.command() 156 | @click.argument("labfile", type=click.Path(exists=True)) 157 | @click.pass_context 158 | def schema(ctx, labfile): 159 | """랩 파일로부터 로그 검증용 JSON 스키마를 생성. 160 | 161 | lab 파일에 정의된 이벤트들을 분석하여 실제 로그 파일의 유효성을 162 | 검증할 수 있는 JSON Schema를 동적으로 생성함. 163 | 164 | Args: 165 | labfile: 스키마를 생성할 lab 파일 경로 166 | """ 167 | # ctx.obj가 없거나 verbose 키가 없을 때 기본값 0 사용 (pytest 등) 168 | verbose = ctx.obj.get("verbose", 0) if ctx.obj else 0 169 | logger = logging.getLogger(__name__) 170 | 171 | logger.info(f"JSON 스키마 생성 시작: {labfile}") 172 | 173 | data = verify_labfile(labfile) 174 | logger.info("lab 파일 검증 완료") 175 | 176 | try: 177 | handle_import(labfile, data) 178 | logger.info("가져오기 처리 완료") 179 | except FileNotFoundError as e: 180 | logger.error(f"가져올 파일 '{e}' 을 찾을 수 없습니다.") 181 | print(f"Error: 가져올 파일 '{e}' 을 찾을 수 없습니다.") 182 | sys.exit(1) 183 | 184 | dname = data["domain"]["name"] 185 | scm_path = f"{dname}.schema.json" 186 | logger.debug(f"도메인 이름: {dname}, 스키마 파일: {scm_path}") 187 | 188 | logger.info(f"JSON 스키마를 '{scm_path}'에 저장") 189 | with open(scm_path, "wt", encoding="utf8") as f: 190 | try: 191 | logger.debug("로그 스키마 생성 시작") 192 | scm = log_schema_from_labfile(data) 193 | f.write(scm) 194 | logger.debug("JSON 스키마 검증 시작") 195 | json.loads(scm) 196 | logger.info("JSON 스키마 생성 및 검증 완료") 197 | except json.decoder.JSONDecodeError as e: 198 | logger.error(f"JSON 스키마 에러: {e}") 199 | print("Error: 생성된 JSON 스키마 에러. 로그랩 개발자에 문의 요망.") 200 | print(e) 201 | sys.exit(1) 202 | 203 | print(f"{scm_path} 에 로그 스키마 저장.") 204 | 205 | 206 | @cli.command() 207 | @click.argument("schema", type=click.Path()) 208 | @click.argument("logfile", type=click.Path(exists=True)) 209 | @click.pass_context 210 | def verify(ctx, schema, logfile): 211 | """실제 로그 파일이 스키마에 맞는지 검증. 212 | 213 | 생성된 JSON Schema를 사용하여 JSON Lines 형태의 로그 파일이 214 | 올바른 구조와 데이터 타입을 가지고 있는지 검증함. 215 | 216 | Args: 217 | schema: 검증에 사용할 JSON 스키마 파일 경로 218 | logfile: 검증할 로그 파일 경로 219 | """ 220 | # ctx.obj가 없거나 verbose 키가 없을 때 기본값 0 사용 (pytest 등) 221 | verbose = ctx.obj.get("verbose", 0) if ctx.obj else 0 222 | logger = logging.getLogger(__name__) 223 | 224 | logger.info(f"로그 파일 검증 시작: {logfile} vs {schema}") 225 | logger.debug(f"스키마 파일: {schema}") 226 | logger.debug(f"로그 파일: {logfile}") 227 | 228 | verify_logfile(schema, logfile) 229 | logger.info("로그 파일 검증 완료") 230 | 231 | 232 | @cli.command() 233 | @click.argument("labfile", type=click.Path(exists=True)) 234 | @click.argument("code_type") 235 | @click.option("-o", "--output", help="출력 파일명") 236 | @click.option("-l", "--lang", help="로그랩 메시지 언어") 237 | @click.option("--utc", is_flag=True, help="이벤트 시간을 UTC로 출력") 238 | @click.pass_context 239 | def object(ctx, labfile, code_type, output, lang, utc): 240 | """랩 파일로부터 로그 작성용 코드 객체를 생성. 241 | 242 | lab 파일에 정의된 이벤트들을 지정된 프로그래밍 언어의 243 | 클래스/구조체 코드로 변환. 생성된 코드는 로그 데이터를 244 | JSON Lines 형태로 직렬화하는 기능을 제공. 245 | 246 | Args: 247 | labfile: 코드를 생성할 lab 파일 경로 248 | code_type: 대상 언어 ('cs', 'py', 'cpp', 'ts', 'java' 중 하나) 249 | output: 저장할 코드 파일명. 지정하지 않으면 표준 출력 250 | lang: 코드 내 메시지 언어 코드 251 | utc: 이벤트 시간을 UTC로 출력할지 여부 252 | """ 253 | # ctx.obj가 없거나 verbose 키가 없을 때 기본값 0 사용 (pytest 등) 254 | verbose = ctx.obj.get("verbose", 0) if ctx.obj else 0 255 | logger = logging.getLogger(__name__) 256 | 257 | logger.info(f"코드 객체 생성 시작: {labfile} -> {code_type}") 258 | logger.debug( 259 | f"옵션: code_type={code_type}, output={output}, lang={lang}, utc={utc}" 260 | ) 261 | 262 | data = verify_labfile(labfile) 263 | logger.info("lab 파일 검증 완료") 264 | 265 | try: 266 | handle_import(labfile, data) 267 | logger.info("가져오기 처리 완료") 268 | except FileNotFoundError as e: 269 | logger.error(f"가져올 파일 '{e}' 을 찾을 수 없습니다.") 270 | print(f"Error: 가져올 파일 '{e}' 을 찾을 수 없습니다.") 271 | sys.exit(1) 272 | 273 | code_type = code_type.lower() 274 | logger.debug(f"코드 타입 정규화: {code_type}") 275 | 276 | if code_type not in ("cs", "py", "cpp", "ts", "java"): 277 | logger.error(f"지원하지 않는 코드 타입: {code_type}") 278 | print(f"Error: 지원하지 않는 코드 타입 (.{code_type}) 입니다.") 279 | sys.exit(1) 280 | 281 | logger.info(f"{code_type} 코드 생성 시작") 282 | src = object_from_labfile(data, code_type, lang, utc) 283 | logger.info("코드 생성 완료") 284 | 285 | if output is None: 286 | logger.debug("표준 출력으로 결과 출력") 287 | print(src) 288 | else: 289 | logger.info(f"코드를 '{output}'에 저장") 290 | with codecs.open(output, "w", "utf-8") as f: 291 | f.write(src) 292 | logger.info("코드 파일 저장 완료") 293 | 294 | 295 | if __name__ == "__main__": 296 | """CLI 스크립트로 직접 실행될 때의 진입점.""" 297 | cli(prog_name="loglab", obj={}) 298 | -------------------------------------------------------------------------------- /docs/etc.rst: -------------------------------------------------------------------------------- 1 | 기타 기능과 팁 2 | ============== 3 | 4 | 여기서는 로그랩을 활용하는데 도움이 되는 기타 기능들을 소개하겠다. 5 | 6 | 복잡한 랩 파일 필터링하기 7 | ------------------------- 8 | 9 | 다양한 이벤트를 정의하고, 외부 랩 파일까지 쓰다보면 한 눈에 구조를 10 | 파악하기가 점점 힘들어진다. 이럴 때는 ``show`` 에 필터를 걸어서 보면 11 | 편리하다. ``show`` 명령의 ``-n`` 또는 ``--name`` 옵션을 이용해 찾는 12 | 타입/베이스/이벤트 이름의 패턴을 줄 수 있다. 13 | 14 | 예를 들어 캐릭터 관련 이벤트만 보고 싶다면, 15 | 16 | :: 17 | 18 | $ loglab show foo.lab.json -n Char 19 | 20 | Domain : foo 21 | Description : 위대한 모바일 게임 22 | 23 | Event : CharLogin 24 | Description : 캐릭터 로그인 25 | +----------+----------+---------------+-----------------+ 26 | | Field | Type | Description | Restrict | 27 | |----------+----------+---------------+-----------------| 28 | | DateTime | datetime | 이벤트 일시 | | 29 | | ServerNo | integer | 서버 번호 | 1 이상 100 미만 | 30 | | AcntId | integer | 계정 ID | 0 이상 | 31 | | CharId | integer | 캐릭터 ID | 0 이상 | 32 | +----------+----------+---------------+-----------------+ 33 | 34 | Event : CharLogout 35 | Description : 캐릭터 로그아웃 36 | +----------+----------+---------------+-----------------+ 37 | | Field | Type | Description | Restrict | 38 | |----------+----------+---------------+-----------------| 39 | | DateTime | datetime | 이벤트 일시 | | 40 | | ServerNo | integer | 서버 번호 | 1 이상 100 미만 | 41 | | AcntId | integer | 계정 ID | 0 이상 | 42 | | CharId | integer | 캐릭터 ID | 0 이상 | 43 | +----------+----------+---------------+-----------------+ 44 | 45 | 다음과 같이 하면 이름에 ``types`` 가 들어가는 요소들, 즉 타입들만 볼 수 46 | 있다. 47 | 48 | :: 49 | 50 | $ loglab show foo.lab.json -c -n types 51 | 52 | Domain : foo 53 | Description : 위대한 모바일 게임 54 | 55 | Type : types.unsigned 56 | Description : Id 타입 57 | +------------+---------------+------------+ 58 | | BaseType | Description | Restrict | 59 | |------------+---------------+------------| 60 | | integer | Id 타입 | 0 이상 | 61 | +------------+---------------+------------+ 62 | 63 | 필드별 타입 지정 64 | ~~~~~~~~~~~~~~~~ 65 | 66 | 로그랩의 ``object`` 명령으로 생성된 로그 객체 멤버 변수의 타입은, 67 | 랩파일에서 지정된 필드의 타입을 고려하여 대상 프로그래밍 랭귀지의 68 | 일반적인 타입으로 생성된다. 예를 들어 랩 파일에서 ``integer`` 로 지정된 69 | 필드는, C# 로그 객체 생성시 ``int`` 를 이용한다. 70 | 71 | 그러나 필드별로 특정 타입을 지정해 사용해야 하는 경우가 있다. 예로 72 | 지금까지 예제에서 계정 ID 를 뜻하던 ``AcntId`` 필드에 C# 의 정수형 73 | ``int`` 의 범위를 넘어서는 큰 값을 지정해야 한다면 곤란하게 된다. 이런 74 | 경우를 위해 랩 파일의 필드에 로그 객체 생성시 사용할 타입을 프로그래밍 75 | 언어별로 지정할 수 있다. 아래 예를 살펴보자. 76 | 77 | .. code:: js 78 | 79 | { 80 | // ... 81 | "types": { 82 | 83 | // ... 84 | 85 | "ulong": { 86 | "type": "integer", 87 | "desc": "0 이상 정수 (C# 로그 객체에서 ulong)", 88 | "minimum": 0, 89 | "objtype": { 90 | "cs": "ulong" 91 | } 92 | } 93 | } 94 | 95 | // ... 96 | 97 | } 98 | 99 | ``types`` 에 ``ulong`` 이라는 커스텀 타입을 정의하고, 이것의 ``objtype`` 100 | 요소에 C# ``cs`` 를 위한 타입을 지정하는 식이다. 이렇게 하면 이 커스텀 101 | 타입 ``types.ulong`` 을 이용하는 필드의 C# 로그 객체 생성시, 기본 타입인 102 | ``int`` 가 아닌 ``ulong`` 을 이용하게 된다. 103 | 104 | 현지화 (Localization) 105 | --------------------- 106 | 107 | 서비스가 잘 완성되어 해외 진출을 준비하는 경우, 현지 언어로 된 로그 108 | 문서가 필요할 수 있다. 현재 로그랩에서는 해당 언어를 위한 별도의 랩 109 | 파일을 만들고 현지 언어로 설명을 번역하는 식으로 작업이 가능하다. 110 | 111 | 문제가 되는 것은 로그랩에서 설명을 위해 자동으로 추가되는 메시지들 112 | (``이벤트 시간``, ``~이상``, ``~미만`` 등) 이 한국어로 나오는 것이다. 113 | 이런 경우를 위해 로그랩은 메시지 언어를 ``언어_지역`` 형식의 로케일로 114 | 선택하는 기능을 제공한다. 115 | 116 | .. note:: 117 | 118 | 언어 코드는 `ISO 119 | 639-1 `__, 120 | 지역 코드는 `ISO 3166-1 `__ 121 | 을 따른다. 122 | 123 | 현재는 영어 ``en_US``, 중국어 ``zh_CN``, 일본어 ``ja_JP`` 가 준비되어 124 | 있다. 아래와 같이 ``show`` 명령에서 ``-l`` 또는 ``--lang`` 옵션을 통해 125 | 메시지 언어를 선택해보자. 126 | 127 | :: 128 | 129 | $ loglab show foo.lab.json -l en_US 130 | 131 | # ... 132 | 133 | Event : GetItem 134 | Description : 캐릭터의 아이템 습득 135 | +----------+----------+------------------+----------------------------+ 136 | | Field | Type | Description | Restrict | 137 | |----------+----------+------------------+----------------------------| 138 | | DateTime | datetime | Event date time | | 139 | | ServerNo | integer | 서버 번호 | 1 or above below 100 | 140 | | AcntId | integer | 계정 ID | | 141 | | Category | integer | 이벤트 분류 | always 2 (캐릭터 이벤트) | 142 | | CharId | integer | 캐릭터 ID | | 143 | | MapCd | integer | 맵 코드 | | 144 | | PosX | number | 맵상 X 위치 | | 145 | | PosY | number | 맵상 Y 위치 | | 146 | | PosZ | number | 맵상 Z 위치 | | 147 | | ItemCd | integer | 아이템 타입 코드 | one of 1 (칼), 2 (방패), 3 | 148 | | | | | (물약) | 149 | | ItemId | integer | 아이템 개체 ID | | 150 | +----------+----------+------------------+----------------------------+ 151 | 152 | :: 153 | 154 | $ loglab show foo.lab.json -l zh_CN 155 | 156 | # ... 157 | 158 | Event : GetItem 159 | Description : 캐릭터의 아이템 습득 160 | +----------+----------+------------------+----------------------------+ 161 | | Field | Type | Description | Restrict | 162 | |----------+----------+------------------+----------------------------| 163 | | DateTime | datetime | 事件日期 | | 164 | | ServerNo | integer | 서버 번호 | 1 以上(含) 100 以下 | 165 | | AcntId | integer | 계정 ID | | 166 | | Category | integer | 이벤트 분류 | 始终 2 (캐릭터 이벤트) | 167 | | CharId | integer | 캐릭터 ID | | 168 | | MapCd | integer | 맵 코드 | | 169 | | PosX | number | 맵상 X 위치 | | 170 | | PosY | number | 맵상 Y 위치 | | 171 | | PosZ | number | 맵상 Z 위치 | | 172 | | ItemCd | integer | 아이템 타입 코드 | 1 (칼), 2 (방패), 3 (물약) | 173 | | | | | 之一 | 174 | | ItemId | integer | 아이템 개체 ID | | 175 | +----------+----------+------------------+----------------------------+ 176 | 177 | :: 178 | 179 | $ loglab show foo.lab.json -l za_JP 180 | 181 | # ... 182 | 183 | Event : GetItem 184 | Description : 캐릭터의 아이템 습득 185 | +----------+----------+------------------+----------------------------+ 186 | | Field | Type | Description | Restrict | 187 | |----------+----------+------------------+----------------------------| 188 | | DateTime | datetime | イベント日時 | | 189 | | ServerNo | integer | 서버 번호 | 1 以上 100 未満 | 190 | | AcntId | integer | 계정 ID | | 191 | | Category | integer | 이벤트 분류 | 常に 2 (캐릭터 이벤트) | 192 | | CharId | integer | 캐릭터 ID | | 193 | | MapCd | integer | 맵 코드 | | 194 | | PosX | number | 맵상 X 위치 | | 195 | | PosY | number | 맵상 Y 위치 | | 196 | | PosZ | number | 맵상 Z 위치 | | 197 | | ItemCd | integer | 아이템 타입 코드 | 1 (칼), 2 (방패), 3 (물약) | 198 | | | | | のいずれか | 199 | | ItemId | integer | 아이템 개체 ID | | 200 | +----------+----------+------------------+----------------------------+ 201 | 202 | 지금까지 작성한 랩 파일을 사용해서 이벤트와 필드 설명이 한국어로 203 | 나오지만, 로그랩에서 자동으로 추가한 설명은 지정한 언어로 나오는 것을 알 204 | 수 있다. 앞에서 설명한 ``html`` 명령도 같은 식으로 동작한다. 205 | 206 | 로그랩 활용 방안 207 | ---------------- 208 | 209 | 지금까지 언급되지 않은 로그랩을 활용 방법을 생각해보자. 210 | 211 | 로그 구현, 수집, 모니터링 212 | ~~~~~~~~~~~~~~~~~~~~~~~~~~ 213 | 214 | 로그랩을 통해 로그 구조의 설계가 끝났으면, 실제 서비스의 서버 등에서 215 | 로그 코드를 작성해야 하겠다. 사용하는 프로그래밍 언어별로 적절한 로깅 216 | 라이브러리를 선택하여 설계에 맞는 JSON 형식으로 남기도록 하자. 남은 217 | 로그는 `fluentd `__ 나 218 | `Filebeat `__ 같은 로그 219 | 수집기를 통해 중앙 저장소에 모으고, 적절한 ETL 과정을 거치면 분석 가능한 220 | 형태의 데이터로 거듭날 것이다. 이 과정에서 로그의 실시간 모니터링이 221 | 필요하면 `Elasticsearch `__ 222 | 같은 툴을 함께 이용할 수 있을 것이다. 223 | 224 | 로그 변경 이력의 체계화 225 | ~~~~~~~~~~~~~~~~~~~~~~~~~~ 226 | 227 | 특정 서비스를 장기간 운용하다보면 버전별 로그 변경 내용을 문서화하고 228 | 공유하는 것도 큰 일이다. 로그랩을 통해 설계/운용되는 로그는 텍스트 229 | 형식인 랩 파일 안에 로그 구조의 모든 것이 표현되기에, 텍스트 파일의 230 | 차이를 비교하는 ``diff`` 등의 툴로 랩 파일을 비교하면 버전별 로그 구조의 231 | 차이를 간단히 표현할 수 있다. 로그 변경 이력을 수작업으로 기록할 필요가 232 | 사라지는 것이다. 233 | 234 | 추가적으로, 랩 파일의 ``domain`` 요소 아래 ``version`` 을 선택적으로 235 | 기술할 수 있는데, 이것을 이용하면 ``html`` 명령으로 생성하는 HTML 파일의 236 | 타이틀에 버전 정보가 추가되기에 문서 구분에 도움이 될 수 있다. 237 | 238 | 디버그 로그는 어디에? 239 | --------------------- 240 | 241 | 게임 업계에서는 분석의 대상이 되는 주요 이벤트의 로그를 **운영 242 | (Operation) 로그** 라 하고, 개발자가 디버깅을 위해 남기는 로그를 243 | **디버그 (Debug) 로그** 로 구분하여 부르는 경우가 많다. 디버그 로그에는 244 | 많은 필드가 필요하지 않으며, 개발자가 자유롭게 기술할 수 있는 문자열 245 | 필드 하나가 중심이 된다. 246 | 247 | 로그의 용량 및 용도 측면에서는 운영 로그와 디버그 로그를 별도의 파일에 248 | 남기는 것이 맞다고 볼 수 있지만, 두 종류의 로그가 하나의 파일에 있으면 249 | 디버깅에는 더 유리할 수 있어 같은 파일에 남기는 것을 선호할 수도 있겠다. 250 | 251 | 이 선택은 서비스 특성에 맞게 결정하면 되겠다. 252 | 253 | .. note:: 254 | 255 | 만약 운영 로그와 디버그 로그를 하나의 파일에 기록하려면, 랩 파일에 256 | 디버그 로그용 이벤트를 하나 추가해 사용하면 되겠다. 아래에 소개하는 257 | MMORPG 예제의 ``Debug`` 이벤트를 참고하자. 258 | 259 | MMORPG 위한 예제 260 | ---------------- 261 | 262 | 로그랩을 큰 프로젝트에 사용할 때 참고할 만한 예제가 있으면 도움이 될 263 | 것이다. 아래는 MMORPG 게임의 주요 이벤트들을 로그랩으로 기술한 것이다 264 | (로그랩 코드의 ``example`` 디렉토리에서 확인할 수 있다). 265 | 266 | 랩 파일 : 267 | https://raw.githubusercontent.com/haje01/loglab/master/example/rpg.lab.json 268 | 269 | HTML 보기 : 270 | http://htmlpreview.github.io/?https://raw.githubusercontent.com/haje01/loglab/master/example/rpg.html 271 | 272 | 몇 가지 구성 측면의 특징을 설명하면, 273 | 274 | **증가/감소는 하나의 이벤트로** 275 | 276 | 기본 필드는 변하지 않고 수량만 증가 또는 감소하는 이벤트들이 있다. 예를 277 | 들어 아이템의 경우 증가하거나 감소할 수 있는데 이것을 각각 별도 이벤트로 278 | 만들지 않고, 아이템 변화 ``ItemChg`` 이벤트 하나를 만들고 변화량 279 | ``Change`` 에 +/- 값을 주는 식으로 구현하였다. 280 | 281 | **ID 와 코드의 구분** 282 | 283 | 앞에서도 언급했지만, 개별 개체를 구분할 때는 아이디 ``Id`` 를, 미리 284 | 정의된 특정 범주값을 나타낼 때는 코드 ``Cd`` 를 필드의 접미어로 285 | 사용했다. 예로 특정 아이템에 대해 ``ItemId`` 는 그 아이템 개체를 286 | 식별하기 위한 값이고, ``ItemCd`` 는 그 아이템이 어떤 종류인지 분류하기 287 | 위한 값이다. ``Id`` 는 임의값으로 유니크하면 되고, ``Cd`` 는 미리 정의된 288 | 값으로 문서화된 설명이 있어야 한다. 289 | 290 | **맵 코드와 좌표** 291 | 292 | 게임내 특정 지역에서 발생하는 이벤트를 위해 맵 코드와 위치 좌표 필드를 293 | 포함하였다. 예제에서는 계정이나 시스템 등 맵상에서가 아닌 이벤트들도 294 | 함께 다루기 위해 옵션으로 설정하였으나, 가능한 경우 꼭 기록하는 것이 295 | 분석에 도움이 된다. 296 | 297 | **링크 ID 이용** 298 | 299 | 하나의 사건에서 여러 로그 이벤트가 발생하는 경우가 있다. 예를 들어 300 | 거래소에서 아이템을 구매하는 경우 아이템은 들어오고 돈은 빠져나가야 301 | 한다. 로그 측면에서는 아이템 증가 로그와 돈 감소 로그가 함께 남아야 하는 302 | 것이다. 303 | 304 | 이런 경우 분석을 위해서는 그 사건의 연관 로그들을 찾아볼 수 있어야 305 | 하는데, 필자는 **링크 ID** 방식을 추천한다. 링크 ID 는 사건 발생 306 | 시점에서 랜덤 정수 하나를 만들고 (트랜잭션 ID 등도 가능하겠다), 그것을 307 | 연관된 로그들의 같은 필드 (예제에서는 ``LinkRd``) 에 기입하는 방식이다. 308 | 랜덤한 정수는 웬만해서는 일치하기 힘들기에, 비슷한 시간대에 발생한 연관 309 | 로그들을 찾기에는 충분한 식별력을 가진다. 310 | 311 | .. note:: 312 | 313 | 일부 서비스들은 이런 경우 다양한 관련 이벤트 정보를 하나의 필드에 314 | 뭉쳐서 넣는 방식을 취하는데, 파싱이 힘들고 확장이 어려워 좋은 방법은 315 | 아닌 것 같다. 316 | 317 | **캐릭터 싱크 로그** 318 | 319 | 싱크 로그는 서버에서 정기적 (예: 5 분) 으로 캐릭터의 정보를 로그로 320 | 출력하는 것이다 (일종의 스냅샷). 예제에서는 캐릭터 상태 ``CharSync`` 와 321 | 캐릭터 머니 상태 ``CharMoneySync`` 이벤트로 구성하였다. 머니는 종류에 322 | 따라 다양할 수 있기에 별도 이벤트로 분리하였고 ``LinkRd`` 로 연결해서 323 | 보도록 하였다. 싱크 로그는 게임내 이상현상이나 어뷰징 탐지에 활용될 수 324 | 있다. 325 | 326 | 이 예제의 방식이 절대적인 것은 아니며, 어디까지나 로그랩의 활용에 참고가 327 | 되었으면 한다. 328 | --------------------------------------------------------------------------------