1################################################################ 2# 3# Licensed to the Apache Software Foundation (ASF) under one 4# or more contributor license agreements. See the NOTICE file 5# distributed with this work for additional information 6# regarding copyright ownership. The ASF licenses this file 7# to you under the Apache License, Version 2.0 (the 8# "License"); you may not use this file except in compliance 9# with the License. You may obtain a copy of the License at 10# 11# http://www.apache.org/licenses/LICENSE-2.0 12# 13# Unless required by applicable law or agreed to in writing, 14# software distributed under the License is distributed on an 15# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY 16# KIND, either express or implied. See the License for the 17# specific language governing permissions and limitations 18# under the License. 19# 20################################################################ 21 22"""Finds an installed AOO from a path the user gives, reads its version, and finds a Python that can import uno. 23 24A location may be an .app bundle, an install root, a program directory, or the soffice launcher itself. The platform 25is read from the files found (versionrc or version.ini, Contents/MacOS), not from the host, so a layout copied from 26another system is understood the same way. 27""" 28import configparser 29import glob 30import json 31import os 32import re 33import shutil 34import subprocess 35import sys 36from dataclasses import dataclass 37from pathlib import Path 38 39MIN_VERSION = (5, 0) 40MIN_WORKER_PYTHON = (3, 11) 41 42 43class LayoutError(Exception): 44 pass 45 46 47class UnsupportedVersion(LayoutError): 48 pass 49 50 51class NoUnoPython(Exception): 52 """No interpreter could import uno. `tried` is [(candidate, source, reason)]; `linked` is what libpyuno links, if known.""" 53 54 def __init__(self, message, tried=(), linked=None): 55 super().__init__(message) 56 self.tried, self.linked = list(tried), linked 57 58 59@dataclass(frozen=True) 60class Version: 61 base: str # OOOBaseVersion, e.g. "5.1" 62 package: str # OOOPackageVersion, e.g. "5.1.0" (may be "") 63 major: int 64 minor: int 65 buildid: str 66 revision: str 67 68 @property 69 def tuple(self): 70 return (self.major, self.minor) 71 72 73@dataclass(frozen=True) 74class Layout: 75 location: Path # what the user gave, symlinks resolved 76 program: Path 77 soffice: Path # the launcher to run 78 unopkg: Path # or None 79 version_file: Path 80 fundamental: Path 81 version: Version 82 platform: str # "macos", "posix" or "windows", from the files found 83 84 @property 85 def pyuno_module(self): 86 for n in ("pyuno.so", "pyuno.pyd"): 87 if (self.program / n).is_file(): 88 return self.program / n 89 return None 90 91 92def parse_version(path): 93 """Version of a versionrc or version.ini; raises LayoutError if it has none.""" 94 cp = configparser.RawConfigParser(interpolation=None) 95 cp.optionxform = str 96 try: 97 cp.read(path, encoding="utf-8") 98 sec = cp["Version"] 99 except (configparser.Error, KeyError, OSError) as e: 100 raise LayoutError("%s has no [Version] section: %s" % (path, e)) 101 base, major_minor = sec.get("OOOBaseVersion", ""), None 102 m = re.match(r"(\d+)\.(\d+)", base) 103 if m: 104 major_minor = (int(m.group(1)), int(m.group(2))) 105 elif sec.get("ProductMajor", "").isdigit(): 106 pm = int(sec["ProductMajor"]) 107 major_minor = (pm // 100, (pm // 10) % 10) 108 base = "%d.%d" % major_minor 109 if major_minor is None: 110 raise LayoutError("%s: no OOOBaseVersion or ProductMajor" % path) 111 return Version(base, sec.get("OOOPackageVersion", ""), major_minor[0], major_minor[1], sec.get("buildid", ""), sec.get("Revision", "")) 112 113 114def _program_dirs(d): 115 yield d 116 yield d / "program" 117 yield d / "Contents" / "program" 118 if d.name == "MacOS" or d.name == "Contents": 119 yield (d if d.name == "Contents" else d.parent) / "program" 120 121 122def find_layout(location): 123 """The Layout of the install at `location`; raises LayoutError, or UnsupportedVersion below 5.0.""" 124 p = Path(os.path.realpath(os.path.expanduser(str(location)))) 125 if not p.exists(): 126 raise LayoutError("no such file or directory: %s" % location) 127 start = p.parent if p.is_file() else p 128 program = vfile = None 129 for d in _program_dirs(start): 130 for n in ("versionrc", "version.ini"): 131 if (d / n).is_file(): 132 program, vfile = d, d / n 133 break 134 if program: 135 break 136 if program is None: 137 raise LayoutError("%s is not an OpenOffice install: no program/versionrc or version.ini found" % location) 138 version = parse_version(vfile) 139 if version.tuple < MIN_VERSION: 140 raise UnsupportedVersion("AOO %s found at %s; this suite needs 5.0 or later" % (version.base, location)) 141 windows = vfile.name == "version.ini" 142 macos = not windows and program.parent.name == "Contents" 143 launcher = [program.parent / "MacOS" / "soffice"] if macos else [] 144 launcher += [program / n for n in (("soffice.exe", "soffice.bin") if windows else ("soffice", "soffice.bin"))] 145 soffice = next((c for c in launcher if c.is_file()), None) 146 if soffice is None: 147 raise LayoutError("no soffice launcher in %s" % program) 148 unopkg = next((program / n for n in (("unopkg.com", "unopkg.exe") if windows else ("unopkg", "unopkg.bin")) if (program / n).is_file()), None) 149 fundamental = program / ("fundamental.ini" if windows else "fundamentalrc") 150 return Layout(p, program, soffice, unopkg, vfile, fundamental, version, "windows" if windows else "macos" if macos else "posix") 151 152 153def default_locations(environ=None): 154 """Where installs usually are (a convenience; the location is normally given explicitly).""" 155 env = os.environ if environ is None else environ 156 pats = ["/Applications/OpenOffice*.app", "/opt/openoffice*", "/usr/local/openoffice*"] 157 for var in ("ProgramFiles", "ProgramFiles(x86)"): 158 if env.get(var): 159 pats.append(os.path.join(env[var], "OpenOffice*")) 160 return sorted(p for pat in pats for p in glob.glob(pat)) 161 162 163def discover(location=None, environ=None): 164 """The Layout for `location`, else $AOO_OFFICE, else the newest 5.x install in the default places.""" 165 env = os.environ if environ is None else environ 166 loc = location or env.get("AOO_OFFICE") 167 if loc: 168 return find_layout(loc) 169 found = [] 170 for p in default_locations(env): 171 try: 172 found.append(find_layout(p)) 173 except LayoutError: 174 continue 175 if not found: 176 raise LayoutError("no AOO 5 install found in the default locations; give the location of the app or binary") 177 return max(found, key=lambda l: l.version.tuple) 178 179 180# ---- a Python that can import uno ----------------------------------------------------------------------------- 181 182@dataclass(frozen=True) 183class UnoPython: 184 path: str 185 version: str 186 source: str 187 188 189def parse_otool(text): 190 """The Python framework path from `otool -L` output, or None.""" 191 for line in text.splitlines(): 192 m = re.match(r"\s*(\S*/Python\.framework/Versions/[\d.]+/Python|\S*/libpython[\d.]+\.dylib)\s", line) 193 if m: 194 return m.group(1) 195 return None 196 197 198def parse_ldd(text): 199 """The libpython file name from `ldd` output, or None.""" 200 m = re.search(r"(libpython(\d+\.\d+)\w*\.so[\d.]*)", text) 201 return m.group(1) if m else None 202 203 204def linked_python(layout, runner=subprocess.run): 205 """What libpyuno is linked against: a path (macOS), a library name (Linux, FreeBSD), or None.""" 206 lib = next((layout.program / n for n in ("libpyuno.dylib", "libpyuno.so") if (layout.program / n).is_file()), None) 207 if lib is None or layout.platform == "windows": 208 return None 209 tool = ["otool", "-L"] if layout.platform == "macos" else ["ldd"] 210 try: 211 out = runner(tool + [str(lib)], capture_output=True, text=True, timeout=30).stdout 212 except (OSError, subprocess.SubprocessError): 213 return None 214 return parse_otool(out) if layout.platform == "macos" else parse_ldd(out) 215 216 217def interpreter_for(linked, which=shutil.which): 218 """The interpreter that belongs to a linked framework path or libpython name, or None.""" 219 if not linked: 220 return None 221 m = re.search(r"(.*)/Python\.framework/Versions/([\d.]+)/Python$", linked) 222 if m: 223 d = Path(m.group(1)) / "Python.framework" / "Versions" / m.group(2) / "bin" 224 for n in ("python" + m.group(2), "python3"): 225 if (d / n).is_file(): 226 return str(d / n) 227 return None 228 m = re.search(r"libpython(\d+\.\d+)", linked) 229 return which("python" + m.group(1)) if m else None 230 231 232def candidates(layout, explicit=None, environ=None, which=shutil.which, linked=linked_python): 233 """[(path, source)] in the order they are tried.""" 234 env = os.environ if environ is None else environ 235 out = [] 236 if explicit or env.get("AOO_UNO_PYTHON"): 237 return [(explicit or env["AOO_UNO_PYTHON"], "explicit")] 238 for n in ("python", "python.exe", "python3", "python.bin"): 239 if (layout.program / n).is_file(): 240 out.append((str(layout.program / n), "bundled program/" + n)) 241 for core in sorted(layout.program.glob("python-core-*")): 242 for n in ("bin/python3", "bin/python", "python.exe"): 243 if (core / n).is_file(): 244 out.append((str(core / n), "bundled " + core.name)) 245 link = linked(layout) 246 mapped = interpreter_for(link, which) 247 if mapped: 248 out.append((mapped, "linked by libpyuno (%s)" % link)) 249 out.append((sys.executable, "the Python running pytest")) 250 for minor in range(15, 10, -1): 251 w = which("python3.%d" % minor) 252 if w: 253 out.append((w, "python3.%d on PATH" % minor)) 254 seen, uniq = set(), [] 255 for path, src in out: 256 key = os.path.realpath(path) if os.path.exists(path) else path 257 if key not in seen: 258 seen.add(key) 259 uniq.append((path, src)) 260 return uniq 261 262 263def probe_env(layout, base=None): 264 env = dict(os.environ if base is None else base) 265 env["URE_BOOTSTRAP"] = "vnd.sun.star.pathname:" + str(layout.fundamental) 266 env["PYTHONPATH"] = os.pathsep.join([str(layout.program)] + ([env["PYTHONPATH"]] if env.get("PYTHONPATH") else [])) 267 var = {"macos": "DYLD_FALLBACK_LIBRARY_PATH", "posix": "LD_LIBRARY_PATH", "windows": "PATH"}[layout.platform] 268 env[var] = os.pathsep.join([str(layout.program)] + ([env[var]] if env.get(var) else [])) 269 return env 270 271 272PROBE_CODE = "import sys, uno, pyuno; print('%d.%d.%d' % sys.version_info[:3])" 273 274 275def probe(python, layout, runner=subprocess.run, timeout=60): 276 """(True, version) if `python` imports uno and pyuno against this install, else (False, reason).""" 277 try: 278 r = runner([python, "-c", PROBE_CODE], env=probe_env(layout), capture_output=True, text=True, timeout=timeout) 279 except (OSError, subprocess.SubprocessError) as e: 280 return False, "could not run: %s" % e 281 if r.returncode != 0: 282 tail = (r.stderr or r.stdout).strip().splitlines() 283 return False, tail[-1] if tail else "exit %d" % r.returncode 284 ver = r.stdout.strip() 285 parts = tuple(int(x) for x in ver.split(".")[:2]) if re.fullmatch(r"\d+\.\d+\.\d+", ver) else None 286 if parts is None: 287 return False, "unexpected output %r" % ver 288 if parts < MIN_WORKER_PYTHON: 289 return False, "Python %s is older than %d.%d" % (ver, *MIN_WORKER_PYTHON) 290 return True, ver 291 292 293def _cache_key(layout): 294 mod = layout.pyuno_module 295 return "%s|%s" % (layout.program, mod.stat().st_mtime_ns if mod else 0) 296 297 298def find_uno_python(layout, explicit=None, cache_file=None, environ=None, which=shutil.which, linked=linked_python, prober=probe): 299 """The first candidate that passes a real probe. An explicit choice (argument or AOO_UNO_PYTHON) is the only candidate: a 300 wrong override is reported, not silently replaced. The result is cached per install and pyuno build.""" 301 cache = {} 302 if cache_file and Path(cache_file).is_file(): 303 try: 304 cache = json.loads(Path(cache_file).read_text()) 305 except (OSError, ValueError): 306 cache = {} 307 key = _cache_key(layout) 308 hit = cache.get(key) 309 if hit and not explicit and not (environ or os.environ).get("AOO_UNO_PYTHON") and os.path.exists(hit["path"]): 310 return UnoPython(hit["path"], hit["version"], hit["source"] + " (cached)") 311 cands = candidates(layout, explicit, environ, which, linked) 312 tried = [] 313 for path, source in cands: 314 ok, detail = prober(path, layout) 315 if ok: 316 res = UnoPython(path, detail, source) 317 if cache_file: 318 try: 319 cache[key] = {"path": path, "version": detail, "source": source} 320 Path(cache_file).parent.mkdir(parents=True, exist_ok=True) 321 Path(cache_file).write_text(json.dumps(cache)) 322 except OSError: 323 pass 324 return res 325 tried.append((path, source, detail)) 326 link = linked(layout) 327 need = ("; libpyuno is linked against %s%s" % (link, "" if link and os.path.exists(link) else " (not found on this machine)")) if link else "" 328 raise NoUnoPython("no Python can import uno from %s%s" % (layout.program, need), tried, link) 329