pyjallib.perforce

P4Python을 사용하는 Perforce 모듈.

이 모듈은 P4Python을 사용하여 Perforce 서버와 상호작용하는 기능을 제공합니다. 주요 기능:

  • 워크스페이스 연결
  • 체인지리스트 관리 (생성, 조회, 편집, 제출, 되돌리기)
  • 파일 작업 (체크아웃, 추가, 삭제)
  • 파일 동기화 및 업데이트 확인
  1"""
  2P4Python을 사용하는 Perforce 모듈.
  3
  4이 모듈은 P4Python을 사용하여 Perforce 서버와 상호작용하는 기능을 제공합니다.
  5주요 기능:
  6- 워크스페이스 연결
  7- 체인지리스트 관리 (생성, 조회, 편집, 제출, 되돌리기)
  8- 파일 작업 (체크아웃, 추가, 삭제)
  9- 파일 동기화 및 업데이트 확인
 10"""
 11
 12import logging
 13from P4 import P4, P4Exception
 14import os
 15from pathlib import Path
 16
 17# 로깅 설정
 18logger = logging.getLogger(__name__)
 19logger.setLevel(logging.DEBUG)
 20# 사용자 문서 폴더 내 로그 파일 저장
 21log_path = os.path.join(Path.home() / "Documents", 'Perforce.log')
 22file_handler = logging.FileHandler(log_path, encoding='utf-8')
 23file_handler.setFormatter(logging.Formatter('%(asctime)s - %(name)s - %(levelname)s - %(message)s'))
 24logger.addHandler(file_handler)
 25
 26
 27class Perforce:
 28    """P4Python을 사용하여 Perforce 작업을 수행하는 클래스."""
 29
 30    def __init__(self):
 31        """Perforce 인스턴스를 초기화합니다."""
 32        self.p4 = P4()
 33        self.connected = False
 34        self.workspaceRoot = r""
 35        logger.info("Perforce 인스턴스 생성됨")
 36
 37    def _is_connected(self) -> bool:
 38        """Perforce 서버 연결 상태를 확인합니다.
 39
 40        Returns:
 41            bool: 연결되어 있으면 True, 아니면 False
 42        """
 43        if not self.connected:
 44            logger.warning("Perforce 서버에 연결되지 않았습니다.")
 45            return False
 46        return True
 47
 48    def _handle_p4_exception(self, e: P4Exception, context_msg: str = "") -> None:
 49        """P4Exception을 처리하고 로깅합니다.
 50
 51        Args:
 52            e (P4Exception): 발생한 예외
 53            context_msg (str, optional): 예외가 발생한 컨텍스트 설명
 54        """
 55        logger.error(f"{context_msg} 중 P4Exception 발생: {e}")
 56        for err in self.p4.errors:
 57            logger.error(f"  P4 Error: {err}")
 58        for warn in self.p4.warnings:
 59            logger.warning(f"  P4 Warning: {warn}")
 60
 61    def connect(self, workspace_name: str) -> bool:
 62        """지정된 워크스페이스에 연결합니다.
 63
 64        Args:
 65            workspace_name (str): 연결할 워크스페이스 이름
 66
 67        Returns:
 68            bool: 연결 성공 시 True, 실패 시 False
 69        """
 70        logger.info(f"'{workspace_name}' 워크스페이스에 연결 시도 중...")
 71        try:
 72            self.p4.client = workspace_name
 73            self.p4.connect()
 74            self.connected = True
 75            
 76            # 워크스페이스 루트 경로 가져오기
 77            try:
 78                client_info = self.p4.run_client("-o", workspace_name)[0]
 79                root_path = client_info.get("Root", "")
 80                
 81                # Windows 경로 형식으로 변환 (슬래시를 백슬래시로)
 82                root_path = os.path.normpath(root_path)
 83                
 84                self.workspaceRoot = root_path
 85                logger.info(f"워크스페이스 루트 절대 경로: {self.workspaceRoot}")
 86            except (IndexError, KeyError) as e:
 87                logger.error(f"워크스페이스 루트 경로 가져오기 실패: {e}")
 88                self.workspaceRoot = ""
 89                
 90            logger.info(f"'{workspace_name}' 워크스페이스에 성공적으로 연결됨 (User: {self.p4.user}, Port: {self.p4.port})")
 91            return True
 92        except P4Exception as e:
 93            self.connected = False
 94            self._handle_p4_exception(e, f"'{workspace_name}' 워크스페이스 연결")
 95            return False
 96
 97    def get_pending_change_list(self) -> list:
 98        """워크스페이스의 Pending된 체인지 리스트를 가져옵니다.
 99
100        Returns:
101            list: 체인지 리스트 정보 딕셔너리들의 리스트
102        """
103        if not self._is_connected():
104            return []
105        logger.debug("Pending 체인지 리스트 조회 중...")
106        try:
107            pending_changes = self.p4.run_changes("-s", "pending", "-u", self.p4.user, "-c", self.p4.client)
108            change_numbers = [int(cl['change']) for cl in pending_changes]
109            
110            # 각 체인지 리스트 번호에 대한 상세 정보 가져오기
111            change_list_info = []
112            for change_number in change_numbers:
113                cl_info = self.get_change_list_by_number(change_number)
114                if cl_info:
115                    change_list_info.append(cl_info)
116            
117            logger.info(f"Pending 체인지 리스트 {len(change_list_info)}개 조회 완료")
118            return change_list_info
119        except P4Exception as e:
120            self._handle_p4_exception(e, "Pending 체인지 리스트 조회")
121            return []
122
123    def create_change_list(self, description: str) -> dict:
124        """새로운 체인지 리스트를 생성합니다.
125
126        Args:
127            description (str): 체인지 리스트 설명
128
129        Returns:
130            dict: 생성된 체인지 리스트 정보. 실패 시 빈 딕셔너리
131        """
132        if not self._is_connected():
133            return {}
134        logger.info(f"새 체인지 리스트 생성 시도: '{description}'")
135        try:
136            change_spec = self.p4.fetch_change()
137            change_spec["Description"] = description
138            result = self.p4.save_change(change_spec)
139            created_change_number = int(result[0].split()[1])
140            logger.info(f"체인지 리스트 {created_change_number} 생성 완료: '{description}'")
141            return self.get_change_list_by_number(created_change_number)
142        except P4Exception as e:
143            self._handle_p4_exception(e, f"체인지 리스트 생성 ('{description}')")
144            return {}
145        except (IndexError, ValueError) as e:
146            logger.error(f"체인지 리스트 번호 파싱 오류: {e}")
147            return {}
148
149    def get_change_list_by_number(self, change_list_number: int) -> dict:
150        """체인지 리스트 번호로 체인지 리스트를 가져옵니다.
151
152        Args:
153            change_list_number (int): 체인지 리스트 번호
154
155        Returns:
156            dict: 체인지 리스트 정보. 실패 시 빈 딕셔너리
157        """
158        if not self._is_connected():
159            return {}
160        logger.debug(f"체인지 리스트 {change_list_number} 정보 조회 중...")
161        try:
162            cl_info = self.p4.fetch_change(change_list_number)
163            if cl_info:
164                logger.info(f"체인지 리스트 {change_list_number} 정보 조회 완료.")
165                return cl_info
166            else:
167                logger.warning(f"체인지 리스트 {change_list_number}를 찾을 수 없습니다.")
168                return {}
169        except P4Exception as e:
170            self._handle_p4_exception(e, f"체인지 리스트 {change_list_number} 정보 조회")
171            return {}
172
173    def get_change_list_by_description(self, description: str) -> dict:
174        """체인지 리스트 설명으로 체인지 리스트를 가져옵니다.
175
176        Args:
177            description (str): 체인지 리스트 설명
178
179        Returns:
180            dict: 체인지 리스트 정보 (일치하는 첫 번째 체인지 리스트)
181        """
182        if not self._is_connected():
183            return {}
184        logger.debug(f"설명으로 체인지 리스트 조회 중: '{description}'")
185        try:
186            pending_changes = self.p4.run_changes("-l", "-s", "pending", "-u", self.p4.user, "-c", self.p4.client)
187            for cl in pending_changes:
188                cl_desc = cl.get('Description', b'').decode('utf-8', 'replace').strip()
189                if cl_desc == description.strip():
190                    logger.info(f"설명 '{description}'에 해당하는 체인지 리스트 {cl['change']} 조회 완료.")
191                    return self.get_change_list_by_number(int(cl['change']))
192            logger.info(f"설명 '{description}'에 해당하는 Pending 체인지 리스트를 찾을 수 없습니다.")
193            return {}
194        except P4Exception as e:
195            self._handle_p4_exception(e, f"설명으로 체인지 리스트 조회 ('{description}')")
196            return {}
197
198    def edit_change_list(self, change_list_number: int, description: str = None, add_file_paths: list = None, remove_file_paths: list = None) -> dict:
199        """체인지 리스트를 편집합니다.
200
201        Args:
202            change_list_number (int): 체인지 리스트 번호
203            description (str, optional): 변경할 설명
204            add_file_paths (list, optional): 추가할 파일 경로 리스트
205            remove_file_paths (list, optional): 제거할 파일 경로 리스트
206
207        Returns:
208            dict: 업데이트된 체인지 리스트 정보
209        """
210        if not self._is_connected():
211            return {}
212        logger.info(f"체인지 리스트 {change_list_number} 편집 시도...")
213        try:
214            if description is not None:
215                change_spec = self.p4.fetch_change(change_list_number)
216                current_description = change_spec.get('Description', '').strip()
217                if current_description != description.strip():
218                    change_spec['Description'] = description
219                    self.p4.save_change(change_spec)
220                    logger.info(f"체인지 리스트 {change_list_number} 설명 변경 완료: '{description}'")
221
222            if add_file_paths:
223                for file_path in add_file_paths:
224                    try:
225                        self.p4.run_reopen("-c", change_list_number, file_path)
226                        logger.info(f"파일 '{file_path}'를 체인지 리스트 {change_list_number}로 이동 완료.")
227                    except P4Exception as e_reopen:
228                        self._handle_p4_exception(e_reopen, f"파일 '{file_path}'을 CL {change_list_number}로 이동")
229
230            if remove_file_paths:
231                for file_path in remove_file_paths:
232                    try:
233                        self.p4.run_revert("-c", change_list_number, file_path)
234                        logger.info(f"파일 '{file_path}'를 체인지 리스트 {change_list_number}에서 제거(revert) 완료.")
235                    except P4Exception as e_revert:
236                        self._handle_p4_exception(e_revert, f"파일 '{file_path}'을 CL {change_list_number}에서 제거(revert)")
237
238            return self.get_change_list_by_number(change_list_number)
239
240        except P4Exception as e:
241            self._handle_p4_exception(e, f"체인지 리스트 {change_list_number} 편집")
242            return self.get_change_list_by_number(change_list_number)
243
244    def _file_op(self, command: str, file_path: str, change_list_number: int, op_name: str) -> bool:
245        """파일 작업을 수행하는 내부 헬퍼 함수입니다.
246
247        Args:
248            command (str): 실행할 명령어 (edit/add/delete)
249            file_path (str): 대상 파일 경로
250            change_list_number (int): 체인지 리스트 번호
251            op_name (str): 작업 이름 (로깅용)
252
253        Returns:
254            bool: 작업 성공 시 True, 실패 시 False
255        """
256        if not self._is_connected():
257            return False
258        logger.info(f"파일 '{file_path}'에 대한 '{op_name}' 작업 시도 (CL: {change_list_number})...")
259        try:
260            if command == "edit":
261                self.p4.run_edit("-c", change_list_number, file_path)
262            elif command == "add":
263                self.p4.run_add("-c", change_list_number, file_path)
264            elif command == "delete":
265                self.p4.run_delete("-c", change_list_number, file_path)
266            else:
267                logger.error(f"지원되지 않는 파일 작업: {command}")
268                return False
269            logger.info(f"파일 '{file_path}'에 대한 '{op_name}' 작업 성공 (CL: {change_list_number}).")
270            return True
271        except P4Exception as e:
272            self._handle_p4_exception(e, f"파일 '{file_path}' {op_name} (CL: {change_list_number})")
273            return False
274
275    def checkout_file(self, file_path: str, change_list_number: int) -> bool:
276        """파일을 체크아웃합니다.
277
278        Args:
279            file_path (str): 체크아웃할 파일 경로
280            change_list_number (int): 체인지 리스트 번호
281
282        Returns:
283            bool: 체크아웃 성공 시 True, 실패 시 False
284        """
285        return self._file_op("edit", file_path, change_list_number, "체크아웃")
286
287    def add_file(self, file_path: str, change_list_number: int) -> bool:
288        """파일을 추가합니다.
289
290        Args:
291            file_path (str): 추가할 파일 경로
292            change_list_number (int): 체인지 리스트 번호
293
294        Returns:
295            bool: 추가 성공 시 True, 실패 시 False
296        """
297        return self._file_op("add", file_path, change_list_number, "추가")
298
299    def delete_file(self, file_path: str, change_list_number: int) -> bool:
300        """파일을 삭제합니다.
301
302        Args:
303            file_path (str): 삭제할 파일 경로
304            change_list_number (int): 체인지 리스트 번호
305
306        Returns:
307            bool: 삭제 성공 시 True, 실패 시 False
308        """
309        return self._file_op("delete", file_path, change_list_number, "삭제")
310
311    def submit_change_list(self, change_list_number: int) -> bool:
312        """체인지 리스트를 제출합니다.
313
314        Args:
315            change_list_number (int): 제출할 체인지 리스트 번호
316
317        Returns:
318            bool: 제출 성공 시 True, 실패 시 False
319        """
320        if not self._is_connected():
321            return False
322        logger.info(f"체인지 리스트 {change_list_number} 제출 시도...")
323        try:
324            self.p4.run_submit("-c", change_list_number)
325            logger.info(f"체인지 리스트 {change_list_number} 제출 성공.")
326            return True
327        except P4Exception as e:
328            self._handle_p4_exception(e, f"체인지 리스트 {change_list_number} 제출")
329            if any("nothing to submit" in err.lower() for err in self.p4.errors):
330                logger.warning(f"체인지 리스트 {change_list_number}에 제출할 파일이 없습니다.")
331            return False
332
333    def revert_change_list(self, change_list_number: int) -> bool:
334        """체인지 리스트를 되돌리고 삭제합니다.
335
336        체인지 리스트 내 모든 파일을 되돌린 후 빈 체인지 리스트를 삭제합니다.
337
338        Args:
339            change_list_number (int): 되돌릴 체인지 리스트 번호
340
341        Returns:
342            bool: 되돌리기 및 삭제 성공 시 True, 실패 시 False
343        """
344        if not self._is_connected():
345            return False
346        logger.info(f"체인지 리스트 {change_list_number} 전체 되돌리기 및 삭제 시도...")
347        try:
348            # 체인지 리스트의 모든 파일 되돌리기
349            self.p4.run_revert("-c", change_list_number, "//...")
350            logger.info(f"체인지 리스트 {change_list_number} 전체 되돌리기 성공.")
351            
352            # 빈 체인지 리스트 삭제
353            try:
354                self.p4.run_change("-d", change_list_number)
355                logger.info(f"체인지 리스트 {change_list_number} 삭제 완료.")
356            except P4Exception as e_delete:
357                self._handle_p4_exception(e_delete, f"체인지 리스트 {change_list_number} 삭제")
358                logger.warning(f"파일 되돌리기는 성공했으나 체인지 리스트 {change_list_number} 삭제에 실패했습니다.")
359                return False
360                
361            return True
362        except P4Exception as e:
363            self._handle_p4_exception(e, f"체인지 리스트 {change_list_number} 전체 되돌리기")
364            return False
365    
366    def delete_empty_change_list(self, change_list_number: int) -> bool:
367        """빈 체인지 리스트를 삭제합니다.
368
369        Args:
370            change_list_number (int): 삭제할 체인지 리스트 번호
371
372        Returns:
373            bool: 삭제 성공 시 True, 실패 시 False
374        """
375        if not self._is_connected():
376            return False
377        
378        logger.info(f"체인지 리스트 {change_list_number} 삭제 시도 중...")
379        try:
380            # 체인지 리스트 정보 가져오기
381            change_spec = self.p4.fetch_change(change_list_number)
382            
383            # 파일이 있는지 확인
384            if change_spec and change_spec.get('Files') and len(change_spec['Files']) > 0:
385                logger.warning(f"체인지 리스트 {change_list_number}에 파일이 {len(change_spec['Files'])}개 있어 삭제할 수 없습니다.")
386                return False
387            
388            # 빈 체인지 리스트 삭제
389            self.p4.run_change("-d", change_list_number)
390            logger.info(f"빈 체인지 리스트 {change_list_number} 삭제 완료.")
391            return True
392        except P4Exception as e:
393            self._handle_p4_exception(e, f"체인지 리스트 {change_list_number} 삭제")
394            return False
395
396    def revert_files(self, change_list_number: int, file_paths: list) -> bool:
397        """체인지 리스트 내의 특정 파일들을 되돌립니다.
398
399        Args:
400            change_list_number (int): 체인지 리스트 번호
401            file_paths (list): 되돌릴 파일 경로 리스트
402
403        Returns:
404            bool: 되돌리기 성공 시 True, 실패 시 False
405        """
406        if not self._is_connected():
407            return False
408        if not file_paths:
409            logger.warning("되돌릴 파일 목록이 비어있습니다.")
410            return True
411            
412        logger.info(f"체인지 리스트 {change_list_number}에서 {len(file_paths)}개 파일 되돌리기 시도...")
413        try:
414            for file_path in file_paths:
415                self.p4.run_revert("-c", change_list_number, file_path)
416                logger.info(f"파일 '{file_path}'를 체인지 리스트 {change_list_number}에서 되돌리기 성공.")
417            return True
418        except P4Exception as e:
419            self._handle_p4_exception(e, f"체인지 리스트 {change_list_number}에서 파일 되돌리기")
420            return False
421
422    def check_update_required(self, file_paths: list) -> bool:
423        """파일이나 폴더의 업데이트 필요 여부를 확인합니다.
424
425        Args:
426            file_paths (list): 확인할 파일 또는 폴더 경로 리스트. 
427                              폴더 경로는 자동으로 재귀적으로 처리됩니다.
428
429        Returns:
430            bool: 업데이트가 필요한 파일이 있으면 True, 없으면 False
431        """
432        if not self._is_connected():
433            return False
434        if not file_paths:
435            logger.debug("업데이트 필요 여부 확인할 파일/폴더 목록이 비어있습니다.")
436            return False
437        
438        # 폴더 경로에 재귀적 와일드카드 패턴을 추가
439        processed_paths = []
440        for path in file_paths:
441            if os.path.isdir(path):
442                # 폴더 경로에 '...'(재귀) 패턴을 추가
443                processed_paths.append(os.path.join(path, '...'))
444                logger.debug(f"폴더 경로를 재귀 패턴으로 변환: {path} -> {os.path.join(path, '...')}")
445            else:
446                processed_paths.append(path)
447        
448        logger.debug(f"파일/폴더 업데이트 필요 여부 확인 중 (항목 {len(processed_paths)}개): {processed_paths}")
449        try:
450            sync_preview_results = self.p4.run_sync("-n", processed_paths)
451            needs_update = False
452            for result in sync_preview_results:
453                if isinstance(result, dict):
454                    if 'up-to-date' not in result.get('how', '') and \
455                       'no such file(s)' not in result.get('depotFile', ''):
456                        if result.get('how') and 'syncing' in result.get('how'):
457                            needs_update = True
458                            logger.info(f"파일 '{result.get('clientFile', result.get('depotFile'))}' 업데이트 필요: {result.get('how')}")
459                            break
460                        elif result.get('action') and result.get('action') not in ['checked', 'exists']:
461                            needs_update = True
462                            logger.info(f"파일 '{result.get('clientFile', result.get('depotFile'))}' 업데이트 필요 (action: {result.get('action')})")
463                            break
464                elif isinstance(result, str):
465                    if "up-to-date" not in result and "no such file(s)" not in result:
466                        needs_update = True
467                        logger.info(f"파일 업데이트 필요 (문자열 결과): {result}")
468                        break
469            
470            if needs_update:
471                logger.info(f"지정된 파일/폴더 중 업데이트가 필요한 파일이 있습니다.")
472            else:
473                logger.info(f"지정된 모든 파일/폴더가 최신 상태입니다.")
474            return needs_update
475        except P4Exception as e:
476            self._handle_p4_exception(e, f"파일/폴더 업데이트 필요 여부 확인 ({processed_paths})")
477            return False
478
479    def sync_files(self, file_paths: list) -> bool:
480        """파일이나 폴더를 동기화합니다.
481
482        Args:
483            file_paths (list): 동기화할 파일 또는 폴더 경로 리스트.
484                             폴더 경로는 자동으로 재귀적으로 처리됩니다.
485
486        Returns:
487            bool: 동기화 성공 시 True, 실패 시 False
488        """
489        if not self._is_connected():
490            return False
491        if not file_paths:
492            logger.debug("싱크할 파일/폴더 목록이 비어있습니다.")
493            return True
494        
495        # 폴더 경로에 재귀적 와일드카드 패턴을 추가
496        processed_paths = []
497        for path in file_paths:
498            if os.path.isdir(path):
499                # 폴더 경로에 '...'(재귀) 패턴을 추가
500                processed_paths.append(os.path.join(path, '...'))
501                logger.debug(f"폴더 경로를 재귀 패턴으로 변환: {path} -> {os.path.join(path, '...')}")
502            else:
503                processed_paths.append(path)
504        
505        logger.info(f"파일/폴더 싱크 시도 (항목 {len(processed_paths)}개): {processed_paths}")
506        try:
507            self.p4.run_sync(processed_paths)
508            logger.info(f"파일/폴더 싱크 완료: {processed_paths}")
509            return True
510        except P4Exception as e:
511            self._handle_p4_exception(e, f"파일/폴더 싱크 ({processed_paths})")
512            return False
513
514    def disconnect(self):
515        """Perforce 서버와의 연결을 해제합니다."""
516        if self.connected:
517            try:
518                self.p4.disconnect()
519                self.connected = False
520                logger.info("Perforce 서버 연결 해제 완료.")
521            except P4Exception as e:
522                self._handle_p4_exception(e, "Perforce 서버 연결 해제")
523        else:
524            logger.debug("Perforce 서버에 이미 연결되지 않은 상태입니다.")
525
526    def __del__(self):
527        """객체가 소멸될 때 자동으로 연결을 해제합니다."""
528        self.disconnect()
logger = <Logger pyjallib.perforce (DEBUG)>
log_path = 'C:\\Users\\jalnaga\\Documents\\Perforce.log'
file_handler = <FileHandler C:\Users\jalnaga\Documents\Perforce.log (NOTSET)>
class Perforce:
 28class Perforce:
 29    """P4Python을 사용하여 Perforce 작업을 수행하는 클래스."""
 30
 31    def __init__(self):
 32        """Perforce 인스턴스를 초기화합니다."""
 33        self.p4 = P4()
 34        self.connected = False
 35        self.workspaceRoot = r""
 36        logger.info("Perforce 인스턴스 생성됨")
 37
 38    def _is_connected(self) -> bool:
 39        """Perforce 서버 연결 상태를 확인합니다.
 40
 41        Returns:
 42            bool: 연결되어 있으면 True, 아니면 False
 43        """
 44        if not self.connected:
 45            logger.warning("Perforce 서버에 연결되지 않았습니다.")
 46            return False
 47        return True
 48
 49    def _handle_p4_exception(self, e: P4Exception, context_msg: str = "") -> None:
 50        """P4Exception을 처리하고 로깅합니다.
 51
 52        Args:
 53            e (P4Exception): 발생한 예외
 54            context_msg (str, optional): 예외가 발생한 컨텍스트 설명
 55        """
 56        logger.error(f"{context_msg} 중 P4Exception 발생: {e}")
 57        for err in self.p4.errors:
 58            logger.error(f"  P4 Error: {err}")
 59        for warn in self.p4.warnings:
 60            logger.warning(f"  P4 Warning: {warn}")
 61
 62    def connect(self, workspace_name: str) -> bool:
 63        """지정된 워크스페이스에 연결합니다.
 64
 65        Args:
 66            workspace_name (str): 연결할 워크스페이스 이름
 67
 68        Returns:
 69            bool: 연결 성공 시 True, 실패 시 False
 70        """
 71        logger.info(f"'{workspace_name}' 워크스페이스에 연결 시도 중...")
 72        try:
 73            self.p4.client = workspace_name
 74            self.p4.connect()
 75            self.connected = True
 76            
 77            # 워크스페이스 루트 경로 가져오기
 78            try:
 79                client_info = self.p4.run_client("-o", workspace_name)[0]
 80                root_path = client_info.get("Root", "")
 81                
 82                # Windows 경로 형식으로 변환 (슬래시를 백슬래시로)
 83                root_path = os.path.normpath(root_path)
 84                
 85                self.workspaceRoot = root_path
 86                logger.info(f"워크스페이스 루트 절대 경로: {self.workspaceRoot}")
 87            except (IndexError, KeyError) as e:
 88                logger.error(f"워크스페이스 루트 경로 가져오기 실패: {e}")
 89                self.workspaceRoot = ""
 90                
 91            logger.info(f"'{workspace_name}' 워크스페이스에 성공적으로 연결됨 (User: {self.p4.user}, Port: {self.p4.port})")
 92            return True
 93        except P4Exception as e:
 94            self.connected = False
 95            self._handle_p4_exception(e, f"'{workspace_name}' 워크스페이스 연결")
 96            return False
 97
 98    def get_pending_change_list(self) -> list:
 99        """워크스페이스의 Pending된 체인지 리스트를 가져옵니다.
100
101        Returns:
102            list: 체인지 리스트 정보 딕셔너리들의 리스트
103        """
104        if not self._is_connected():
105            return []
106        logger.debug("Pending 체인지 리스트 조회 중...")
107        try:
108            pending_changes = self.p4.run_changes("-s", "pending", "-u", self.p4.user, "-c", self.p4.client)
109            change_numbers = [int(cl['change']) for cl in pending_changes]
110            
111            # 각 체인지 리스트 번호에 대한 상세 정보 가져오기
112            change_list_info = []
113            for change_number in change_numbers:
114                cl_info = self.get_change_list_by_number(change_number)
115                if cl_info:
116                    change_list_info.append(cl_info)
117            
118            logger.info(f"Pending 체인지 리스트 {len(change_list_info)}개 조회 완료")
119            return change_list_info
120        except P4Exception as e:
121            self._handle_p4_exception(e, "Pending 체인지 리스트 조회")
122            return []
123
124    def create_change_list(self, description: str) -> dict:
125        """새로운 체인지 리스트를 생성합니다.
126
127        Args:
128            description (str): 체인지 리스트 설명
129
130        Returns:
131            dict: 생성된 체인지 리스트 정보. 실패 시 빈 딕셔너리
132        """
133        if not self._is_connected():
134            return {}
135        logger.info(f"새 체인지 리스트 생성 시도: '{description}'")
136        try:
137            change_spec = self.p4.fetch_change()
138            change_spec["Description"] = description
139            result = self.p4.save_change(change_spec)
140            created_change_number = int(result[0].split()[1])
141            logger.info(f"체인지 리스트 {created_change_number} 생성 완료: '{description}'")
142            return self.get_change_list_by_number(created_change_number)
143        except P4Exception as e:
144            self._handle_p4_exception(e, f"체인지 리스트 생성 ('{description}')")
145            return {}
146        except (IndexError, ValueError) as e:
147            logger.error(f"체인지 리스트 번호 파싱 오류: {e}")
148            return {}
149
150    def get_change_list_by_number(self, change_list_number: int) -> dict:
151        """체인지 리스트 번호로 체인지 리스트를 가져옵니다.
152
153        Args:
154            change_list_number (int): 체인지 리스트 번호
155
156        Returns:
157            dict: 체인지 리스트 정보. 실패 시 빈 딕셔너리
158        """
159        if not self._is_connected():
160            return {}
161        logger.debug(f"체인지 리스트 {change_list_number} 정보 조회 중...")
162        try:
163            cl_info = self.p4.fetch_change(change_list_number)
164            if cl_info:
165                logger.info(f"체인지 리스트 {change_list_number} 정보 조회 완료.")
166                return cl_info
167            else:
168                logger.warning(f"체인지 리스트 {change_list_number}를 찾을 수 없습니다.")
169                return {}
170        except P4Exception as e:
171            self._handle_p4_exception(e, f"체인지 리스트 {change_list_number} 정보 조회")
172            return {}
173
174    def get_change_list_by_description(self, description: str) -> dict:
175        """체인지 리스트 설명으로 체인지 리스트를 가져옵니다.
176
177        Args:
178            description (str): 체인지 리스트 설명
179
180        Returns:
181            dict: 체인지 리스트 정보 (일치하는 첫 번째 체인지 리스트)
182        """
183        if not self._is_connected():
184            return {}
185        logger.debug(f"설명으로 체인지 리스트 조회 중: '{description}'")
186        try:
187            pending_changes = self.p4.run_changes("-l", "-s", "pending", "-u", self.p4.user, "-c", self.p4.client)
188            for cl in pending_changes:
189                cl_desc = cl.get('Description', b'').decode('utf-8', 'replace').strip()
190                if cl_desc == description.strip():
191                    logger.info(f"설명 '{description}'에 해당하는 체인지 리스트 {cl['change']} 조회 완료.")
192                    return self.get_change_list_by_number(int(cl['change']))
193            logger.info(f"설명 '{description}'에 해당하는 Pending 체인지 리스트를 찾을 수 없습니다.")
194            return {}
195        except P4Exception as e:
196            self._handle_p4_exception(e, f"설명으로 체인지 리스트 조회 ('{description}')")
197            return {}
198
199    def edit_change_list(self, change_list_number: int, description: str = None, add_file_paths: list = None, remove_file_paths: list = None) -> dict:
200        """체인지 리스트를 편집합니다.
201
202        Args:
203            change_list_number (int): 체인지 리스트 번호
204            description (str, optional): 변경할 설명
205            add_file_paths (list, optional): 추가할 파일 경로 리스트
206            remove_file_paths (list, optional): 제거할 파일 경로 리스트
207
208        Returns:
209            dict: 업데이트된 체인지 리스트 정보
210        """
211        if not self._is_connected():
212            return {}
213        logger.info(f"체인지 리스트 {change_list_number} 편집 시도...")
214        try:
215            if description is not None:
216                change_spec = self.p4.fetch_change(change_list_number)
217                current_description = change_spec.get('Description', '').strip()
218                if current_description != description.strip():
219                    change_spec['Description'] = description
220                    self.p4.save_change(change_spec)
221                    logger.info(f"체인지 리스트 {change_list_number} 설명 변경 완료: '{description}'")
222
223            if add_file_paths:
224                for file_path in add_file_paths:
225                    try:
226                        self.p4.run_reopen("-c", change_list_number, file_path)
227                        logger.info(f"파일 '{file_path}'를 체인지 리스트 {change_list_number}로 이동 완료.")
228                    except P4Exception as e_reopen:
229                        self._handle_p4_exception(e_reopen, f"파일 '{file_path}'을 CL {change_list_number}로 이동")
230
231            if remove_file_paths:
232                for file_path in remove_file_paths:
233                    try:
234                        self.p4.run_revert("-c", change_list_number, file_path)
235                        logger.info(f"파일 '{file_path}'를 체인지 리스트 {change_list_number}에서 제거(revert) 완료.")
236                    except P4Exception as e_revert:
237                        self._handle_p4_exception(e_revert, f"파일 '{file_path}'을 CL {change_list_number}에서 제거(revert)")
238
239            return self.get_change_list_by_number(change_list_number)
240
241        except P4Exception as e:
242            self._handle_p4_exception(e, f"체인지 리스트 {change_list_number} 편집")
243            return self.get_change_list_by_number(change_list_number)
244
245    def _file_op(self, command: str, file_path: str, change_list_number: int, op_name: str) -> bool:
246        """파일 작업을 수행하는 내부 헬퍼 함수입니다.
247
248        Args:
249            command (str): 실행할 명령어 (edit/add/delete)
250            file_path (str): 대상 파일 경로
251            change_list_number (int): 체인지 리스트 번호
252            op_name (str): 작업 이름 (로깅용)
253
254        Returns:
255            bool: 작업 성공 시 True, 실패 시 False
256        """
257        if not self._is_connected():
258            return False
259        logger.info(f"파일 '{file_path}'에 대한 '{op_name}' 작업 시도 (CL: {change_list_number})...")
260        try:
261            if command == "edit":
262                self.p4.run_edit("-c", change_list_number, file_path)
263            elif command == "add":
264                self.p4.run_add("-c", change_list_number, file_path)
265            elif command == "delete":
266                self.p4.run_delete("-c", change_list_number, file_path)
267            else:
268                logger.error(f"지원되지 않는 파일 작업: {command}")
269                return False
270            logger.info(f"파일 '{file_path}'에 대한 '{op_name}' 작업 성공 (CL: {change_list_number}).")
271            return True
272        except P4Exception as e:
273            self._handle_p4_exception(e, f"파일 '{file_path}' {op_name} (CL: {change_list_number})")
274            return False
275
276    def checkout_file(self, file_path: str, change_list_number: int) -> bool:
277        """파일을 체크아웃합니다.
278
279        Args:
280            file_path (str): 체크아웃할 파일 경로
281            change_list_number (int): 체인지 리스트 번호
282
283        Returns:
284            bool: 체크아웃 성공 시 True, 실패 시 False
285        """
286        return self._file_op("edit", file_path, change_list_number, "체크아웃")
287
288    def add_file(self, file_path: str, change_list_number: int) -> bool:
289        """파일을 추가합니다.
290
291        Args:
292            file_path (str): 추가할 파일 경로
293            change_list_number (int): 체인지 리스트 번호
294
295        Returns:
296            bool: 추가 성공 시 True, 실패 시 False
297        """
298        return self._file_op("add", file_path, change_list_number, "추가")
299
300    def delete_file(self, file_path: str, change_list_number: int) -> bool:
301        """파일을 삭제합니다.
302
303        Args:
304            file_path (str): 삭제할 파일 경로
305            change_list_number (int): 체인지 리스트 번호
306
307        Returns:
308            bool: 삭제 성공 시 True, 실패 시 False
309        """
310        return self._file_op("delete", file_path, change_list_number, "삭제")
311
312    def submit_change_list(self, change_list_number: int) -> bool:
313        """체인지 리스트를 제출합니다.
314
315        Args:
316            change_list_number (int): 제출할 체인지 리스트 번호
317
318        Returns:
319            bool: 제출 성공 시 True, 실패 시 False
320        """
321        if not self._is_connected():
322            return False
323        logger.info(f"체인지 리스트 {change_list_number} 제출 시도...")
324        try:
325            self.p4.run_submit("-c", change_list_number)
326            logger.info(f"체인지 리스트 {change_list_number} 제출 성공.")
327            return True
328        except P4Exception as e:
329            self._handle_p4_exception(e, f"체인지 리스트 {change_list_number} 제출")
330            if any("nothing to submit" in err.lower() for err in self.p4.errors):
331                logger.warning(f"체인지 리스트 {change_list_number}에 제출할 파일이 없습니다.")
332            return False
333
334    def revert_change_list(self, change_list_number: int) -> bool:
335        """체인지 리스트를 되돌리고 삭제합니다.
336
337        체인지 리스트 내 모든 파일을 되돌린 후 빈 체인지 리스트를 삭제합니다.
338
339        Args:
340            change_list_number (int): 되돌릴 체인지 리스트 번호
341
342        Returns:
343            bool: 되돌리기 및 삭제 성공 시 True, 실패 시 False
344        """
345        if not self._is_connected():
346            return False
347        logger.info(f"체인지 리스트 {change_list_number} 전체 되돌리기 및 삭제 시도...")
348        try:
349            # 체인지 리스트의 모든 파일 되돌리기
350            self.p4.run_revert("-c", change_list_number, "//...")
351            logger.info(f"체인지 리스트 {change_list_number} 전체 되돌리기 성공.")
352            
353            # 빈 체인지 리스트 삭제
354            try:
355                self.p4.run_change("-d", change_list_number)
356                logger.info(f"체인지 리스트 {change_list_number} 삭제 완료.")
357            except P4Exception as e_delete:
358                self._handle_p4_exception(e_delete, f"체인지 리스트 {change_list_number} 삭제")
359                logger.warning(f"파일 되돌리기는 성공했으나 체인지 리스트 {change_list_number} 삭제에 실패했습니다.")
360                return False
361                
362            return True
363        except P4Exception as e:
364            self._handle_p4_exception(e, f"체인지 리스트 {change_list_number} 전체 되돌리기")
365            return False
366    
367    def delete_empty_change_list(self, change_list_number: int) -> bool:
368        """빈 체인지 리스트를 삭제합니다.
369
370        Args:
371            change_list_number (int): 삭제할 체인지 리스트 번호
372
373        Returns:
374            bool: 삭제 성공 시 True, 실패 시 False
375        """
376        if not self._is_connected():
377            return False
378        
379        logger.info(f"체인지 리스트 {change_list_number} 삭제 시도 중...")
380        try:
381            # 체인지 리스트 정보 가져오기
382            change_spec = self.p4.fetch_change(change_list_number)
383            
384            # 파일이 있는지 확인
385            if change_spec and change_spec.get('Files') and len(change_spec['Files']) > 0:
386                logger.warning(f"체인지 리스트 {change_list_number}에 파일이 {len(change_spec['Files'])}개 있어 삭제할 수 없습니다.")
387                return False
388            
389            # 빈 체인지 리스트 삭제
390            self.p4.run_change("-d", change_list_number)
391            logger.info(f"빈 체인지 리스트 {change_list_number} 삭제 완료.")
392            return True
393        except P4Exception as e:
394            self._handle_p4_exception(e, f"체인지 리스트 {change_list_number} 삭제")
395            return False
396
397    def revert_files(self, change_list_number: int, file_paths: list) -> bool:
398        """체인지 리스트 내의 특정 파일들을 되돌립니다.
399
400        Args:
401            change_list_number (int): 체인지 리스트 번호
402            file_paths (list): 되돌릴 파일 경로 리스트
403
404        Returns:
405            bool: 되돌리기 성공 시 True, 실패 시 False
406        """
407        if not self._is_connected():
408            return False
409        if not file_paths:
410            logger.warning("되돌릴 파일 목록이 비어있습니다.")
411            return True
412            
413        logger.info(f"체인지 리스트 {change_list_number}에서 {len(file_paths)}개 파일 되돌리기 시도...")
414        try:
415            for file_path in file_paths:
416                self.p4.run_revert("-c", change_list_number, file_path)
417                logger.info(f"파일 '{file_path}'를 체인지 리스트 {change_list_number}에서 되돌리기 성공.")
418            return True
419        except P4Exception as e:
420            self._handle_p4_exception(e, f"체인지 리스트 {change_list_number}에서 파일 되돌리기")
421            return False
422
423    def check_update_required(self, file_paths: list) -> bool:
424        """파일이나 폴더의 업데이트 필요 여부를 확인합니다.
425
426        Args:
427            file_paths (list): 확인할 파일 또는 폴더 경로 리스트. 
428                              폴더 경로는 자동으로 재귀적으로 처리됩니다.
429
430        Returns:
431            bool: 업데이트가 필요한 파일이 있으면 True, 없으면 False
432        """
433        if not self._is_connected():
434            return False
435        if not file_paths:
436            logger.debug("업데이트 필요 여부 확인할 파일/폴더 목록이 비어있습니다.")
437            return False
438        
439        # 폴더 경로에 재귀적 와일드카드 패턴을 추가
440        processed_paths = []
441        for path in file_paths:
442            if os.path.isdir(path):
443                # 폴더 경로에 '...'(재귀) 패턴을 추가
444                processed_paths.append(os.path.join(path, '...'))
445                logger.debug(f"폴더 경로를 재귀 패턴으로 변환: {path} -> {os.path.join(path, '...')}")
446            else:
447                processed_paths.append(path)
448        
449        logger.debug(f"파일/폴더 업데이트 필요 여부 확인 중 (항목 {len(processed_paths)}개): {processed_paths}")
450        try:
451            sync_preview_results = self.p4.run_sync("-n", processed_paths)
452            needs_update = False
453            for result in sync_preview_results:
454                if isinstance(result, dict):
455                    if 'up-to-date' not in result.get('how', '') and \
456                       'no such file(s)' not in result.get('depotFile', ''):
457                        if result.get('how') and 'syncing' in result.get('how'):
458                            needs_update = True
459                            logger.info(f"파일 '{result.get('clientFile', result.get('depotFile'))}' 업데이트 필요: {result.get('how')}")
460                            break
461                        elif result.get('action') and result.get('action') not in ['checked', 'exists']:
462                            needs_update = True
463                            logger.info(f"파일 '{result.get('clientFile', result.get('depotFile'))}' 업데이트 필요 (action: {result.get('action')})")
464                            break
465                elif isinstance(result, str):
466                    if "up-to-date" not in result and "no such file(s)" not in result:
467                        needs_update = True
468                        logger.info(f"파일 업데이트 필요 (문자열 결과): {result}")
469                        break
470            
471            if needs_update:
472                logger.info(f"지정된 파일/폴더 중 업데이트가 필요한 파일이 있습니다.")
473            else:
474                logger.info(f"지정된 모든 파일/폴더가 최신 상태입니다.")
475            return needs_update
476        except P4Exception as e:
477            self._handle_p4_exception(e, f"파일/폴더 업데이트 필요 여부 확인 ({processed_paths})")
478            return False
479
480    def sync_files(self, file_paths: list) -> bool:
481        """파일이나 폴더를 동기화합니다.
482
483        Args:
484            file_paths (list): 동기화할 파일 또는 폴더 경로 리스트.
485                             폴더 경로는 자동으로 재귀적으로 처리됩니다.
486
487        Returns:
488            bool: 동기화 성공 시 True, 실패 시 False
489        """
490        if not self._is_connected():
491            return False
492        if not file_paths:
493            logger.debug("싱크할 파일/폴더 목록이 비어있습니다.")
494            return True
495        
496        # 폴더 경로에 재귀적 와일드카드 패턴을 추가
497        processed_paths = []
498        for path in file_paths:
499            if os.path.isdir(path):
500                # 폴더 경로에 '...'(재귀) 패턴을 추가
501                processed_paths.append(os.path.join(path, '...'))
502                logger.debug(f"폴더 경로를 재귀 패턴으로 변환: {path} -> {os.path.join(path, '...')}")
503            else:
504                processed_paths.append(path)
505        
506        logger.info(f"파일/폴더 싱크 시도 (항목 {len(processed_paths)}개): {processed_paths}")
507        try:
508            self.p4.run_sync(processed_paths)
509            logger.info(f"파일/폴더 싱크 완료: {processed_paths}")
510            return True
511        except P4Exception as e:
512            self._handle_p4_exception(e, f"파일/폴더 싱크 ({processed_paths})")
513            return False
514
515    def disconnect(self):
516        """Perforce 서버와의 연결을 해제합니다."""
517        if self.connected:
518            try:
519                self.p4.disconnect()
520                self.connected = False
521                logger.info("Perforce 서버 연결 해제 완료.")
522            except P4Exception as e:
523                self._handle_p4_exception(e, "Perforce 서버 연결 해제")
524        else:
525            logger.debug("Perforce 서버에 이미 연결되지 않은 상태입니다.")
526
527    def __del__(self):
528        """객체가 소멸될 때 자동으로 연결을 해제합니다."""
529        self.disconnect()

P4Python을 사용하여 Perforce 작업을 수행하는 클래스.

Perforce()
31    def __init__(self):
32        """Perforce 인스턴스를 초기화합니다."""
33        self.p4 = P4()
34        self.connected = False
35        self.workspaceRoot = r""
36        logger.info("Perforce 인스턴스 생성됨")

Perforce 인스턴스를 초기화합니다.

p4
connected
workspaceRoot
def connect(self, workspace_name: str) -> bool:
62    def connect(self, workspace_name: str) -> bool:
63        """지정된 워크스페이스에 연결합니다.
64
65        Args:
66            workspace_name (str): 연결할 워크스페이스 이름
67
68        Returns:
69            bool: 연결 성공 시 True, 실패 시 False
70        """
71        logger.info(f"'{workspace_name}' 워크스페이스에 연결 시도 중...")
72        try:
73            self.p4.client = workspace_name
74            self.p4.connect()
75            self.connected = True
76            
77            # 워크스페이스 루트 경로 가져오기
78            try:
79                client_info = self.p4.run_client("-o", workspace_name)[0]
80                root_path = client_info.get("Root", "")
81                
82                # Windows 경로 형식으로 변환 (슬래시를 백슬래시로)
83                root_path = os.path.normpath(root_path)
84                
85                self.workspaceRoot = root_path
86                logger.info(f"워크스페이스 루트 절대 경로: {self.workspaceRoot}")
87            except (IndexError, KeyError) as e:
88                logger.error(f"워크스페이스 루트 경로 가져오기 실패: {e}")
89                self.workspaceRoot = ""
90                
91            logger.info(f"'{workspace_name}' 워크스페이스에 성공적으로 연결됨 (User: {self.p4.user}, Port: {self.p4.port})")
92            return True
93        except P4Exception as e:
94            self.connected = False
95            self._handle_p4_exception(e, f"'{workspace_name}' 워크스페이스 연결")
96            return False

지정된 워크스페이스에 연결합니다.

Args: workspace_name (str): 연결할 워크스페이스 이름

Returns: bool: 연결 성공 시 True, 실패 시 False

def get_pending_change_list(self) -> list:
 98    def get_pending_change_list(self) -> list:
 99        """워크스페이스의 Pending된 체인지 리스트를 가져옵니다.
100
101        Returns:
102            list: 체인지 리스트 정보 딕셔너리들의 리스트
103        """
104        if not self._is_connected():
105            return []
106        logger.debug("Pending 체인지 리스트 조회 중...")
107        try:
108            pending_changes = self.p4.run_changes("-s", "pending", "-u", self.p4.user, "-c", self.p4.client)
109            change_numbers = [int(cl['change']) for cl in pending_changes]
110            
111            # 각 체인지 리스트 번호에 대한 상세 정보 가져오기
112            change_list_info = []
113            for change_number in change_numbers:
114                cl_info = self.get_change_list_by_number(change_number)
115                if cl_info:
116                    change_list_info.append(cl_info)
117            
118            logger.info(f"Pending 체인지 리스트 {len(change_list_info)}개 조회 완료")
119            return change_list_info
120        except P4Exception as e:
121            self._handle_p4_exception(e, "Pending 체인지 리스트 조회")
122            return []

워크스페이스의 Pending된 체인지 리스트를 가져옵니다.

Returns: list: 체인지 리스트 정보 딕셔너리들의 리스트

def create_change_list(self, description: str) -> dict:
124    def create_change_list(self, description: str) -> dict:
125        """새로운 체인지 리스트를 생성합니다.
126
127        Args:
128            description (str): 체인지 리스트 설명
129
130        Returns:
131            dict: 생성된 체인지 리스트 정보. 실패 시 빈 딕셔너리
132        """
133        if not self._is_connected():
134            return {}
135        logger.info(f"새 체인지 리스트 생성 시도: '{description}'")
136        try:
137            change_spec = self.p4.fetch_change()
138            change_spec["Description"] = description
139            result = self.p4.save_change(change_spec)
140            created_change_number = int(result[0].split()[1])
141            logger.info(f"체인지 리스트 {created_change_number} 생성 완료: '{description}'")
142            return self.get_change_list_by_number(created_change_number)
143        except P4Exception as e:
144            self._handle_p4_exception(e, f"체인지 리스트 생성 ('{description}')")
145            return {}
146        except (IndexError, ValueError) as e:
147            logger.error(f"체인지 리스트 번호 파싱 오류: {e}")
148            return {}

새로운 체인지 리스트를 생성합니다.

Args: description (str): 체인지 리스트 설명

Returns: dict: 생성된 체인지 리스트 정보. 실패 시 빈 딕셔너리

def get_change_list_by_number(self, change_list_number: int) -> dict:
150    def get_change_list_by_number(self, change_list_number: int) -> dict:
151        """체인지 리스트 번호로 체인지 리스트를 가져옵니다.
152
153        Args:
154            change_list_number (int): 체인지 리스트 번호
155
156        Returns:
157            dict: 체인지 리스트 정보. 실패 시 빈 딕셔너리
158        """
159        if not self._is_connected():
160            return {}
161        logger.debug(f"체인지 리스트 {change_list_number} 정보 조회 중...")
162        try:
163            cl_info = self.p4.fetch_change(change_list_number)
164            if cl_info:
165                logger.info(f"체인지 리스트 {change_list_number} 정보 조회 완료.")
166                return cl_info
167            else:
168                logger.warning(f"체인지 리스트 {change_list_number}를 찾을 수 없습니다.")
169                return {}
170        except P4Exception as e:
171            self._handle_p4_exception(e, f"체인지 리스트 {change_list_number} 정보 조회")
172            return {}

체인지 리스트 번호로 체인지 리스트를 가져옵니다.

Args: change_list_number (int): 체인지 리스트 번호

Returns: dict: 체인지 리스트 정보. 실패 시 빈 딕셔너리

def get_change_list_by_description(self, description: str) -> dict:
174    def get_change_list_by_description(self, description: str) -> dict:
175        """체인지 리스트 설명으로 체인지 리스트를 가져옵니다.
176
177        Args:
178            description (str): 체인지 리스트 설명
179
180        Returns:
181            dict: 체인지 리스트 정보 (일치하는 첫 번째 체인지 리스트)
182        """
183        if not self._is_connected():
184            return {}
185        logger.debug(f"설명으로 체인지 리스트 조회 중: '{description}'")
186        try:
187            pending_changes = self.p4.run_changes("-l", "-s", "pending", "-u", self.p4.user, "-c", self.p4.client)
188            for cl in pending_changes:
189                cl_desc = cl.get('Description', b'').decode('utf-8', 'replace').strip()
190                if cl_desc == description.strip():
191                    logger.info(f"설명 '{description}'에 해당하는 체인지 리스트 {cl['change']} 조회 완료.")
192                    return self.get_change_list_by_number(int(cl['change']))
193            logger.info(f"설명 '{description}'에 해당하는 Pending 체인지 리스트를 찾을 수 없습니다.")
194            return {}
195        except P4Exception as e:
196            self._handle_p4_exception(e, f"설명으로 체인지 리스트 조회 ('{description}')")
197            return {}

체인지 리스트 설명으로 체인지 리스트를 가져옵니다.

Args: description (str): 체인지 리스트 설명

Returns: dict: 체인지 리스트 정보 (일치하는 첫 번째 체인지 리스트)

def edit_change_list( self, change_list_number: int, description: str = None, add_file_paths: list = None, remove_file_paths: list = None) -> dict:
199    def edit_change_list(self, change_list_number: int, description: str = None, add_file_paths: list = None, remove_file_paths: list = None) -> dict:
200        """체인지 리스트를 편집합니다.
201
202        Args:
203            change_list_number (int): 체인지 리스트 번호
204            description (str, optional): 변경할 설명
205            add_file_paths (list, optional): 추가할 파일 경로 리스트
206            remove_file_paths (list, optional): 제거할 파일 경로 리스트
207
208        Returns:
209            dict: 업데이트된 체인지 리스트 정보
210        """
211        if not self._is_connected():
212            return {}
213        logger.info(f"체인지 리스트 {change_list_number} 편집 시도...")
214        try:
215            if description is not None:
216                change_spec = self.p4.fetch_change(change_list_number)
217                current_description = change_spec.get('Description', '').strip()
218                if current_description != description.strip():
219                    change_spec['Description'] = description
220                    self.p4.save_change(change_spec)
221                    logger.info(f"체인지 리스트 {change_list_number} 설명 변경 완료: '{description}'")
222
223            if add_file_paths:
224                for file_path in add_file_paths:
225                    try:
226                        self.p4.run_reopen("-c", change_list_number, file_path)
227                        logger.info(f"파일 '{file_path}'를 체인지 리스트 {change_list_number}로 이동 완료.")
228                    except P4Exception as e_reopen:
229                        self._handle_p4_exception(e_reopen, f"파일 '{file_path}'을 CL {change_list_number}로 이동")
230
231            if remove_file_paths:
232                for file_path in remove_file_paths:
233                    try:
234                        self.p4.run_revert("-c", change_list_number, file_path)
235                        logger.info(f"파일 '{file_path}'를 체인지 리스트 {change_list_number}에서 제거(revert) 완료.")
236                    except P4Exception as e_revert:
237                        self._handle_p4_exception(e_revert, f"파일 '{file_path}'을 CL {change_list_number}에서 제거(revert)")
238
239            return self.get_change_list_by_number(change_list_number)
240
241        except P4Exception as e:
242            self._handle_p4_exception(e, f"체인지 리스트 {change_list_number} 편집")
243            return self.get_change_list_by_number(change_list_number)

체인지 리스트를 편집합니다.

Args: change_list_number (int): 체인지 리스트 번호 description (str, optional): 변경할 설명 add_file_paths (list, optional): 추가할 파일 경로 리스트 remove_file_paths (list, optional): 제거할 파일 경로 리스트

Returns: dict: 업데이트된 체인지 리스트 정보

def checkout_file(self, file_path: str, change_list_number: int) -> bool:
276    def checkout_file(self, file_path: str, change_list_number: int) -> bool:
277        """파일을 체크아웃합니다.
278
279        Args:
280            file_path (str): 체크아웃할 파일 경로
281            change_list_number (int): 체인지 리스트 번호
282
283        Returns:
284            bool: 체크아웃 성공 시 True, 실패 시 False
285        """
286        return self._file_op("edit", file_path, change_list_number, "체크아웃")

파일을 체크아웃합니다.

Args: file_path (str): 체크아웃할 파일 경로 change_list_number (int): 체인지 리스트 번호

Returns: bool: 체크아웃 성공 시 True, 실패 시 False

def add_file(self, file_path: str, change_list_number: int) -> bool:
288    def add_file(self, file_path: str, change_list_number: int) -> bool:
289        """파일을 추가합니다.
290
291        Args:
292            file_path (str): 추가할 파일 경로
293            change_list_number (int): 체인지 리스트 번호
294
295        Returns:
296            bool: 추가 성공 시 True, 실패 시 False
297        """
298        return self._file_op("add", file_path, change_list_number, "추가")

파일을 추가합니다.

Args: file_path (str): 추가할 파일 경로 change_list_number (int): 체인지 리스트 번호

Returns: bool: 추가 성공 시 True, 실패 시 False

def delete_file(self, file_path: str, change_list_number: int) -> bool:
300    def delete_file(self, file_path: str, change_list_number: int) -> bool:
301        """파일을 삭제합니다.
302
303        Args:
304            file_path (str): 삭제할 파일 경로
305            change_list_number (int): 체인지 리스트 번호
306
307        Returns:
308            bool: 삭제 성공 시 True, 실패 시 False
309        """
310        return self._file_op("delete", file_path, change_list_number, "삭제")

파일을 삭제합니다.

Args: file_path (str): 삭제할 파일 경로 change_list_number (int): 체인지 리스트 번호

Returns: bool: 삭제 성공 시 True, 실패 시 False

def submit_change_list(self, change_list_number: int) -> bool:
312    def submit_change_list(self, change_list_number: int) -> bool:
313        """체인지 리스트를 제출합니다.
314
315        Args:
316            change_list_number (int): 제출할 체인지 리스트 번호
317
318        Returns:
319            bool: 제출 성공 시 True, 실패 시 False
320        """
321        if not self._is_connected():
322            return False
323        logger.info(f"체인지 리스트 {change_list_number} 제출 시도...")
324        try:
325            self.p4.run_submit("-c", change_list_number)
326            logger.info(f"체인지 리스트 {change_list_number} 제출 성공.")
327            return True
328        except P4Exception as e:
329            self._handle_p4_exception(e, f"체인지 리스트 {change_list_number} 제출")
330            if any("nothing to submit" in err.lower() for err in self.p4.errors):
331                logger.warning(f"체인지 리스트 {change_list_number}에 제출할 파일이 없습니다.")
332            return False

체인지 리스트를 제출합니다.

Args: change_list_number (int): 제출할 체인지 리스트 번호

Returns: bool: 제출 성공 시 True, 실패 시 False

def revert_change_list(self, change_list_number: int) -> bool:
334    def revert_change_list(self, change_list_number: int) -> bool:
335        """체인지 리스트를 되돌리고 삭제합니다.
336
337        체인지 리스트 내 모든 파일을 되돌린 후 빈 체인지 리스트를 삭제합니다.
338
339        Args:
340            change_list_number (int): 되돌릴 체인지 리스트 번호
341
342        Returns:
343            bool: 되돌리기 및 삭제 성공 시 True, 실패 시 False
344        """
345        if not self._is_connected():
346            return False
347        logger.info(f"체인지 리스트 {change_list_number} 전체 되돌리기 및 삭제 시도...")
348        try:
349            # 체인지 리스트의 모든 파일 되돌리기
350            self.p4.run_revert("-c", change_list_number, "//...")
351            logger.info(f"체인지 리스트 {change_list_number} 전체 되돌리기 성공.")
352            
353            # 빈 체인지 리스트 삭제
354            try:
355                self.p4.run_change("-d", change_list_number)
356                logger.info(f"체인지 리스트 {change_list_number} 삭제 완료.")
357            except P4Exception as e_delete:
358                self._handle_p4_exception(e_delete, f"체인지 리스트 {change_list_number} 삭제")
359                logger.warning(f"파일 되돌리기는 성공했으나 체인지 리스트 {change_list_number} 삭제에 실패했습니다.")
360                return False
361                
362            return True
363        except P4Exception as e:
364            self._handle_p4_exception(e, f"체인지 리스트 {change_list_number} 전체 되돌리기")
365            return False

체인지 리스트를 되돌리고 삭제합니다.

체인지 리스트 내 모든 파일을 되돌린 후 빈 체인지 리스트를 삭제합니다.

Args: change_list_number (int): 되돌릴 체인지 리스트 번호

Returns: bool: 되돌리기 및 삭제 성공 시 True, 실패 시 False

def delete_empty_change_list(self, change_list_number: int) -> bool:
367    def delete_empty_change_list(self, change_list_number: int) -> bool:
368        """빈 체인지 리스트를 삭제합니다.
369
370        Args:
371            change_list_number (int): 삭제할 체인지 리스트 번호
372
373        Returns:
374            bool: 삭제 성공 시 True, 실패 시 False
375        """
376        if not self._is_connected():
377            return False
378        
379        logger.info(f"체인지 리스트 {change_list_number} 삭제 시도 중...")
380        try:
381            # 체인지 리스트 정보 가져오기
382            change_spec = self.p4.fetch_change(change_list_number)
383            
384            # 파일이 있는지 확인
385            if change_spec and change_spec.get('Files') and len(change_spec['Files']) > 0:
386                logger.warning(f"체인지 리스트 {change_list_number}에 파일이 {len(change_spec['Files'])}개 있어 삭제할 수 없습니다.")
387                return False
388            
389            # 빈 체인지 리스트 삭제
390            self.p4.run_change("-d", change_list_number)
391            logger.info(f"빈 체인지 리스트 {change_list_number} 삭제 완료.")
392            return True
393        except P4Exception as e:
394            self._handle_p4_exception(e, f"체인지 리스트 {change_list_number} 삭제")
395            return False

빈 체인지 리스트를 삭제합니다.

Args: change_list_number (int): 삭제할 체인지 리스트 번호

Returns: bool: 삭제 성공 시 True, 실패 시 False

def revert_files(self, change_list_number: int, file_paths: list) -> bool:
397    def revert_files(self, change_list_number: int, file_paths: list) -> bool:
398        """체인지 리스트 내의 특정 파일들을 되돌립니다.
399
400        Args:
401            change_list_number (int): 체인지 리스트 번호
402            file_paths (list): 되돌릴 파일 경로 리스트
403
404        Returns:
405            bool: 되돌리기 성공 시 True, 실패 시 False
406        """
407        if not self._is_connected():
408            return False
409        if not file_paths:
410            logger.warning("되돌릴 파일 목록이 비어있습니다.")
411            return True
412            
413        logger.info(f"체인지 리스트 {change_list_number}에서 {len(file_paths)}개 파일 되돌리기 시도...")
414        try:
415            for file_path in file_paths:
416                self.p4.run_revert("-c", change_list_number, file_path)
417                logger.info(f"파일 '{file_path}'를 체인지 리스트 {change_list_number}에서 되돌리기 성공.")
418            return True
419        except P4Exception as e:
420            self._handle_p4_exception(e, f"체인지 리스트 {change_list_number}에서 파일 되돌리기")
421            return False

체인지 리스트 내의 특정 파일들을 되돌립니다.

Args: change_list_number (int): 체인지 리스트 번호 file_paths (list): 되돌릴 파일 경로 리스트

Returns: bool: 되돌리기 성공 시 True, 실패 시 False

def check_update_required(self, file_paths: list) -> bool:
423    def check_update_required(self, file_paths: list) -> bool:
424        """파일이나 폴더의 업데이트 필요 여부를 확인합니다.
425
426        Args:
427            file_paths (list): 확인할 파일 또는 폴더 경로 리스트. 
428                              폴더 경로는 자동으로 재귀적으로 처리됩니다.
429
430        Returns:
431            bool: 업데이트가 필요한 파일이 있으면 True, 없으면 False
432        """
433        if not self._is_connected():
434            return False
435        if not file_paths:
436            logger.debug("업데이트 필요 여부 확인할 파일/폴더 목록이 비어있습니다.")
437            return False
438        
439        # 폴더 경로에 재귀적 와일드카드 패턴을 추가
440        processed_paths = []
441        for path in file_paths:
442            if os.path.isdir(path):
443                # 폴더 경로에 '...'(재귀) 패턴을 추가
444                processed_paths.append(os.path.join(path, '...'))
445                logger.debug(f"폴더 경로를 재귀 패턴으로 변환: {path} -> {os.path.join(path, '...')}")
446            else:
447                processed_paths.append(path)
448        
449        logger.debug(f"파일/폴더 업데이트 필요 여부 확인 중 (항목 {len(processed_paths)}개): {processed_paths}")
450        try:
451            sync_preview_results = self.p4.run_sync("-n", processed_paths)
452            needs_update = False
453            for result in sync_preview_results:
454                if isinstance(result, dict):
455                    if 'up-to-date' not in result.get('how', '') and \
456                       'no such file(s)' not in result.get('depotFile', ''):
457                        if result.get('how') and 'syncing' in result.get('how'):
458                            needs_update = True
459                            logger.info(f"파일 '{result.get('clientFile', result.get('depotFile'))}' 업데이트 필요: {result.get('how')}")
460                            break
461                        elif result.get('action') and result.get('action') not in ['checked', 'exists']:
462                            needs_update = True
463                            logger.info(f"파일 '{result.get('clientFile', result.get('depotFile'))}' 업데이트 필요 (action: {result.get('action')})")
464                            break
465                elif isinstance(result, str):
466                    if "up-to-date" not in result and "no such file(s)" not in result:
467                        needs_update = True
468                        logger.info(f"파일 업데이트 필요 (문자열 결과): {result}")
469                        break
470            
471            if needs_update:
472                logger.info(f"지정된 파일/폴더 중 업데이트가 필요한 파일이 있습니다.")
473            else:
474                logger.info(f"지정된 모든 파일/폴더가 최신 상태입니다.")
475            return needs_update
476        except P4Exception as e:
477            self._handle_p4_exception(e, f"파일/폴더 업데이트 필요 여부 확인 ({processed_paths})")
478            return False

파일이나 폴더의 업데이트 필요 여부를 확인합니다.

Args: file_paths (list): 확인할 파일 또는 폴더 경로 리스트. 폴더 경로는 자동으로 재귀적으로 처리됩니다.

Returns: bool: 업데이트가 필요한 파일이 있으면 True, 없으면 False

def sync_files(self, file_paths: list) -> bool:
480    def sync_files(self, file_paths: list) -> bool:
481        """파일이나 폴더를 동기화합니다.
482
483        Args:
484            file_paths (list): 동기화할 파일 또는 폴더 경로 리스트.
485                             폴더 경로는 자동으로 재귀적으로 처리됩니다.
486
487        Returns:
488            bool: 동기화 성공 시 True, 실패 시 False
489        """
490        if not self._is_connected():
491            return False
492        if not file_paths:
493            logger.debug("싱크할 파일/폴더 목록이 비어있습니다.")
494            return True
495        
496        # 폴더 경로에 재귀적 와일드카드 패턴을 추가
497        processed_paths = []
498        for path in file_paths:
499            if os.path.isdir(path):
500                # 폴더 경로에 '...'(재귀) 패턴을 추가
501                processed_paths.append(os.path.join(path, '...'))
502                logger.debug(f"폴더 경로를 재귀 패턴으로 변환: {path} -> {os.path.join(path, '...')}")
503            else:
504                processed_paths.append(path)
505        
506        logger.info(f"파일/폴더 싱크 시도 (항목 {len(processed_paths)}개): {processed_paths}")
507        try:
508            self.p4.run_sync(processed_paths)
509            logger.info(f"파일/폴더 싱크 완료: {processed_paths}")
510            return True
511        except P4Exception as e:
512            self._handle_p4_exception(e, f"파일/폴더 싱크 ({processed_paths})")
513            return False

파일이나 폴더를 동기화합니다.

Args: file_paths (list): 동기화할 파일 또는 폴더 경로 리스트. 폴더 경로는 자동으로 재귀적으로 처리됩니다.

Returns: bool: 동기화 성공 시 True, 실패 시 False

def disconnect(self):
515    def disconnect(self):
516        """Perforce 서버와의 연결을 해제합니다."""
517        if self.connected:
518            try:
519                self.p4.disconnect()
520                self.connected = False
521                logger.info("Perforce 서버 연결 해제 완료.")
522            except P4Exception as e:
523                self._handle_p4_exception(e, "Perforce 서버 연결 해제")
524        else:
525            logger.debug("Perforce 서버에 이미 연결되지 않은 상태입니다.")

Perforce 서버와의 연결을 해제합니다.