"""SourceMixin — source code read/write, lock/unlock, activate, syntax-check.""" from __future__ import annotations import logging import re import xml.etree.ElementTree as ET from sapcli.exceptions import ( ActivationError, LockError, SyntaxCheckError, ) logger = logging.getLogger("sapcli.client") class SourceMixin: """Source-code read/write and lock management.""" # ------------------------------------------------------------------ # Source read / write # ------------------------------------------------------------------ def get_source(self, src_uri: str) -> str: url = f"{self.host}{src_uri}" hdrs = self._headers() hdrs["Accept"] = "text/plain" logger.info("GET SOURCE: GET %s", url) resp = self.session.get(url, headers=hdrs) logger.info( "GET SOURCE RESPONSE: HTTP %s, %d bytes", resp.status_code, len(resp.content) if resp.content else 0, ) resp.raise_for_status() return resp.text def set_source( self, src_uri: str, source: str, lock_handle: str, corr_nr: str | None = None, ) -> bool: params: dict[str, str] = {} if lock_handle: params["lockHandle"] = lock_handle if corr_nr: params["corrNr"] = corr_nr url = f"{self.host}{src_uri}" hdrs = self._headers("text/plain; charset=utf-8") logger.info("SET SOURCE: PUT %s (%d chars)", url, len(source)) resp = self.session.put( url, headers=hdrs, params=params, data=source.encode("utf-8") ) logger.info("SET SOURCE RESPONSE: HTTP %s", resp.status_code) if resp.status_code >= 400: logger.error("SET SOURCE ERROR BODY: %s", resp.text[:2000]) existing_nr = self._extract_locked_corrnr(resp.text) if existing_nr and existing_nr != corr_nr: logger.info("使用 corrNr=%s 重试 SET SOURCE", existing_nr) params["corrNr"] = existing_nr resp = self.session.put( url, headers=hdrs, params=params, data=source.encode("utf-8") ) logger.info("SET SOURCE RETRY RESPONSE: HTTP %s", resp.status_code) if resp.status_code >= 400: resp.raise_for_status() return True # ------------------------------------------------------------------ # Lock / unlock # ------------------------------------------------------------------ def lock( self, obj_uri: str, corr_nr: str | None = None, accept: str | None = None, ) -> tuple[str, str | None]: """锁定对象。 Returns: (lock_handle, effective_corr_nr) — 实际使用的传输请求号。 如果对象已绑定在某请求中,effective_corrnr 为该请求号; 否则为传入的 corr_nr(可能为 None)。 """ self._stateful = True url = f"{self.host}{obj_uri}" params: dict[str, str] = {"_action": "LOCK", "accessMode": "MODIFY"} effective_corr_nr = corr_nr if corr_nr: params["corrNr"] = corr_nr hdrs = self._headers() hdrs["Accept"] = accept or "*/*" logger.info("LOCK: POST %s (corrNr=%s)", url, corr_nr or "none") resp = self.session.post(url, headers=hdrs, params=params) logger.info("LOCK RESPONSE: HTTP %s", resp.status_code) if resp.status_code == 500: existing_nr = self._extract_locked_corrnr(resp.text) if existing_nr: effective_corr_nr = existing_nr logger.info("对象已锁定在请求 %s 中,使用该请求重试", existing_nr) params["corrNr"] = existing_nr resp = self.session.post(url, headers=hdrs, params=params, data="") logger.info("LOCK RETRY RESPONSE: HTTP %s", resp.status_code) if resp.status_code >= 400: raise LockError(f"锁定失败: HTTP {resp.status_code}", obj_uri=obj_uri) root = ET.fromstring(resp.content) handle = root.findtext(".//LOCK_HANDLE") if not handle: for el in root.iter(): if "HANDLE" in el.tag.upper(): handle = el.text break if not handle: raise LockError("锁定失败: 未获取到 lock handle", obj_uri=obj_uri) # 从成功响应中提取 corrNr(对象可能已绑定传输请求) if not effective_corr_nr: extracted = self._extract_corrnr_from_lock_response(root) if extracted: effective_corr_nr = extracted logger.info("从 lock 响应中检测到 corrNr: %s", extracted) logger.info( "Lock handle: %s (corrNr=%s)", handle[:20] if len(handle) > 20 else handle, effective_corr_nr or "none", ) return handle, effective_corr_nr def _extract_locked_corrnr(self, error_body: str) -> str | None: try: root = ET.fromstring(error_body) for entry in root.iter(): if entry.attrib.get("key") == "corrNr": corrnr = entry.text if corrnr and corrnr != "*": return corrnr for el in root.iter(): if el.tag.endswith("message") and el.text: m = re.search(r"request\s+(\w+)", el.text, re.IGNORECASE) if m: return m.group(1) except Exception: logger.debug("XML 解析提取 locked corrnr 失败", exc_info=True) return None def _extract_corrnr_from_lock_response(self, root: ET.Element) -> str | None: """从成功的 lock 响应 XML 中提取传输请求号。 常见格式: ... DEVK901362 也可能是: DEVK901362 """ # 策略 1: 直接找 CORRN 标签 for tag_name in ("CORRN", "corrNr", "corr_nr"): text = root.findtext(f".//{tag_name}") if text and text.strip() and text.strip() != "*": return text.strip() # 策略 2: 遍历所有元素,找标签或属性包含 corrNr 的 for el in root.iter(): # 检查属性 for attr_key in el.attrib: if "corr" in attr_key.lower() and "nr" in attr_key.lower(): val = el.attrib[attr_key] if val and val.strip() and val.strip() != "*": return val.strip() # 检查标签名包含 CORRN if el.text and "corr" in el.tag.lower() and el.text.strip() and el.text.strip() != "*": return el.text.strip() # 策略 3: 找 key="corrNr" 的 property 元素 for el in root.iter(): if el.attrib.get("key") == "corrNr" and el.text: val = el.text.strip() if val and val != "*": return val return None def unlock(self, obj_uri: str, lock_handle: str) -> bool: url = f"{self.host}{obj_uri}" params = {"_action": "UNLOCK", "lockHandle": lock_handle} hdrs = self._headers("text/plain; charset=utf-8") logger.info("UNLOCK: POST %s", url) resp = self.session.post(url, headers=hdrs, params=params, data="") logger.info("UNLOCK RESPONSE: HTTP %s", resp.status_code) self._stateful = False return resp.status_code == 200 # ------------------------------------------------------------------ # Activate / syntax check # ------------------------------------------------------------------ def activate( self, name: str, obj_uri: str, corr_nr: str | None = None, ) -> tuple[bool, list[dict[str, str]]]: body = ( '' '' f'' "" ) self._stateful = False url = f"{self.host}/sap/bc/adt/activation" params: dict[str, str] = {"method": "activate"} if corr_nr: params["corrNr"] = corr_nr hdrs = self._headers() logger.info("ACTIVATE: POST %s name=%s corrNr=%s", url, name, corr_nr or "none") resp = self.session.post(url, headers=hdrs, params=params, data=body) logger.info( "ACTIVATE RESPONSE: HTTP %s CT=%s", resp.status_code, resp.headers.get("content-type", "")[:80], ) if resp.status_code != 200: logger.error("Activation HTTP %s", resp.status_code) raise ActivationError(f"激活失败: HTTP {resp.status_code}") ct = resp.headers.get("content-type", "") if "inactivectsobjects" in ct: root = ET.fromstring(resp.content) ioc_ns = "http://www.sap.com/abapxml/inactiveCtsObjects" inactive_objects: list[str] = [] for entry in root.findall(f".//{{{ioc_ns}}}entry"): obj_elem = entry.find(f"{{{ioc_ns}}}object") if obj_elem is not None: ref = obj_elem.find(".//{http://www.sap.com/adt/core}ref") if ref is None: for child in obj_elem: if child.tag.endswith("}ref"): ref = child break if ref is not None: inactive_name = ref.attrib.get( "{http://www.sap.com/adt/core}name", "" ) inactive_type = ref.attrib.get( "{http://www.sap.com/adt/core}type", "" ) if inactive_name: inactive_objects.append(f"{inactive_name} ({inactive_type})") if inactive_objects: logger.warning("激活返回未激活对象: %s", ", ".join(inactive_objects)) msg_text = f"Objects still inactive: {', '.join(inactive_objects)}" messages: list[dict[str, str]] = [ {"type": "E", "line": "?", "text": msg_text, "href": ""} ] logger.info("Activation result: FAILED (inactive objects)") return False, messages logger.info("Activation result: SUCCESS (inactiveObjects 响应但无对象列出)") return True, [] if not resp.content or not resp.text.strip(): # NW 7.40 返回空响应但可能并未真正激活 # 验证对象实际状态 logger.info("Activation returned empty response, verifying object status...") try: verify_resp = self.session.get( f"{self.host}{obj_uri}", headers=self._headers(), ) if verify_resp.status_code == 200: vroot = ET.fromstring(verify_resp.content) version = vroot.attrib.get("{http://www.sap.com/adt/core}version", "") if version == "inactive": # NW 7.40: first activation may only "stage" the change. # Retry activation once, then re-verify. logger.info("Object still inactive, retrying activation (NW 7.40 double-activate workaround)...") retry_resp = self.session.post( url, headers=hdrs, params=params, data=body, ) logger.info( "RETRY ACTIVATE: HTTP %s len=%s", retry_resp.status_code, len(retry_resp.content), ) if retry_resp.status_code == 200: # Re-verify after retry verify_resp2 = self.session.get( f"{self.host}{obj_uri}", headers=self._headers(), ) if verify_resp2.status_code == 200: vroot2 = ET.fromstring(verify_resp2.content) version2 = vroot2.attrib.get( "{http://www.sap.com/adt/core}version", "" ) if version2 == "active": logger.info("Retry activation succeeded!") return True, [] logger.warning("Object still inactive after retry (NW 7.40)") messages = [ { "type": "W", "line": "?", "text": "ADT 激活返回空响应,对象仍为 inactive(NW 7.40 已知限制)。请在 SAP GUI SE09 手动激活。", "href": "", } ] return False, messages elif version == "active": logger.info("Verified: object is active after activation") except Exception as e: logger.warning("Could not verify activation status: %s", e) logger.info("Activation success (空响应)") return True, [] root = ET.fromstring(resp.content) messages = [] for msg in root.iter(): msg_type = msg.attrib.get("type", "") if msg_type in ("E", "W", "I", "S"): line = msg.attrib.get("line", "?") href = msg.attrib.get("href", "") txt = "" for child in msg.iter(): if child.text and child.tag.endswith("}txt"): txt = child.text break if not txt: for child in msg.iter(): if child.text and len(child.text.strip()) > 3: txt = child.text.strip() break messages.append({"type": msg_type, "line": line, "text": txt, "href": href}) logger.info("Activation msg [%s] line=%s: %s", msg_type, line, txt) errors = [m for m in messages if m["type"] == "E"] success = len(errors) == 0 logger.info( "Activation result: %s (%d errors)", "SUCCESS" if success else "FAILED", len(errors), ) return success, messages def syntax_check( self, name: str, obj_uri: str, ) -> tuple[bool, list[dict[str, str]]]: body = ( '' '' f'' "" ) self._stateful = False url = f"{self.host}/sap/bc/adt/activation" params = {"method": "check"} hdrs = self._headers() logger.info("SYNTAX CHECK: POST %s name=%s", url, name) resp = self.session.post(url, headers=hdrs, params=params, data=body) logger.info("SYNTAX CHECK RESPONSE: HTTP %s", resp.status_code) if resp.status_code != 200: logger.error("Syntax check HTTP %s", resp.status_code) raise SyntaxCheckError(f"语法检查失败: HTTP {resp.status_code}") if not resp.content or not resp.text.strip(): logger.info("Syntax check: OK (空响应)") return True, [] root = ET.fromstring(resp.content) messages: list[dict[str, str]] = [] for msg in root.iter(): msg_type = msg.attrib.get("type", "") if msg_type in ("E", "W", "I", "S"): line = msg.attrib.get("line", "?") href = msg.attrib.get("href", "") txt = "" for child in msg.iter(): if child.text and child.tag.endswith("}txt"): txt = child.text break if not txt: for child in msg.iter(): if child.text and len(child.text.strip()) > 3: txt = child.text.strip() break messages.append({"type": msg_type, "line": line, "text": txt, "href": href}) logger.info("Syntax check msg [%s] line=%s: %s", msg_type, line, txt) errors = [m for m in messages if m["type"] == "E"] success = len(errors) == 0 logger.info( "Syntax check result: %s (%d errors)", "OK" if success else "ERRORS", len(errors), ) return success, messages