#*************************************************************************** #* Copyright (c) 2001,2002 Jürgen Riegel * #* Copyright (c) 2025 Frank Martínez * #* * #* This file is part of the FreeCAD CAx development system. * #* * #* This program is free software you can redistribute it and/or modify * #* it under the terms of the GNU Lesser General Public License (LGPL) * #* as published by the Free Software Foundation either version 2 of * #* the License, or (at your option) any later version. * #* for detail see the LICENCE text file. * #* * #* FreeCAD is distributed in the hope that it will be useful, * #* but WITHOUT ANY WARRANTY without even the implied warranty of * #* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * #* GNU Lesser General Public License for more details. * #* * #* You should have received a copy of the GNU Library General Public * #* License along with FreeCAD if not, write to the Free Software * #* Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 * #* USA * #* * #***************************************************************************/ # FreeCAD init module - App # # Gathering all the information to start FreeCAD. # This is the second of four init scripts: # +------+------------------+-----------------------------+ # | This | Script | Runs | # +------+------------------+-----------------------------+ # | | CMakeVariables | always | # | >>>> | FreeCADInit | always | # | | FreeCADTest | only if test and not Gui | # | | FreeCADGuiInit | only if Gui is up | # +------+------------------+-----------------------------+ import FreeCAD App = FreeCAD App.Console.PrintLog("Init: starting App::FreeCADInit.py\n") App.Console.PrintLog("░░░▀█▀░█▀█░▀█▀░▀█▀░░░█▀█░█▀█░█▀█░░\n") App.Console.PrintLog("░░░░█░░█░█░░█░░░█░░░░█▀█░█▀▀░█▀▀░░\n") App.Console.PrintLog("░░░▀▀▀░▀░▀░▀▀▀░░▀░░░░▀░▀░▀░░░▀░░░░\n") try: import sys import os import traceback import inspect from enum import IntEnum # Leak to globals (backwards compat) from datetime import datetime # Leak to globals (backwards compat) from pathlib import Path # Removed manually import dataclasses import collections import collections.abc as coll_abc import platform import types import importlib.resources as resources import importlib import functools import re import pkgutil except ImportError: App.Console.PrintError("\n\nSeems the python standard libs are not installed, bailing out!\n\n") raise # ┌────────────────────────────────────────────────┐ # │ Logging Frameworks │ # └────────────────────────────────────────────────┘ def __logger(fn): __logger.sep = "\n" def wrapper(text: object, *, sep: str | None = None) -> None: fn(f"{text!s}{__logger.sep if sep is None else sep}") return wrapper Log = __logger(App.Console.PrintLog) Msg = __logger(App.Console.PrintMessage) Err = __logger(App.Console.PrintError) Wrn = __logger(App.Console.PrintWarning) Crt = __logger(App.Console.PrintCritical) Ntf = __logger(App.Console.PrintNotification) Tnf = __logger(App.Console.PrintTranslatedNotification) class FCADLogger: """ Convenient class for tagged logging. Example usage: >>> logger = FreeCAD.Logger('MyModule') >>> logger.info('log test {}',1) 24.36053 (1): test log 1 The default output format is: (line number): message The message is formatted using new style Python string formatting, e.g. 'test {}'.format(1). It is strongly recommended to not directly use Python string formatting, but pass additional argument indirectly through various logger print function, because the logger can skip string evaluation in case the logging level is disabled. For more options, please consult the docstring of __init__(), catch() and report(). To set/get logger level: >>> FreeCAD.setLogLevel('MyModule','Trace') >>> FreeCAD.getLogLevel('MyModule') 4 There are five predefined logger level, each corresponding to an integer value, as shown below together with the corresponding logger print method, 0: Error, Logger.error() 1: Warning, Logger.warn() 2: Message, Logger.msg() or info() 3: Log, Logger.log() or debug() 4: Trace, Logger.trace() FreeCAD.setLogLevel() supports both text and integer value, which allows you to define your own levels. The level set is persisted to user configuration file. By default any tag has a log level of 2 for release, and 3 for debug build. """ _levels = { 'Error': 0, 'error': 0, 'Warning': 1, 'warn': 1, 'Message': 2, 'msg': 2, 'info': 2, 'Log': 3, 'log': 3, 'debug': 3, 'Trace': 4, 'trace': 4, } _printer = ( App.Console.PrintError, App.Console.PrintWarning, App.Console.PrintMessage, App.Console.PrintLog, App.Console.PrintLog ) _defaults = ( ('printTag', True), ('noUpdateUI', True), ('timing', True), ('lineno', True), ('parent', None), ('title', 'FreeCAD'), ) printTag: bool noUpdateUI: bool timing: bool lineno: bool parent: "FCADLogger" title: str def __init__(self, tag: str, **kwargs) -> None: """ Construct a logger instance. Supported arguments are their default values are, * tag: a string tag for this logger. The log level of this logger can be accessed using FreeCAD.getLogLevel(tag)/setLogLevel(tag,level). All logger instance with the same tag shares the same level setting. * printTag (True): whether to print tag * noUpdateUI (True): whether to update GUI when printing. This is useful to show log output on lengthy operations. Be careful though, this may allow unexpected user interaction when the application is busy, which may lead to crash * timing (True): whether to print time stamp * lineno (True): whether to print source file and line number * parent (None): provide a parent logger, so that the log printing will check for parent's log level in addition of its own * title ('FreeCAD'): message box title used by report() """ self.tag = tag self.laststamp = datetime.now() for key, default in self._defaults: setattr(self, key, kwargs.get(key, default)) def _isEnabledFor(self, level: int) -> bool: """ Internal function to check for an integer log level. * level: integer log level """ if self.parent and not self.parent._isEnabledFor(level): return False return App.getLogLevel(self.tag) >= level def isEnabledFor(self, level: int | str) -> bool: """ To check for an integer or text log level. * level: integer or text log level """ if not isinstance(level, int): level = self.__class__._levels[level] return self._isEnabledFor(level) def _logger_method(name: str, level: int, level_name: str): # pylint: disable=no-self-argument """ Create level logger. """ docstring = f""" "{level_name}" level log printer * msg: message string. May contain new style Python string formatter. This function accepts additional positional and keyword arguments, which are forward to string.format() to generate the logging message. It is strongly recommended to not directly use Python string formatting, but pass additional arguments here, because the printer can skip string evaluation in case the logging level is disabled. """ def log_fn(self, msg: str, *args, **kwargs) -> None: if self._isEnabledFor(level): frame = kwargs.pop('frame', 0) + 1 self._log(level, msg, frame, args, kwargs) log_fn.__doc__ = docstring log_fn.__name__ = name return log_fn def _log( self, level: int, msg: str, frame: int = 0, args: tuple = (), kwargs: dict | None = None, ) -> None: """ Internal log printing function. * level: integer log level * msg: message, may contain new style string format specifier * frame (0): the calling frame for printing source file and line number. For example, in case you have your own logging function, and you want to show the callers source location, then set frame to one. * args: tuple for positional arguments to be passed to string.format() * kwargs: dictionary for keyword arguments to be passed to string.format() """ if (args or kwargs) and isinstance(msg, str): if not kwargs: msg = msg.format(*args) else: msg = msg.format(*args, **kwargs) prefix = '' if self.timing: now = datetime.now() prefix += '{} '.format((now-self.laststamp).total_seconds()) self.laststamp = now if self.printTag: prefix += '<{}> '.format(self.tag) if self.lineno: try: frame = sys._getframe(frame+1) prefix += '{}({}): '.format(os.path.basename( frame.f_code.co_filename),frame.f_lineno) except Exception: frame = inspect.stack()[frame+1] prefix += '{}({}): '.format(os.path.basename(frame[1]),frame[2]) self.__class__._printer[level]('{}{}\n'.format(prefix,msg)) if not self.noUpdateUI and App.GuiUp: import FreeCADGui try: FreeCADGui.updateGui() except Exception: pass def _catch_logger_method(name: str, level: int, level_name: str): # pylint: disable=no-self-argument """ Create level catch logger. """ docstring = f""" Catch any exception from a function and print as "{level_name}". * msg: message string. Unlike log printer, this argument must not contain any string formatter. * func: a callable object * args: tuple of positional arguments to be passed to func. * kwargs: dictionary of keyword arguments to be passed to func. """ def catch_fn(self, msg: str, func: callable, *args, **kwargs) -> object | None: return self._catch(level, msg, func, args, kwargs) catch_fn.__doc__ = docstring catch_fn.__name__ = name return catch_fn def _catch( self, level: int, msg: str, func: callable, args: tuple = (), kwargs: dict | None = None, ) -> object | None: """ Internal function to log exception of any callable. * level: integer log level * msg: message string. Unlike _log(), this argument must not contain any string formatter. * func: a callable object * args: tuple of positional arguments to be passed to func. * kwargs: dictionary of keyword arguments to be passed to func. """ try: if not kwargs: kwargs = {} return func(*args, **kwargs) except Exception: if self._isEnabledFor(level): self._log(level, f"{msg}\n{traceback.format_exc()}", frame=2) return None def report(self, msg: str, func: callable, *args, **kwargs) -> object | None: """ Catch any exception report it with a message box. * msg: message string. Unlike log printer, this argument must not contain any string formatter. * func: a callable object * args: tuple of positional arguments to be passed to func. * kwargs: dictionary of keyword arguments to be passed to func. """ try: return func(*args, **kwargs) except Exception as e: self.error(f"{msg}\n{traceback.format_exc()}", frame=1) if App.GuiUp: import FreeCADGui, PySide PySide.QtGui.QMessageBox.critical( FreeCADGui.getMainWindow(), self.title, str(e), ) return None error = _logger_method("error", 0, "Error") warn = _logger_method("warn", 1, "Warning") msg = _logger_method("msg", 2, "Message") log = _logger_method("log", 3, "Log") trace = _logger_method("trace", 4, "Trace") info = msg debug = log catch = _catch_logger_method("catch", 0, "Error") catchWarn = _catch_logger_method("catchWarn", 1, "Warning") catchMsg = _catch_logger_method("catchMsg", 2, "Message") catchLog = _catch_logger_method("catchLog", 3, "Log") catchTrace = _catch_logger_method("catchTrace", 4, "Trace") catchInfo = catchMsg catchDebug = catchLog App.Logger = FCADLogger # ┌────────────────────────────────────────────────┐ # │ App definitions │ # └────────────────────────────────────────────────┘ # store the cmake variables # This data comes from generated file src/App/CMakeScript.h and it is # injected into globals in a previous stage. App.__cmake__ = globals().get("cmake", []) # store unit test names App.__unit_test__ = [] App.addImportType("FreeCAD document (*.FCStd)", "FreeCAD") # set to no gui, is overwritten by InitGui App.GuiUp = 0 # fmt: off # fill up unit definitions App.Units.NanoMetre = App.Units.Quantity('nm') App.Units.MicroMetre = App.Units.Quantity('um') App.Units.MilliMetre = App.Units.Quantity('mm') App.Units.CentiMetre = App.Units.Quantity('cm') App.Units.DeciMetre = App.Units.Quantity('dm') App.Units.Metre = App.Units.Quantity('m') App.Units.KiloMetre = App.Units.Quantity('km') App.Units.MilliLiter = App.Units.Quantity('ml') App.Units.Liter = App.Units.Quantity('l') App.Units.Hertz = App.Units.Quantity('Hz') App.Units.KiloHertz = App.Units.Quantity('kHz') App.Units.MegaHertz = App.Units.Quantity('MHz') App.Units.GigaHertz = App.Units.Quantity('GHz') App.Units.TeraHertz = App.Units.Quantity('THz') App.Units.MicroGram = App.Units.Quantity('ug') App.Units.MilliGram = App.Units.Quantity('mg') App.Units.Gram = App.Units.Quantity('g') App.Units.KiloGram = App.Units.Quantity('kg') App.Units.Ton = App.Units.Quantity('t') App.Units.Second = App.Units.Quantity('s') App.Units.Minute = App.Units.Quantity('min') App.Units.Hour = App.Units.Quantity('h') App.Units.Ampere = App.Units.Quantity('A') App.Units.MilliAmpere = App.Units.Quantity('mA') App.Units.KiloAmpere = App.Units.Quantity('kA') App.Units.MegaAmpere = App.Units.Quantity('MA') App.Units.Kelvin = App.Units.Quantity('K') App.Units.MilliKelvin = App.Units.Quantity('mK') App.Units.MicroKelvin = App.Units.Quantity('uK') App.Units.MilliMole = App.Units.Quantity('mmol') App.Units.Mole = App.Units.Quantity('mol') App.Units.Candela = App.Units.Quantity('cd') App.Units.Inch = App.Units.Quantity('in') App.Units.Foot = App.Units.Quantity('ft') App.Units.Thou = App.Units.Quantity('thou') App.Units.Yard = App.Units.Quantity('yd') App.Units.Mile = App.Units.Quantity('mi') App.Units.SquareFoot = App.Units.Quantity('sqft') App.Units.CubicFoot = App.Units.Quantity('cft') App.Units.Pound = App.Units.Quantity('lb') App.Units.Ounce = App.Units.Quantity('oz') App.Units.Stone = App.Units.Quantity('st') App.Units.Hundredweights= App.Units.Quantity('cwt') App.Units.Newton = App.Units.Quantity('N') App.Units.MilliNewton = App.Units.Quantity('mN') App.Units.KiloNewton = App.Units.Quantity('kN') App.Units.MegaNewton = App.Units.Quantity('MN') App.Units.NewtonPerMeter = App.Units.Quantity('N/m') App.Units.MilliNewtonPerMeter = App.Units.Quantity('mN/m') App.Units.KiloNewtonPerMeter = App.Units.Quantity('kN/m') App.Units.MegaNewtonPerMeter = App.Units.Quantity('MN/m') App.Units.Pascal = App.Units.Quantity('Pa') App.Units.KiloPascal = App.Units.Quantity('kPa') App.Units.MegaPascal = App.Units.Quantity('MPa') App.Units.GigaPascal = App.Units.Quantity('GPa') App.Units.MilliBar = App.Units.Quantity('mbar') App.Units.Bar = App.Units.Quantity('bar') App.Units.PoundForce = App.Units.Quantity('lbf') App.Units.Torr = App.Units.Quantity('Torr') App.Units.mTorr = App.Units.Quantity('mTorr') App.Units.yTorr = App.Units.Quantity('uTorr') App.Units.PSI = App.Units.Quantity('psi') App.Units.KSI = App.Units.Quantity('ksi') App.Units.MPSI = App.Units.Quantity('Mpsi') App.Units.Watt = App.Units.Quantity('W') App.Units.MilliWatt = App.Units.Quantity('mW') App.Units.KiloWatt = App.Units.Quantity('kW') App.Units.VoltAmpere = App.Units.Quantity('VA') App.Units.Volt = App.Units.Quantity('V') App.Units.MilliVolt = App.Units.Quantity('mV') App.Units.KiloVolt = App.Units.Quantity('kV') App.Units.MegaSiemens = App.Units.Quantity('MS') App.Units.KiloSiemens = App.Units.Quantity('kS') App.Units.Siemens = App.Units.Quantity('S') App.Units.MilliSiemens = App.Units.Quantity('mS') App.Units.MicroSiemens = App.Units.Quantity('uS') App.Units.Ohm = App.Units.Quantity('Ohm') App.Units.KiloOhm = App.Units.Quantity('kOhm') App.Units.MegaOhm = App.Units.Quantity('MOhm') App.Units.Coulomb = App.Units.Quantity('C') App.Units.Tesla = App.Units.Quantity('T') App.Units.Gauss = App.Units.Quantity('G') App.Units.Weber = App.Units.Quantity('Wb') # disable Oersted because people need to input e.g. a field strength of # 1 ampere per meter -> 1 A/m and not get the recalculation to Oersted # App.Units.Oersted = App.Units.Quantity('Oe') App.Units.PicoFarad = App.Units.Quantity('pF') App.Units.NanoFarad = App.Units.Quantity('nF') App.Units.MicroFarad = App.Units.Quantity('uF') App.Units.MilliFarad = App.Units.Quantity('mF') App.Units.Farad = App.Units.Quantity('F') App.Units.NanoHenry = App.Units.Quantity('nH') App.Units.MicroHenry = App.Units.Quantity('uH') App.Units.MilliHenry = App.Units.Quantity('mH') App.Units.Henry = App.Units.Quantity('H') App.Units.Joule = App.Units.Quantity('J') App.Units.MilliJoule = App.Units.Quantity('mJ') App.Units.KiloJoule = App.Units.Quantity('kJ') App.Units.NewtonMeter = App.Units.Quantity('Nm') App.Units.VoltAmpereSecond = App.Units.Quantity('VAs') App.Units.WattSecond = App.Units.Quantity('Ws') App.Units.KiloWattHour = App.Units.Quantity('kWh') App.Units.ElectronVolt = App.Units.Quantity('eV') App.Units.KiloElectronVolt = App.Units.Quantity('keV') App.Units.MegaElectronVolt = App.Units.Quantity('MeV') App.Units.Calorie = App.Units.Quantity('cal') App.Units.KiloCalorie = App.Units.Quantity('kcal') App.Units.MPH = App.Units.Quantity('mi/h') App.Units.KMH = App.Units.Quantity('km/h') App.Units.Degree = App.Units.Quantity('deg') App.Units.Radian = App.Units.Quantity('rad') App.Units.Gon = App.Units.Quantity('gon') App.Units.AngularMinute = App.Units.Quantity().AngularMinute App.Units.AngularSecond = App.Units.Quantity().AngularSecond # SI base units # (length, weight, time, current, temperature, amount of substance, luminous intensity, angle) App.Units.AmountOfSubstance = App.Units.Unit(0,0,0,0,0,1) App.Units.ElectricCurrent = App.Units.Unit(0,0,0,1) App.Units.Length = App.Units.Unit(1) App.Units.LuminousIntensity = App.Units.Unit(0,0,0,0,0,0,1) App.Units.Mass = App.Units.Unit(0,1) App.Units.Temperature = App.Units.Unit(0,0,0,0,1) App.Units.TimeSpan = App.Units.Unit(0,0,1) # all other combined units App.Units.Acceleration = App.Units.Unit(1,0,-2) App.Units.Angle = App.Units.Unit(0,0,0,0,0,0,0,1) App.Units.AngleOfFriction = App.Units.Unit(0,0,0,0,0,0,0,1) App.Units.Area = App.Units.Unit(2) App.Units.CompressiveStrength = App.Units.Unit(-1,1,-2) App.Units.CurrentDensity = App.Units.Unit(-2,0,0,1) App.Units.Density = App.Units.Unit(-3,1) App.Units.DissipationRate = App.Units.Unit(2,0,-3) App.Units.DynamicViscosity = App.Units.Unit(-1,1,-1) App.Units.Frequency = App.Units.Unit(0,0,-1) App.Units.MagneticFluxDensity = App.Units.Unit(0,1,-2,-1) App.Units.Magnetization = App.Units.Unit(-1,0,0,1) App.Units.ElectricalCapacitance = App.Units.Unit(-2,-1,4,2) App.Units.ElectricalConductance = App.Units.Unit(-2,-1,3,2) App.Units.ElectricalConductivity = App.Units.Unit(-3,-1,3,2) App.Units.ElectricalInductance = App.Units.Unit(2,1,-2,-2) App.Units.ElectricalResistance = App.Units.Unit(2,1,-3,-2) App.Units.ElectricCharge = App.Units.Unit(0,0,1,1) App.Units.ElectricPotential = App.Units.Unit(2,1,-3,-1) App.Units.Force = App.Units.Unit(1,1,-2) App.Units.HeatFlux = App.Units.Unit(0,1,-3,0,0) App.Units.InverseArea = App.Units.Unit(-2) App.Units.InverseLength = App.Units.Unit(-1) App.Units.InverseVolume = App.Units.Unit(-3) App.Units.KinematicViscosity = App.Units.Unit(2,0,-1) App.Units.Pressure = App.Units.Unit(-1,1,-2) App.Units.Power = App.Units.Unit(2,1,-3) App.Units.ShearModulus = App.Units.Unit(-1,1,-2) App.Units.SpecificEnergy = App.Units.Unit(2,0,-2) App.Units.SpecificHeat = App.Units.Unit(2,0,-2,0,-1) App.Units.Stiffness = App.Units.Unit(0,1,-2) App.Units.Stress = App.Units.Unit(-1,1,-2) App.Units.ThermalConductivity = App.Units.Unit(1,1,-3,0,-1) App.Units.ThermalExpansionCoefficient = App.Units.Unit(0,0,0,0,-1) App.Units.ThermalTransferCoefficient = App.Units.Unit(0,1,-3,0,-1) App.Units.UltimateTensileStrength = App.Units.Unit(-1,1,-2) App.Units.Velocity = App.Units.Unit(1,0,-1) App.Units.VacuumPermittivity = App.Units.Unit(-3,-1,4,2) App.Units.Volume = App.Units.Unit(3) App.Units.VolumeFlowRate = App.Units.Unit(3,0,-1) App.Units.VolumetricThermalExpansionCoefficient = App.Units.Unit(0,0,0,0,-1) App.Units.Work = App.Units.Unit(2,1,-2) App.Units.YieldStrength = App.Units.Unit(-1,1,-2) App.Units.YoungsModulus = App.Units.Unit(-1,1,-2) # fmt: on # The values must match with that of the # C++ enum class UnitSystem class Scheme(IntEnum): Internal = 0 MKS = 1 Imperial = 2 ImperialDecimal = 3 Centimeter = 4 ImperialBuilding = 5 MmMin = 6 ImperialCivil = 7 FEM = 8 MeterDecimal = 9 App.Units.Scheme = Scheme class NumberFormat(IntEnum): Default = 0 Fixed = 1 Scientific = 2 App.Units.NumberFormat = NumberFormat class ScaleType(IntEnum): Other = -1 NoScaling = 0 NonUniformRight = 1 NonUniformLeft = 2 Uniform = 3 App.ScaleType = ScaleType class PropertyType(IntEnum): Prop_None = 0 Prop_ReadOnly = 1 Prop_Transient = 2 Prop_Hidden = 4 Prop_Output = 8 Prop_NoRecompute = 16 Prop_NoPersist = 32 App.PropertyType = PropertyType class ReturnType(IntEnum): PyObject = 0 DocObject = 1 DocAndPyObject = 2 Placement = 3 Matrix = 4 LinkAndPlacement = 5 LinkAndMatrix = 6 App.ReturnType = ReturnType # ┌────────────────────────────────────────────────┐ # │ Init Framework │ # └────────────────────────────────────────────────┘ class Transient: """ Mark the symbol for removal from global scope on cleanup. """ names = ["Path"] def __call__(self, target): Transient.names.append(target.__name__) return target @classmethod def cleanup(cls) -> None: # Remove imports # os: kept for backwards compat keep = set(("__builtins__", "FreeCAD", "App", "os", "sys", "traceback", "inspect")) names = [name for name, ref in globals().items() if isinstance(ref, types.ModuleType)] for name in names: if name not in keep: del globals()[name] # Remove transient symbols cls.names.extend(("transient", cls.__name__)) for name in cls.names: del globals()[name] transient = Transient() @transient def call_in_place(fn): """Call the function in place immediately after its definition.""" fn() return fn @transient class utils: HLine = "-" * 80 @staticmethod def str_to_paths(paths: str, delim: str = ";") -> list[Path]: """Convert a delimited string list of paths to a list of Path objects.""" items = (item.strip() for item in paths.split(delim)) # Filtering out empty paths: This may break backwards compat or not. # If something breaks, just remove the filter to allow empty paths non_empty_paths = filter(bool, items) return list(map(Path, non_empty_paths)) @staticmethod def env_to_path(name: str) -> Path | None: if path := os.environ.get(name): return Path(path) return None @staticmethod def setup_tty_tab_completion(): """ Tries to setup readline-based tab-completion. Call this function only if you are in a tty-based REPL environment. """ try: import readline import rlcompleter # noqa: F401, import required readline.parse_and_bind("tab: complete") except ImportError: # Note: As there is no readline on Windows, # we just ignore import errors here. pass @transient class PathPriority(IntEnum): Ignore = 0 FallbackLast = 1 FallbackFirst = 2 OverrideLast = 3 OverrideFirst = 4 @transient @dataclasses.dataclass class PathSet: """ Collection of paths with priority support. Items can be inserted at specific priorities and composed in nested levels. Structure: [ override:(OverrideFirst, *_, OverrideLast), *source, fallback:(FallbackFirst, *_, FallbackLast), ] """ source: list["Path | PathSet"] = dataclasses.field(default_factory=list) override: collections.deque["Path | PathSet"] = dataclasses.field(default_factory=collections.deque) fallback: collections.deque["Path | PathSet"] = dataclasses.field(default_factory=collections.deque) def add(self, item: "Path | PathSet", priority: PathPriority = PathPriority.OverrideLast) -> None: """Add item into the corresponding priority slot.""" if isinstance(item, Path): item = item.resolve() if not item.exists(): return if priority == PathPriority.FallbackLast: self.fallback.append(item) elif priority == PathPriority.OverrideFirst: self.override.appendleft(item) elif priority == PathPriority.OverrideLast: self.override.append(item) elif priority == PathPriority.FallbackFirst: self.fallback.appendleft(item) elif priority == PathPriority.Ignore: pass else: msg = "Invalid path priority" raise ValueError(msg) def iter(self) -> coll_abc.Iterable[Path]: """ Return iterable in priority order, higher first. """ for section in (self.override, self.source, self.fallback): for path in section: if isinstance(path, PathSet): yield from path.iter() else: yield path def build(self) -> list[Path]: """ Build and remove duplicates, keep priority order. """ return list(dict.fromkeys(self.iter())) @transient class SearchPaths: """ Manages search paths for binaries, libraries and modules. The most generic search path is PATH in environment. DLL search path is windows specific. sys.path is for module imports. """ env_path: PathSet sys_path: PathSet dll_path: PathSet def __init__(self): self.env_path = PathSet([Path(p) for p in os.environ.get("PATH", "").split(os.pathsep)]) self.sys_path = PathSet(sys.path) self.dll_path = PathSet() def add( self, item: Path | PathSet, *, env_path: PathPriority = PathPriority.OverrideLast, sys_path: PathPriority = PathPriority.OverrideFirst, dll_path: PathPriority = PathPriority.OverrideLast ) -> None: """ Add item to required namespaces with the specified priority. Actual changes are buffered until commit(). """ self.env_path.add(item, env_path) self.sys_path.add(item, sys_path) self.dll_path.add(item, dll_path) def commit(self) -> None: """Apply changes to underlying namespaces and priorities.""" os.environ["PATH"] = os.pathsep.join(str(path) for path in self.env_path.build()) sys.path = [str(path) for path in self.sys_path.build()] if win32 := WindowsPlatform(): win32.add_dll_search_paths(self.dll_path.build()) # Reset self.__init__() @transient class Config: AdditionalModulePaths = utils.str_to_paths(App.ConfigGet("AdditionalModulePaths")) AdditionalMacroPaths = utils.str_to_paths(App.ConfigGet("AdditionalMacroPaths")) RunMode: str = App.ConfigGet('RunMode') DisabledAddons: set[str] = set(mod for mod in App.ConfigGet("DisabledAddons").split(";") if mod) @transient class WindowsPlatform: """ Windows specific hooks. """ initialized = False enabled = platform.system() == 'Windows' and hasattr(os, "add_dll_directory") def __init__(self) -> None: if not WindowsPlatform.enabled or WindowsPlatform.initialized: return if lib_pack := utils.env_to_path("FREECAD_LIBPACK_BIN"): os.add_dll_directory(str(lib_pack.resolve())) if win_dir := utils.env_to_path("WINDIR"): system32 = win_dir / "system32" os.add_dll_directory(str(system32.resolve())) WindowsPlatform.initialized = True def __bool__(self) -> bool: return self.enabled def add_dll_search_paths(self, paths: list[Path]) -> None: for path in paths: os.add_dll_directory(str(path.resolve())) @transient class DarwinPlatform: """ MacOSX specific hooks. """ enabled = platform.system() == "Darwin" and len(platform.mac_ver()[0]) > 0 def __bool__(self) -> bool: return self.enabled def post(self) -> None: # add special path for MacOSX (bug #0000307): Where is this bug documented? sys.path.append(os.path.expanduser("~/Library/Application Support/FreeCAD/Mod")) class ModState(IntEnum): Unsupported = -3 Failed = -2 Disabled = -1 Discovered = 0 Resolved = 1 Loaded = 2 @transient class Mod: """ Base Mod. There are two types of Mods: Directory based (DirMod) or Module based (ExtMod). """ ALL_ADDONS_DISABLED = "ALL_ADDONS_DISABLED" ADDON_DISABLED = "ADDON_DISABLED" PACKAGE_XML = "package.xml" state: ModState @property def kind(self) -> str: """Return the Mod type: 'Dir' or 'Ext'""" return "Ext" @property def init_mode(self) -> str: """Return the Mod init mode: 'exec' or 'import' or ''""" return "import" @property def metadata(self) -> App.Metadata | None: """Return Metadata from package.xml if any.""" def check_disabled(self) -> bool: """ Mods can be disabled by several methods: - command line argument: --disable-addon - stop file: ALL_ADDONS_DISABLED - stop file: ADDON_DISABLED """ def process_metadata(self, search_paths: SearchPaths) -> None: """ Process package.xml if present to check version compatibility and to scan internal workbenches. """ def run_init(self) -> None: """ Run all required initialization scripts/modules: Init.py, init, __init__.py """ def supports_freecad_version(self) -> bool: """ Check if the Mod supports the current FreeCAD version. """ if meta := self.metadata: return meta.supportsCurrentFreeCAD() return True def load(self, search_paths: SearchPaths) -> None: """ Load the Mod. """ try: self.process_metadata(search_paths) except Exception as ex: self.state = ModState.Failed Err(str(ex)) else: if self.state == ModState.Resolved: self.run_init() if self.state == ModState.Resolved: self.state = ModState.Loaded @transient class ExtMod(Mod): """ Module based Mod (aka extension module). This kind of Mods are loaded using python module system, no direct filesystem or compile/execute hacks are used. extension modules must be defined in namespace freecad.*, i.e. freecad.MyAddon. """ name: str # full module name, i.e.: freecad.MyAddon def __init__(self, name: str): self.state = ModState.Resolved self.name = name @functools.cached_property def metadata(self) -> App.Metadata | None: with resources.as_file(resources.files(self.name)) as base: metadata = base / self.PACKAGE_XML if metadata.exists(): return App.Metadata(str(metadata)) metadata = base.parent.parent / self.PACKAGE_XML if metadata.exists(): return App.Metadata(str(metadata)) return None def check_disabled(self) -> bool: with resources.as_file(resources.files(self.name)) as base: return (base / self.ADDON_DISABLED).exists() or (base.parent.parent / self.ADDON_DISABLED).exists() def process_metadata(self, _search_paths: SearchPaths) -> None: meta = self.metadata if not meta: return if not self.supports_freecad_version(): self.state = ModState.Unsupported Msg(f"NOTICE: {self.name} does not support this version of FreeCAD, so is being skipped") def _init_error(self, ex: Exception, error_msg: str) -> None: Err(f'During initialization the error "{ex!s}" occurred in {self.name}') Err(utils.HLine) Err(error_msg) Err(utils.HLine) Log(f'Init: Initializing {self.name}... failed') Err(utils.HLine) Log(error_msg) Err(utils.HLine) def run_init(self) -> None: try: module = importlib.import_module(self.name) # Implicit run of __init__.py except Exception as ex: self._init_error(ex, traceback.format_exc()) self.state = ModState.Failed else: self.run_secondary_init(module) def run_secondary_init(self, module: types.ModuleType) -> None: try: importlib.import_module(f"{module.__name__}.init") except ModuleNotFoundError: pass # Ok, this module is optional except Exception as ex: self._init_error(ex, traceback.format_exc()) self.state = ModState.Failed @transient class DirMod(Mod): """ Directory based Mod. (aka Standard/Legacy). This kind of Mods are scanned from several directories in the system following certain priority. The name part of the path is used as the module name. Dir based modules can be overridden if several copies exists in different directories, resolution is based on directory priority. """ INIT_PY = "Init.py" _path: collections.deque[Path] def __init__(self, path: Path) -> None: self.state = ModState.Discovered self._path = collections.deque() self._path.append(path) @property def kind(self) -> str: return "Dir" @property def init_mode(self) -> str: return "exec" if (self.path / self.INIT_PY).exists() else '' @property def name(self) -> str: return self.path.name @functools.cached_property def metadata(self) -> App.Metadata | None: metadata = self.path / self.PACKAGE_XML if metadata.exists(): return App.Metadata(str(metadata)) return None def process_metadata(self, search_paths: SearchPaths): meta = self.metadata if not meta: return if not self.supports_freecad_version(): self.state = ModState.Unsupported Msg(f"NOTICE: {meta.Name} does not support this version of FreeCAD, so is being skipped") return content = meta.Content if "workbench" in content: workbenches = content["workbench"] for workbench in workbenches: if not workbench.supportsCurrentFreeCAD(): Msg(f"NOTICE: {meta.Name} content item {workbench.Name} does not support this version of FreeCAD, so is being skipped") continue subdirectory = workbench.Name if not workbench.Subdirectory else workbench.Subdirectory subdirectory = re.split(r"[/\\]+", subdirectory) subdirectory = self.path / Path(*subdirectory) search_paths.add( subdirectory, env_path=PathPriority.OverrideLast, sys_path=PathPriority.OverrideFirst, dll_path=PathPriority.FallbackLast, ) def override_with(self, path: Path) -> None: """ Override current path with the one provided. """ self._path.appendleft(path) @property def path(self) -> Path: """Current (highest priority) path.""" return self._path[0] @property def alternative_paths(self) -> list[Path]: """Alternative paths in priority order""" return list(self._path)[1:] def check_disabled(self) -> bool: name = self.path.name if name in Config.DisabledAddons: Msg(f'NOTICE: Addon "{name}" disabled by presence of "--disable-addon {name}" argument') return True for flag in (self.ALL_ADDONS_DISABLED, self.ADDON_DISABLED): if (self.path / flag).exists(): Msg(f'NOTICE: Addon "{self.path!s}" disabled by presence of {flag} stopfile') return True return False def resolve(self, search_paths: SearchPaths) -> None: """ Add the current path to search paths to make it loadable. """ if self.check_disabled(): self.state = ModState.Disabled return search_paths.add( self.path, env_path=PathPriority.OverrideLast, sys_path=PathPriority.OverrideFirst, ) self.state = ModState.Resolved def run_init(self) -> None: init_py = self.path / self.INIT_PY if not init_py.exists(): self.state = ModState.Loaded Log(f"Init: Initializing {self.path!s} ({self.INIT_PY} not found)... ignore") return try: source = init_py.read_text(encoding="utf-8") code = compile(source, init_py, 'exec') exec(code) except Exception as ex: Log(f"Init: Initializing {self.path!s}... failed") Log(utils.HLine) Log(f"{traceback.format_exc()}") Log(utils.HLine) Err(f"During initialization the error \"{ex!s}\" occurred in {init_py!s}") Err("Please look into the log file for further information") self.state = ModState.Failed else: self.state = ModState.Loaded Log(f"Init: Initializing {self.path!s}... done") @transient class ExtModScanner: """ Scan extension Mods from the python import path. """ mods: list[ExtMod] def __init__(self): self.mods = [] def scan(self): import freecad modules = (m[1] for m in pkgutil.iter_modules(freecad.__path__, "freecad.") if m[2]) for module_name in modules: mod = ExtMod(module_name) self.mods.append(mod) if module_name in Config.DisabledAddons: mod.state = ModState.Disabled Msg(f'NOTICE: Addon "{module_name}" disabled by presence of "--disable-addon {module_name}" argument') continue Log(f'Init: Initializing {module_name}') def iter(self) -> coll_abc.Iterable[ExtMod]: return self.mods @transient class DirModScanner: """ Sacan in the filesystem for Dir based Mods in the valid locations. """ EXCLUDE: set[str] = set(["", "CVS", "__init__.py"]) # Why? mods: dict[str, DirMod] visited: set[str] def __init__(self) -> None: self.mods = {} self.visited = set() def iter(self) -> coll_abc.Iterable[DirMod]: """All discovered Mods.""" return self.mods.values() def dirs(self) -> list[Path]: """Paths of all discovered Mods.""" return [mod.path for mod in self.mods.values()] def scan_and_override(self, base: Path, *, flat: bool = False, warning: str | None = None) -> None: """ Scan in base with higher priority. """ if (key := str(base.resolve())) in self.visited: return self.visited.add(key) if not base.exists(): if warning: Wrn(warning) else: Wrn(f"No modules found in {base!s}") return if warning: Wrn(warning) if flat: self.mods[str(base)] = DirMod(base) return for mod_dir in filter(Path.is_dir, base.iterdir()): name = mod_dir.name.lower() if name in DirModScanner.EXCLUDE: continue if mod := self.mods.get(name): mod.override_with(mod_dir) continue self.mods[name] = DirMod(mod_dir) # ┌────────────────────────────────────────────────┐ # │ Init Pipeline Definition │ # └────────────────────────────────────────────────┘ @transient class InitPipeline: """ Init sequence, setup search paths, scan and load Mods and run platform specific hooks. """ std_home = Path(App.getHomePath()).resolve() std_lib = Path(App.getLibraryDir()).resolve() user_home = Path(App.getUserAppDataDir()).resolve() dir_mod_scanner = DirModScanner() ext_mod_scanner = ExtModScanner() search_paths = SearchPaths() def added_python_packages(self) -> PathSet: """ Additional python packages installed by AddonManager/pip. """ major, minor, _ = platform.python_version_tuple() packages = self.user_home / "AdditionalPythonPackages" vendor_path = packages / f"py{major}{minor}" paths = PathSet() paths.add(vendor_path) paths.add(packages) return paths def scan(self) -> None: """ Scan step, search for standard directories, libraries and Mods. """ std_home = self.std_home user_home = self.user_home std_lib = self.std_lib std_mod = std_home / "Mod" std_ext = std_home / "Ext" std_bin = std_home / "bin" user_macro = Path(App.getUserMacroDir(True)).resolve() user_mod = user_home / "Mod" search_paths = self.search_paths legacy_user_mod = Path.home() / ".FreeCAD" / "Mod" if legacy_user_mod.exists(): Wrn (f"User path has changed to {user_home!s}. Please move user modules and macros") # Libraries libraries = PathSet() libraries.add(std_home / "lib") libraries.add(std_home / "lib64") libraries.add(std_home / "lib-py3") libraries.add(std_lib) search_paths.add( libraries, env_path=PathPriority.Ignore, sys_path=PathPriority.OverrideLast, ) # Tools search_paths.add( std_home / "Tools", env_path=PathPriority.Ignore, sys_path=PathPriority.FallbackLast, ) # Binaries search_paths.add( std_bin, env_path=PathPriority.OverrideFirst, sys_path=PathPriority.Ignore, dll_path=PathPriority.FallbackLast, ) # Scan for Directory based Mods # Order is important because of overrides Log("Init: Searching for modules...") mods = self.dir_mod_scanner mods.scan_and_override(std_mod) mods.scan_and_override(user_mod) mods.scan_and_override(user_macro / "Mod") additional_mods = Config.AdditionalModulePaths + Config.AdditionalMacroPaths for add in additional_mods: mods.scan_and_override(add, flat=True) # to have all the module-paths available in FreeCADGuiInit.py: App.__ModDirs__ = [str(d) for d in mods.dirs()] # this allows importing with: # from FreeCAD.Module import package import_path = PathSet([libraries]) import_path.add(std_mod, PathPriority.OverrideFirst) import_path.add(user_mod, PathPriority.FallbackLast) App.__path__ = [str(path) for path in import_path.build()] # also add these directories to the sys.path to # not change the old behavior. once we have moved to # proper python modules this can eventually be removed. search_paths.add( std_mod, env_path=PathPriority.Ignore, sys_path=PathPriority.OverrideFirst, ) search_paths.add( std_ext, env_path=PathPriority.Ignore, sys_path=PathPriority.OverrideLast, ) # Additional installed packages (AddonManager/pip) search_paths.add( self.added_python_packages(), env_path=PathPriority.Ignore, sys_path=PathPriority.FallbackLast, ) # Resolve Dir Mods for mod in mods.iter(): mod.resolve(search_paths) def load_mods(self) -> None: """ Load Mods step, load both Dir based and Module based Mods. """ module_cache = [] # Update search paths to make Mods visible to import system. search_paths = self.search_paths search_paths.commit() # Dir Mods first for mod in self.dir_mod_scanner.iter(): if mod.state == ModState.Resolved: mod.load(search_paths) module_cache.append(mod) # Update search paths: may have changed by dir loads search_paths.commit() # Finally, Module based Mod are loaded from python path self.ext_mod_scanner.scan() for mod in self.ext_mod_scanner.iter(): if mod.state == ModState.Resolved: mod.load(search_paths) module_cache.append(mod) # Save to use in FreeCADGuiInit.py App.__ModCache__ = module_cache def register_macro_sources(self) -> None: """ Add Macro sources to search paths. """ std_macro = self.std_home / "Macro" user_macro_default = Path(App.getUserMacroDir(False)).resolve() user_macro = Path(App.getUserMacroDir(True)).resolve() # add MacroDir to path (RFE #0000504) self.search_paths.add( user_macro_default, env_path=PathPriority.Ignore, sys_path=PathPriority.FallbackLast, ) self.search_paths.add( user_macro, env_path=PathPriority.Ignore, sys_path=PathPriority.FallbackLast, ) self.search_paths.add( std_macro, env_path=PathPriority.Ignore, sys_path=PathPriority.FallbackLast, ) def post(self) -> None: """ Run final steps. """ if macosx := DarwinPlatform(): macosx.post() def setup_tty(self) -> None: # Note: just checking whether stdin is a TTY is not enough, as the GUI is set up only after this # script has run. And checking only the RunMode is not enough, as we are maybe not interactive. if Config.RunMode == 'Cmd' and hasattr(sys.stdin, 'isatty') and sys.stdin.isatty(): utils.setup_tty_tab_completion() def report(self) -> None: std_mod = self.std_home / "Mod" Log(f"Using {std_mod!s} as module path!") Log("System path after init:") for path in os.environ["PATH"].split(os.pathsep): Log(f" {path}") Log("FreeCADInit Mod summary:") output = [] output.append(f"+-{'--':-<24}-+-{'----':-<10}-+-{'---':-<6}-+-{'-----':-<48}-") output.append(f"| {'Mod':<24} | {'State':<10} | {'Mode':<6} | {'Source':<48} ") output.append(output[0]) for mod in self.dir_mod_scanner.iter(): output.append(f"| {mod.name:<24.24} | {mod.state.name:<10.10} | {mod.init_mode:<6.6} | {mod.path!s}") for alt in mod.alternative_paths: output.append(f"| {' ':<24.24} | {' ':<10.10} | {' ':<6.6} | {alt!s}") for mod in self.ext_mod_scanner.iter(): output.append(f"| {mod.name:<24.24} | {mod.state.name:<10.10} | {mod.init_mode:<6.6} | {mod.name}") for line in output: Log(line) Log(output[0]) def run(self) -> None: """ Pipeline entry point. """ self.scan() self.load_mods() self.register_macro_sources() self.post() self.report() self.setup_tty() # ┌────────────────────────────────────────────────┐ # │ Init Applications │ # └────────────────────────────────────────────────┘ @transient @call_in_place def init_applications() -> None: try: InitPipeline().run() Log('Init: App::FreeCADInit.py done') except Exception as ex: Err(f'Error in init_applications {ex!s}') Err(utils.HLine) Err(traceback.format_exc()) Err(utils.HLine) # ┌────────────────────────────────────────────────┐ # │ Cleanup for next scripts │ # └────────────────────────────────────────────────┘ # Reset logger to no extra newline for subsequent scripts (Backwards compat) __logger.sep = "" # Clean global namespace transient.cleanup()