diff --git a/app/__pycache__/main.cpython-310.pyc b/app/__pycache__/main.cpython-310.pyc index dd22d26..de23c19 100644 Binary files a/app/__pycache__/main.cpython-310.pyc and b/app/__pycache__/main.cpython-310.pyc differ diff --git a/app/main.py b/app/main.py index 3eaaede..54ef9e1 100644 --- a/app/main.py +++ b/app/main.py @@ -58,8 +58,9 @@ async def validate_user(user_id): if not allowed: raise HTTPException(status_code=403, detail="권한이 없습니다.") -# ==== 셀러리 태스크 ==== +# ==== 셀러리 태스크 (워커 시스템으로 전송) ==== def start_celery_task(task_name, **kwargs): + """워커 시스템으로 작업 전송""" return celery_app.send_task(task_name, kwargs=kwargs) # ==== 엔드포인트 ==== @@ -67,23 +68,43 @@ def start_celery_task(task_name, **kwargs): async def translate_me(req: TranslateRequest): await validate_user(req.user_id) filename = f"{uuid.uuid4().hex}_{int(time.time())}.png" - # (여기서 파일 경로 생성 및 저장) - task = start_celery_task("app.tasks.translate_task", **req.dict(), filename=filename) - logger.info(f"태스크 등록: {task.id}, 파일명: {filename}") + + # 워커 시스템의 translate_task로 전송 + task = start_celery_task("worker.translate_task", **req.dict(), filename=filename) + logger.info(f"번역 태스크 등록: {task.id}, 파일명: {filename}") return {"task_id": task.id, "filename": filename} @app.post("/inpaint_me") async def inpaint_me(req: InpaintRequest): await validate_user(req.user_id) - task = start_celery_task("app.tasks.inpaint_task", **req.dict()) + + # 워커 시스템의 inpaint_task로 전송 + task = start_celery_task("worker.inpaint_task", **req.dict()) + logger.info(f"인페인팅 태스크 등록: {task.id}") return {"task_id": task.id} @app.post("/ocr_me") async def ocr_me(req: OCRRequest): await validate_user(req.user_id) - task = start_celery_task("app.tasks.ocr_task", **req.dict()) + + # 워커 시스템의 ocr_task로 전송 + task = start_celery_task("worker.ocr_task", **req.dict()) + logger.info(f"OCR 태스크 등록: {task.id}") return {"task_id": task.id} +@app.get("/task_status/{task_id}") +async def get_task_status(task_id: str): + """작업 상태 확인""" + try: + result = AsyncResult(task_id, app=celery_app) + return { + "task_id": task_id, + "status": result.status, + "result": result.result if result.ready() else None + } + except Exception as e: + raise HTTPException(status_code=404, detail=f"작업을 찾을 수 없습니다: {str(e)}") + @app.post("/upload_image") async def upload_image(user_id: str = Form(...), file: UploadFile = File(...)): original_dir = f"images/{user_id}/original/" @@ -103,4 +124,13 @@ def download_image(user_id: str, filename: str): with open(file_path, "rb") as f: data = f.read() os.remove(file_path) # 다운로드 직후 삭제 - return Response(content=data, media_type="image/png") \ No newline at end of file + return Response(content=data, media_type="image/png") + +@app.get("/") +async def root(): + return {"message": "이미지 번역 메인 서버", "status": "running"} + +@app.get("/health") +async def health_check(): + """헬스 체크""" + return {"status": "healthy", "timestamp": time.time()} \ No newline at end of file diff --git a/app/tasks.py b/app/tasks.py deleted file mode 100644 index 1fecde0..0000000 --- a/app/tasks.py +++ /dev/null @@ -1,32 +0,0 @@ -from app.celery_worker import celery_app - -@celery_app.task(name="app.tasks.translate_task") -def translate_task(**kwargs): - # 실제 번역 처리 로직 - return { - "ocr_texts": ["중국어1", "중국어2"], - "ocr_boxes": [ - {"text": "중국어1", "box": [10, 20, 100, 120]}, - {"text": "중국어2", "box": [110, 120, 200, 220]} - ], - "translated_texts": ["한글1", "한글2"], - "inpainted_image": "base64string...." - } - -@celery_app.task(name="app.tasks.inpaint_task") -def inpaint_task(**kwargs): - # 실제 인페인팅 처리 로직 - return { - "inpainted_image": "base64string...." - } - -@celery_app.task(name="app.tasks.ocr_task") -def ocr_task(**kwargs): - # 실제 OCR 처리 로직 - return { - "ocr_texts": ["중국어1", "중국어2"], - "ocr_boxes": [ - {"text": "중국어1", "box": [10, 20, 100, 120]}, - {"text": "중국어2", "box": [110, 120, 200, 220]} - ] - } \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml index b91a396..987b91e 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -11,30 +11,13 @@ services: - "7890:7890" depends_on: - redis - celery: - build: . - container_name: celery_worker - command: celery -A app.celery_worker worker --loglevel=info --concurrency=2 - volumes: - - ./app:/app/app - depends_on: - - redis - - celery-beat: - build: . - container_name: celery_beat - command: celery -A app.celery_worker beat --loglevel=info - volumes: - - ./app:/app/app - - ./images:/app/images - depends_on: - - redis redis: image: redis:6.2 container_name: redis_server ports: - "6379:6379" + flower: build: . container_name: flower_monitor diff --git a/worker.py b/worker.py new file mode 100644 index 0000000..2eb0a80 --- /dev/null +++ b/worker.py @@ -0,0 +1,176 @@ +#!/usr/bin/env python3 +""" +독립적인 Celery 워커 시스템 +메인서버의 Redis와 연결하여 작업을 처리합니다. +""" + +import os +import sys +import argparse +from celery import Celery +from modules.image_processor2 import ImageProcessor +from modules.loggerModule import Logger1 +from modules.gpt_client import GPTClient + +def get_base_dir(): + """ + 실행 환경에 따라 base_dir을 설정하는 메서드. + """ + if getattr(sys, 'frozen', False): + base_dir = os.path.dirname(sys.executable) + internal_dir = os.path.join(base_dir, 'lib') + if os.path.exists(internal_dir): + return internal_dir + else: + base_dir = os.path.dirname(os.path.abspath(__file__)) + return base_dir + +# 환경 변수에서 Redis URL 가져오기 (기본값은 로컬 메인서버) +REDIS_URL = os.getenv("REDIS_URL", "redis://localhost:6379/0") +MAIN_SERVER_HOST = os.getenv("MAIN_SERVER_HOST", "localhost") + +# Celery 앱 생성 +celery_app = Celery( + "image_worker", + broker=REDIS_URL, + backend=REDIS_URL +) + +# Celery 설정 +celery_app.conf.update( + task_serializer='json', + accept_content=['json'], + result_serializer='json', + timezone='Asia/Seoul', + enable_utc=True, + task_routes={ + 'worker.translate_task': {'queue': 'translate'}, + 'worker.inpaint_task': {'queue': 'inpaint'}, + 'worker.ocr_task': {'queue': 'ocr'}, + } +) + +# 글로벌 ImageProcessor 인스턴스 +image_processor = None + +def initialize_processor(): + """ImageProcessor 초기화""" + global image_processor + if image_processor is None: + logger = Logger1() + gpt_client = GPTClient() + base_dir = get_base_dir() + font_path = os.path.join(base_dir, "modules", "fonts", "HakgyoansimDunggeunmisoTTFB.ttf") + + print(f"워커 초기화 중...") + print(f"Base directory: {base_dir}") + print(f"Font path: {font_path}") + print(f"Redis URL: {REDIS_URL}") + + image_processor = ImageProcessor(logger, gpt_client, base_dir, font_path) + print("ImageProcessor 초기화 완료") + + return image_processor + +@celery_app.task(name="worker.translate_task", bind=True) +def translate_task(self, **kwargs): + """번역 작업 처리""" + try: + processor = initialize_processor() + + # 실제 번역 로직 호출 + # TODO: ImageProcessor의 번역 메서드와 연결 + result = { + "task_id": self.request.id, + "status": "completed", + "ocr_texts": ["번역된 텍스트1", "번역된 텍스트2"], + "ocr_boxes": [ + {"text": "번역된 텍스트1", "box": [10, 20, 100, 120]}, + {"text": "번역된 텍스트2", "box": [110, 120, 200, 220]} + ], + "translated_texts": ["한글 번역1", "한글 번역2"], + "inpainted_image": "base64_encoded_image_data" + } + + print(f"번역 작업 완료: {self.request.id}") + return result + + except Exception as e: + print(f"번역 작업 오류: {str(e)}") + raise self.retry(countdown=60, max_retries=3) + +@celery_app.task(name="worker.inpaint_task", bind=True) +def inpaint_task(self, **kwargs): + """인페인팅 작업 처리""" + try: + processor = initialize_processor() + + # 실제 인페인팅 로직 호출 + # TODO: ImageProcessor의 인페인팅 메서드와 연결 + result = { + "task_id": self.request.id, + "status": "completed", + "inpainted_image": "base64_encoded_inpainted_image" + } + + print(f"인페인팅 작업 완료: {self.request.id}") + return result + + except Exception as e: + print(f"인페인팅 작업 오류: {str(e)}") + raise self.retry(countdown=60, max_retries=3) + +@celery_app.task(name="worker.ocr_task", bind=True) +def ocr_task(self, **kwargs): + """OCR 작업 처리""" + try: + processor = initialize_processor() + + # 실제 OCR 로직 호출 + # TODO: ImageProcessor의 OCR 메서드와 연결 + result = { + "task_id": self.request.id, + "status": "completed", + "ocr_texts": ["감지된 텍스트1", "감지된 텍스트2"], + "ocr_boxes": [ + {"text": "감지된 텍스트1", "box": [10, 20, 100, 120]}, + {"text": "감지된 텍스트2", "box": [110, 120, 200, 220]} + ] + } + + print(f"OCR 작업 완료: {self.request.id}") + return result + + except Exception as e: + print(f"OCR 작업 오류: {str(e)}") + raise self.retry(countdown=60, max_retries=3) + +if __name__ == "__main__": + parser = argparse.ArgumentParser(description="이미지 처리 Celery 워커") + parser.add_argument('--concurrency', type=int, default=2, help='동시 처리 작업 수') + parser.add_argument('--redis-url', type=str, help='Redis 서버 URL') + parser.add_argument('--main-server', type=str, help='메인 서버 주소') + + args = parser.parse_args() + + # 명령행 인수로 Redis URL 오버라이드 + if args.redis_url: + os.environ["REDIS_URL"] = args.redis_url + REDIS_URL = args.redis_url + + if args.main_server: + os.environ["MAIN_SERVER_HOST"] = args.main_server + MAIN_SERVER_HOST = args.main_server + + print(f"워커 시작 중...") + print(f"Redis URL: {REDIS_URL}") + print(f"메인 서버: {MAIN_SERVER_HOST}") + print(f"동시 처리 수: {args.concurrency}") + + # 워커 실행 + celery_app.worker_main([ + 'worker', + '--loglevel=info', + f'--concurrency={args.concurrency}', + '--queues=translate,inpaint,ocr' + ]) \ No newline at end of file diff --git a/~/work/worker-system/Dockerfile b/~/work/worker-system/Dockerfile new file mode 100644 index 0000000..f37af65 --- /dev/null +++ b/~/work/worker-system/Dockerfile @@ -0,0 +1,28 @@ +FROM python:3.8-slim + +# 시스템 패키지 업데이트 및 필요한 패키지 설치 +RUN apt-get update && apt-get install -y \ + libglib2.0-0 \ + libsm6 \ + libxext6 \ + libxrender-dev \ + libgomp1 \ + libgl1-mesa-glx \ + libglib2.0-0 \ + wget \ + && rm -rf /var/lib/apt/lists/* + +WORKDIR /worker + +# requirements.txt 먼저 복사하여 도커 캐시 활용 +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +# 전체 소스 복사 +COPY . . + +# 워커 실행 스크립트에 실행 권한 부여 +RUN chmod +x worker.py + +# 기본 명령어 +CMD ["python", "worker.py", "--concurrency", "2"] \ No newline at end of file diff --git a/~/work/worker-system/README.md b/~/work/worker-system/README.md new file mode 100644 index 0000000..697c9af --- /dev/null +++ b/~/work/worker-system/README.md @@ -0,0 +1,130 @@ +# 이미지 처리 워커 시스템 + +메인서버와 분리된 독립적인 이미지 처리 워커 시스템입니다. + +## 🏗️ 아키텍처 + +``` +클라이언트 → 메인서버 (FastAPI) → Redis → 워커 시스템 (ImageProcessor) +``` + +- **메인서버**: 클라이언트 요청 중계, 인증, API 제공 +- **워커**: 실제 OCR, 번역, 인페인팅 작업 처리 +- **Redis**: 작업 큐 및 결과 저장소 + +## 🚀 실행 방법 + +### 1. Docker 모드 (권장) +```bash +cd ~/work/worker-system +WORKER_MODE=docker ./start_worker.sh +``` + +### 2. 로컬 모드 +```bash +cd ~/work/worker-system +WORKER_MODE=local ./start_worker.sh +``` + +### 3. 원격 모드 +```bash +cd ~/work/worker-system +REDIS_URL="redis://메인서버IP:6379/0" WORKER_MODE=remote ./start_worker.sh +``` + +## ⚙️ 환경 변수 + +| 변수 | 설명 | 기본값 | +|------|------|--------| +| `REDIS_URL` | Redis 서버 주소 | `redis://localhost:6379/0` | +| `MAIN_SERVER_HOST` | 메인 서버 주소 | `localhost` | +| `CONCURRENCY` | 동시 처리 작업 수 | `2` | +| `WORKER_MODE` | 실행 모드 | `docker` | + +## 📋 사용 예시 + +### 로컬에서 실행 +```bash +# 기본 설정으로 실행 +./start_worker.sh + +# 동시 처리 수 조정 +CONCURRENCY=4 ./start_worker.sh + +# 원격 메인서버에 연결 +REDIS_URL="redis://192.168.1.100:6379/0" MAIN_SERVER_HOST="192.168.1.100" ./start_worker.sh +``` + +### 원격 서버에서 실행 +```bash +# 원격 서버에 워커 시스템 배포 +scp -r ~/work/worker-system user@remote-server:~/ + +# 원격 서버에서 실행 +ssh user@remote-server +cd ~/worker-system +REDIS_URL="redis://메인서버IP:6379/0" WORKER_MODE=docker ./start_worker.sh +``` + +## 🔧 개발 모드 + +### 직접 실행 +```bash +python worker.py --concurrency 2 --redis-url redis://localhost:6379/0 +``` + +### 특정 큐만 처리 +```bash +python worker.py --concurrency 1 --redis-url redis://localhost:6379/0 +``` + +## 📊 모니터링 + +Flower를 통해 워커 상태를 모니터링할 수 있습니다: +- 메인서버의 Flower: http://메인서버:5555 + +## 🔄 자동 재시작 + +systemd를 사용한 자동 재시작 설정: + +```bash +# /etc/systemd/system/image-worker.service +[Unit] +Description=Image Processing Worker +After=docker.service + +[Service] +Type=forking +User=ckh08045 +WorkingDirectory=/home/ckh08045/work/worker-system +Environment=WORKER_MODE=docker +Environment=REDIS_URL=redis://localhost:6379/0 +ExecStart=/home/ckh08045/work/worker-system/start_worker.sh +Restart=always + +[Install] +WantedBy=multi-user.target +``` + +```bash +sudo systemctl enable image-worker +sudo systemctl start image-worker +``` + +## 🐛 문제 해결 + +### Redis 연결 오류 +- 메인서버의 Redis가 실행 중인지 확인 +- 포트 6379가 열려있는지 확인 +- 방화벽 설정 확인 + +### Docker 권한 오류 +```bash +sudo usermod -aG docker $USER +newgrp docker +``` + +### 의존성 오류 +```bash +pip install -r requirements.txt +``` \ No newline at end of file diff --git a/~/work/worker-system/app b/~/work/worker-system/app new file mode 100644 index 0000000..e69de29 diff --git a/~/work/worker-system/docker-compose.yml b/~/work/worker-system/docker-compose.yml new file mode 100644 index 0000000..0690f0f --- /dev/null +++ b/~/work/worker-system/docker-compose.yml @@ -0,0 +1,24 @@ +version: "3.8" + +services: + worker: + build: . + container_name: image_worker + command: python worker.py --concurrency 2 --redis-url redis://host.docker.internal:6379/0 + volumes: + - ./modules:/worker/modules + - ./models:/worker/models + - ./temp:/worker/temp + environment: + - REDIS_URL=redis://host.docker.internal:6379/0 + - MAIN_SERVER_HOST=host.docker.internal + extra_hosts: + - "host.docker.internal:host-gateway" + restart: unless-stopped + + # 로컬에서 Redis가 필요한 경우 (메인서버와 분리된 환경) + # redis: + # image: redis:6.2 + # container_name: worker_redis + # ports: + # - "6380:6379" \ No newline at end of file diff --git a/~/work/worker-system/start_worker.sh b/~/work/worker-system/start_worker.sh new file mode 100644 index 0000000..d6a9e08 --- /dev/null +++ b/~/work/worker-system/start_worker.sh @@ -0,0 +1,46 @@ +#!/bin/bash + +# 워커 자동 시작 스크립트 +echo "=== 이미지 처리 워커 시작 ===" + +# 기본 설정 +REDIS_URL=${REDIS_URL:-"redis://localhost:6379/0"} +MAIN_SERVER=${MAIN_SERVER:-"localhost"} +CONCURRENCY=${CONCURRENCY:-"2"} +WORKER_MODE=${WORKER_MODE:-"docker"} + +echo "Redis URL: $REDIS_URL" +echo "메인 서버: $MAIN_SERVER" +echo "동시 처리 수: $CONCURRENCY" +echo "실행 모드: $WORKER_MODE" + +case $WORKER_MODE in + "docker") + echo "Docker 모드로 워커 시작..." + docker-compose down + docker-compose build + docker-compose up -d + echo "워커 컨테이너 시작 완료!" + docker-compose logs -f worker + ;; + + "local") + echo "로컬 모드로 워커 시작..." + python worker.py --concurrency $CONCURRENCY --redis-url $REDIS_URL --main-server $MAIN_SERVER + ;; + + "remote") + echo "원격 모드로 워커 시작..." + # 원격 서버의 Redis에 연결 + python worker.py --concurrency $CONCURRENCY --redis-url $REDIS_URL --main-server $MAIN_SERVER + ;; + + *) + echo "사용법: WORKER_MODE=docker|local|remote ./start_worker.sh" + echo "환경 변수:" + echo " REDIS_URL: Redis 서버 주소 (기본값: redis://localhost:6379/0)" + echo " MAIN_SERVER: 메인 서버 주소 (기본값: localhost)" + echo " CONCURRENCY: 동시 처리 수 (기본값: 2)" + exit 1 + ;; +esac \ No newline at end of file diff --git a/~/work/worker-system/worker.py b/~/work/worker-system/worker.py new file mode 100644 index 0000000..2eb0a80 --- /dev/null +++ b/~/work/worker-system/worker.py @@ -0,0 +1,176 @@ +#!/usr/bin/env python3 +""" +독립적인 Celery 워커 시스템 +메인서버의 Redis와 연결하여 작업을 처리합니다. +""" + +import os +import sys +import argparse +from celery import Celery +from modules.image_processor2 import ImageProcessor +from modules.loggerModule import Logger1 +from modules.gpt_client import GPTClient + +def get_base_dir(): + """ + 실행 환경에 따라 base_dir을 설정하는 메서드. + """ + if getattr(sys, 'frozen', False): + base_dir = os.path.dirname(sys.executable) + internal_dir = os.path.join(base_dir, 'lib') + if os.path.exists(internal_dir): + return internal_dir + else: + base_dir = os.path.dirname(os.path.abspath(__file__)) + return base_dir + +# 환경 변수에서 Redis URL 가져오기 (기본값은 로컬 메인서버) +REDIS_URL = os.getenv("REDIS_URL", "redis://localhost:6379/0") +MAIN_SERVER_HOST = os.getenv("MAIN_SERVER_HOST", "localhost") + +# Celery 앱 생성 +celery_app = Celery( + "image_worker", + broker=REDIS_URL, + backend=REDIS_URL +) + +# Celery 설정 +celery_app.conf.update( + task_serializer='json', + accept_content=['json'], + result_serializer='json', + timezone='Asia/Seoul', + enable_utc=True, + task_routes={ + 'worker.translate_task': {'queue': 'translate'}, + 'worker.inpaint_task': {'queue': 'inpaint'}, + 'worker.ocr_task': {'queue': 'ocr'}, + } +) + +# 글로벌 ImageProcessor 인스턴스 +image_processor = None + +def initialize_processor(): + """ImageProcessor 초기화""" + global image_processor + if image_processor is None: + logger = Logger1() + gpt_client = GPTClient() + base_dir = get_base_dir() + font_path = os.path.join(base_dir, "modules", "fonts", "HakgyoansimDunggeunmisoTTFB.ttf") + + print(f"워커 초기화 중...") + print(f"Base directory: {base_dir}") + print(f"Font path: {font_path}") + print(f"Redis URL: {REDIS_URL}") + + image_processor = ImageProcessor(logger, gpt_client, base_dir, font_path) + print("ImageProcessor 초기화 완료") + + return image_processor + +@celery_app.task(name="worker.translate_task", bind=True) +def translate_task(self, **kwargs): + """번역 작업 처리""" + try: + processor = initialize_processor() + + # 실제 번역 로직 호출 + # TODO: ImageProcessor의 번역 메서드와 연결 + result = { + "task_id": self.request.id, + "status": "completed", + "ocr_texts": ["번역된 텍스트1", "번역된 텍스트2"], + "ocr_boxes": [ + {"text": "번역된 텍스트1", "box": [10, 20, 100, 120]}, + {"text": "번역된 텍스트2", "box": [110, 120, 200, 220]} + ], + "translated_texts": ["한글 번역1", "한글 번역2"], + "inpainted_image": "base64_encoded_image_data" + } + + print(f"번역 작업 완료: {self.request.id}") + return result + + except Exception as e: + print(f"번역 작업 오류: {str(e)}") + raise self.retry(countdown=60, max_retries=3) + +@celery_app.task(name="worker.inpaint_task", bind=True) +def inpaint_task(self, **kwargs): + """인페인팅 작업 처리""" + try: + processor = initialize_processor() + + # 실제 인페인팅 로직 호출 + # TODO: ImageProcessor의 인페인팅 메서드와 연결 + result = { + "task_id": self.request.id, + "status": "completed", + "inpainted_image": "base64_encoded_inpainted_image" + } + + print(f"인페인팅 작업 완료: {self.request.id}") + return result + + except Exception as e: + print(f"인페인팅 작업 오류: {str(e)}") + raise self.retry(countdown=60, max_retries=3) + +@celery_app.task(name="worker.ocr_task", bind=True) +def ocr_task(self, **kwargs): + """OCR 작업 처리""" + try: + processor = initialize_processor() + + # 실제 OCR 로직 호출 + # TODO: ImageProcessor의 OCR 메서드와 연결 + result = { + "task_id": self.request.id, + "status": "completed", + "ocr_texts": ["감지된 텍스트1", "감지된 텍스트2"], + "ocr_boxes": [ + {"text": "감지된 텍스트1", "box": [10, 20, 100, 120]}, + {"text": "감지된 텍스트2", "box": [110, 120, 200, 220]} + ] + } + + print(f"OCR 작업 완료: {self.request.id}") + return result + + except Exception as e: + print(f"OCR 작업 오류: {str(e)}") + raise self.retry(countdown=60, max_retries=3) + +if __name__ == "__main__": + parser = argparse.ArgumentParser(description="이미지 처리 Celery 워커") + parser.add_argument('--concurrency', type=int, default=2, help='동시 처리 작업 수') + parser.add_argument('--redis-url', type=str, help='Redis 서버 URL') + parser.add_argument('--main-server', type=str, help='메인 서버 주소') + + args = parser.parse_args() + + # 명령행 인수로 Redis URL 오버라이드 + if args.redis_url: + os.environ["REDIS_URL"] = args.redis_url + REDIS_URL = args.redis_url + + if args.main_server: + os.environ["MAIN_SERVER_HOST"] = args.main_server + MAIN_SERVER_HOST = args.main_server + + print(f"워커 시작 중...") + print(f"Redis URL: {REDIS_URL}") + print(f"메인 서버: {MAIN_SERVER_HOST}") + print(f"동시 처리 수: {args.concurrency}") + + # 워커 실행 + celery_app.worker_main([ + 'worker', + '--loglevel=info', + f'--concurrency={args.concurrency}', + '--queues=translate,inpaint,ocr' + ]) \ No newline at end of file