xref: /trunk/test/aoo-test/src/aootest/install.py (revision 2cb6d6f119bbe2f78742f1c5c8538cb0d4e72ede)
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