#!/usr/bin/env python3 """One interpreter start for the whole of 99-python3-appdirs. Emits `key=value' lines, one per line, in file order; the suite reads them back with grep. A python3 start on this arm is tens of seconds and every measurement here is an `os.getenv' and an `os.path.join' over strings under 64 characters, so the start is the entire cost of the suite and taking it once is the difference between a 30-second run and a ten-minute one. NOTHING HERE TOUCHES THE FILESYSTEM, AND THAT IS THE PACKAGE'S WHOLE SHAPE. appdirs COMPOSES PATHS. It never creates, reads, stats or opens anything -- there is not one filesystem call in the module -- so a fixture HOME need not exist. This probe sets HOME to the literal `/bst-appdirs-home', which is not a directory on this image and is never made one, and every expectation below is therefore a LITERAL STRING rather than something derived from whatever HOME the runner happened to have. `home_absent' at the tail asserts the directory still does not exist after all 100-odd calls, which is that claim measured rather than asserted from the source. THE ENVIRONMENT IS THE INPUT. On this platform every one of the seven directory functions is a `os.getenv(XDG_..., )' followed by a join, so each case below states the environment it ran under. `env' restores the whole of the XDG block after each call, so a case cannot leak into the next. TWO RULES THE VALUES OBEY. The runner parses its input line by line, so no value may contain a newline -- every value below is a single line. And no value may depend on the encoding stdout happens to be in: every one is an ASCII path, a name, or `yes'/`no'. """ import os import sys emitted = [] def emit(key, value): emitted.append(key) print('%s=%s' % (key, value)) try: import appdirs from appdirs import (AppDirs, user_data_dir, user_config_dir, user_cache_dir, user_state_dir, user_log_dir, site_data_dir, site_config_dir) except Exception as exc: # pragma: no cover print('import_error=%s: %s' % (type(exc).__name__, exc)) sys.exit(1) # ------------------------------------------------------------ environment ---- # HOME is synthetic on purpose -- see the docstring. The XDG block is cleared # to a known-absent state so that a case which does not name a variable is # measuring the module's DEFAULT and not the runner's environment. HOME = '/bst-appdirs-home' XDG = ('XDG_DATA_HOME', 'XDG_CONFIG_HOME', 'XDG_CACHE_HOME', 'XDG_STATE_HOME', 'XDG_DATA_DIRS', 'XDG_CONFIG_DIRS') os.environ['HOME'] = HOME for _v in XDG: os.environ.pop(_v, None) def env(**kw): """Run with the XDG block set to exactly `kw', then restore it. A value of None means "unset", which is not the same as "empty" -- the `*-empty' cases below turn on precisely that difference, because `os.getenv(name, default)' returns the empty string for a variable that is set to it and the default only for one that is absent.""" saved = {v: os.environ.get(v) for v in XDG} class _Ctx(object): def __enter__(self): for v in XDG: os.environ.pop(v, None) for k, val in kw.items(): if val is not None: os.environ[k] = val return self def __exit__(self, *a): for v in XDG: os.environ.pop(v, None) if saved[v] is not None: os.environ[v] = saved[v] return False return _Ctx() def call(fn, *args, **kw): """The returned path, or `!' on a raise. The exception NAME rather than its message: appdirs raises only where it joins None into a path, and CPython's wording for that has moved between releases while the type has not.""" try: return fn(*args, **kw) except Exception as exc: return '!%s' % type(exc).__name__ APP = 'MyApp' AUTHOR = 'MyCompany' VER = '1.0' # ---------------------------------------------------------------- identity --- emit('module_file', getattr(appdirs, '__file__', '')) emit('is_package', 'yes' if hasattr(appdirs, '__path__') else 'no') emit('version_attr', appdirs.__version__) emit('version_info', '.'.join(str(x) for x in appdirs.__version_info__)) emit('version_info_is_tuple', 'yes' if isinstance(appdirs.__version_info__, tuple) else 'no') # `system' IS COMPUTED AT IMPORT AND NEVER RE-READ. Every branch in the module # tests this module-level global, so which platform's rules a caller gets is # fixed at the first `import appdirs' and monkeypatching `sys.platform' # afterwards changes nothing. Naming it here is what makes every XDG # expectation below a statement about THIS image rather than about Linux in # general. emit('system', appdirs.system) emit('sys_platform', sys.platform) emit('system_is_platform', 'yes' if appdirs.system == sys.platform else 'no') emit('py3_flag', 'yes' if appdirs.PY3 else 'no') # NO `__all__'. `from appdirs import *' therefore exports every public name the # module binds -- including `os', `sys' and `unicode', which is a real hazard # and not a stylistic one. Both halves are asserted: that the list is absent, # and that a star-import consequently rebinds `os'. emit('has___all__', 'yes' if hasattr(appdirs, '__all__') else 'no') _ns = {} exec('from appdirs import *', _ns) emit('star_exports_os', 'yes' if 'os' in _ns else 'no') emit('star_exports_sys', 'yes' if 'sys' in _ns else 'no') emit('star_exports_unicode', 'yes' if 'unicode' in _ns else 'no') emit('star_exports_system', 'yes' if 'system' in _ns else 'no') # `_get_win_folder' IS NOT BOUND ON THIS PLATFORM. The alias is assigned inside # `if system == "win32"', so on Linux the name does not exist at all -- while # the four `_get_win_folder_with_*' implementations it would have chosen among # are all still defined, because they are module-level `def's. Dead weight # rather than dead code, and asserted because the four being present makes it # look as though the alias must be too. emit('has_get_win_folder', 'yes' if hasattr(appdirs, '_get_win_folder') else 'no') emit('has_win_registry', 'yes' if hasattr(appdirs, '_get_win_folder_from_registry') else 'no') emit('has_win_pywin32', 'yes' if hasattr(appdirs, '_get_win_folder_with_pywin32') else 'no') emit('has_win_ctypes', 'yes' if hasattr(appdirs, '_get_win_folder_with_ctypes') else 'no') emit('has_win_jna', 'yes' if hasattr(appdirs, '_get_win_folder_with_jna') else 'no') # --------------------------------------------------- user_*: XDG unset ------- # The documented defaults, which is what a caller gets on an image that sets no # XDG variables -- this one. with env(): emit('udata_default', call(user_data_dir, APP)) emit('uconfig_default', call(user_config_dir, APP)) emit('ucache_default', call(user_cache_dir, APP)) emit('ustate_default', call(user_state_dir, APP)) emit('ulog_default', call(user_log_dir, APP)) # appname None returns the BASE directory with nothing appended, and the # version is dropped with it -- `if appname and version' -- so a caller who # passes a version and no appname silently loses it. emit('udata_noname', call(user_data_dir)) emit('uconfig_noname', call(user_config_dir)) emit('ucache_noname', call(user_cache_dir)) emit('ustate_noname', call(user_state_dir)) emit('udata_noname_versioned', call(user_data_dir, None, None, VER)) # `user_log_dir' WITH NO APPNAME STILL APPENDS `log', because the opinion # block is not guarded by appname the way the version block is. emit('ulog_noname', call(user_log_dir)) # THE VERSION GOES ON THE END -- except in user_log_dir, below. emit('udata_versioned', call(user_data_dir, APP, None, VER)) emit('uconfig_versioned', call(user_config_dir, APP, None, VER)) emit('ucache_versioned', call(user_cache_dir, APP, None, VER)) emit('ustate_versioned', call(user_state_dir, APP, None, VER)) # `user_log_dir' PUTS THE VERSION BEFORE `log', NOT AFTER IT. It delegates # to user_cache_dir WITH the version, sets its own `version' to False so the # trailing block cannot fire, and only then appends `log'. So the log # directory of version 1.0 is a child of the versioned cache dir, which is # the opposite nesting from every other function here. emit('ulog_versioned', call(user_log_dir, APP, None, VER)) emit('ulog_versioned_is_cache_child', 'yes' if call(user_log_dir, APP, None, VER) == os.path.join(call(user_cache_dir, APP, None, VER), 'log') else 'no') # `opinion=False' DROPS THE `log' COMPONENT and gives back the cache dir # unchanged -- which means user_log_dir(opinion=False) IS user_cache_dir. emit('ulog_no_opinion', call(user_log_dir, APP, None, None, False)) emit('ulog_no_opinion_is_cache', 'yes' if call(user_log_dir, APP, None, None, False) == call(user_cache_dir, APP) else 'no') emit('ulog_no_opinion_versioned', call(user_log_dir, APP, None, VER, False)) # `opinion=False' ON user_cache_dir IS A NO-OP ON THIS PLATFORM -- the # parameter exists for the Windows "Cache" suffix alone, and the Unix branch # never reads it. Asserted because the signature offers it here. emit('ucache_no_opinion', call(user_cache_dir, APP, None, None, False)) emit('ucache_opinion_ignored', 'yes' if call(user_cache_dir, APP, None, None, False) == call(user_cache_dir, APP, None, None, True) else 'no') # -------------------------------- appauthor and roaming are Windows-only ----- # Both parameters are accepted on every platform and READ ON NONE BUT WINDOWS. # The ticket asks for this explicitly, and it is the most likely thing for a # caller to get wrong: a path that differs by author on Windows is the same path # here, so an application that keys its data by author is keying it by nothing. with env(): emit('udata_author', call(user_data_dir, APP, AUTHOR)) emit('udata_author_ignored', 'yes' if call(user_data_dir, APP, AUTHOR) == call(user_data_dir, APP) else 'no') # `appauthor=False' is the documented way to SUPPRESS the author component # on Windows. Here there is nothing to suppress, so it too is a no-op -- # and it does not raise, which a caller reading `if appauthor is not False' # might not expect on a platform that never reaches that line. emit('udata_author_false', call(user_data_dir, APP, False)) emit('uconfig_author', call(user_config_dir, APP, AUTHOR)) emit('ucache_author', call(user_cache_dir, APP, AUTHOR)) emit('ustate_author', call(user_state_dir, APP, AUTHOR)) emit('ulog_author', call(user_log_dir, APP, AUTHOR)) emit('sdata_author', call(site_data_dir, APP, AUTHOR)) emit('sconfig_author', call(site_config_dir, APP, AUTHOR)) # `roaming' is the Windows roaming-profile switch and is the fourth # positional of user_data_dir, user_config_dir and user_state_dir. Setting # it changes nothing on this platform. emit('udata_roaming', call(user_data_dir, APP, None, None, True)) emit('udata_roaming_ignored', 'yes' if call(user_data_dir, APP, None, None, True) == call(user_data_dir, APP) else 'no') emit('uconfig_roaming', call(user_config_dir, APP, None, None, True)) emit('ustate_roaming', call(user_state_dir, APP, None, None, True)) emit('udata_roaming_versioned', call(user_data_dir, APP, AUTHOR, VER, True)) # ------------------------------------------- user_*: the XDG variables win --- # Each of the four user directories reads ONE variable, and the mapping is # asserted one variable at a time so that a case names which one moved. with env(XDG_DATA_HOME='/xdg/data'): emit('udata_xdg', call(user_data_dir, APP)) emit('udata_xdg_versioned', call(user_data_dir, APP, AUTHOR, VER)) emit('udata_xdg_noname', call(user_data_dir)) # The other three do NOT read it. emit('uconfig_under_data_home', call(user_config_dir, APP)) with env(XDG_CONFIG_HOME='/xdg/config'): emit('uconfig_xdg', call(user_config_dir, APP)) emit('uconfig_xdg_versioned', call(user_config_dir, APP, AUTHOR, VER)) emit('udata_under_config_home', call(user_data_dir, APP)) with env(XDG_CACHE_HOME='/xdg/cache'): emit('ucache_xdg', call(user_cache_dir, APP)) emit('ucache_xdg_versioned', call(user_cache_dir, APP, AUTHOR, VER)) # user_log_dir IS user_cache_dir plus `log', so XDG_CACHE_HOME moves it too # -- there is no XDG_LOG_HOME and no separate variable to set. emit('ulog_xdg', call(user_log_dir, APP)) with env(XDG_STATE_HOME='/xdg/state'): emit('ustate_xdg', call(user_state_dir, APP)) emit('ustate_xdg_versioned', call(user_state_dir, APP, AUTHOR, VER)) # All four at once, which is the shape a desktop session actually sets. with env(XDG_DATA_HOME='/xdg/data', XDG_CONFIG_HOME='/xdg/config', XDG_CACHE_HOME='/xdg/cache', XDG_STATE_HOME='/xdg/state'): emit('all_udata', call(user_data_dir, APP)) emit('all_uconfig', call(user_config_dir, APP)) emit('all_ucache', call(user_cache_dir, APP)) emit('all_ustate', call(user_state_dir, APP)) emit('all_ulog', call(user_log_dir, APP)) # A RELATIVE XDG VALUE IS USED AS-IS. The XDG base-directory specification says # a value that is not an absolute path "must be ignored"; appdirs does not check, # so the caller gets a relative path back and will write into the process's # current directory. with env(XDG_DATA_HOME='relative/data'): emit('udata_relative', call(user_data_dir, APP)) emit('udata_relative_isabs', 'yes' if os.path.isabs(call(user_data_dir, APP)) else 'no') # AN EMPTY XDG VALUE IS NOT THE DEFAULT -- it is the empty string, and joining # an appname onto it yields a BARE RELATIVE NAME. `os.getenv(name, default)' # returns the default only for an ABSENT variable, and the spec treats empty and # absent alike. This is the same defect as `udata_relative' at its worst: the # answer is `MyApp', a path with no directory part at all. with env(XDG_DATA_HOME=''): emit('udata_empty', call(user_data_dir, APP)) emit('udata_empty_isabs', 'yes' if os.path.isabs(call(user_data_dir, APP)) else 'no') with env(XDG_CONFIG_HOME=''): emit('uconfig_empty', call(user_config_dir, APP)) with env(XDG_CACHE_HOME=''): emit('ucache_empty', call(user_cache_dir, APP)) emit('ulog_empty', call(user_log_dir, APP)) with env(XDG_STATE_HOME=''): emit('ustate_empty', call(user_state_dir, APP)) # A `~' IN AN XDG VALUE IS NOT EXPANDED for the user directories -- the value # goes straight into os.path.join. The SITE directories DO expanduser each # element, which is the asymmetry `sdata_tilde' below pins. with env(XDG_DATA_HOME='~/mydata'): emit('udata_tilde', call(user_data_dir, APP)) # ------------------------------------------------------------- site_* -------- # The site directories take a LIST, and their `multipath' parameter chooses # between the first element and all of them joined by os.pathsep. emit('pathsep', os.pathsep) with env(): emit('sdata_default', call(site_data_dir, APP)) emit('sdata_default_multi', call(site_data_dir, APP, None, None, True)) emit('sdata_default_noname', call(site_data_dir)) emit('sdata_default_noname_multi', call(site_data_dir, None, None, None, True)) emit('sconfig_default', call(site_config_dir, APP)) emit('sconfig_default_multi', call(site_config_dir, APP, None, None, True)) emit('sconfig_default_noname', call(site_config_dir)) # THE VERSION IS FOLDED INTO THE APPNAME, not appended at the end, so it # lands under EVERY element of a multipath answer rather than only the last. emit('sdata_versioned', call(site_data_dir, APP, None, VER)) emit('sdata_versioned_multi', call(site_data_dir, APP, None, VER, True)) emit('sconfig_versioned', call(site_config_dir, APP, None, VER)) emit('sconfig_versioned_multi', call(site_config_dir, APP, None, VER, True)) # A VERSION WITH NO APPNAME IS DROPPED HERE TOO, and the multipath form then # returns the bare system directories. emit('sdata_noname_versioned', call(site_data_dir, None, None, VER)) with env(XDG_DATA_DIRS='/opt/share:/srv/share'): emit('sdata_xdg', call(site_data_dir, APP)) emit('sdata_xdg_multi', call(site_data_dir, APP, None, None, True)) emit('sdata_xdg_versioned_multi', call(site_data_dir, APP, None, VER, True)) with env(XDG_CONFIG_DIRS='/opt/etc:/srv/etc'): emit('sconfig_xdg', call(site_config_dir, APP)) emit('sconfig_xdg_multi', call(site_config_dir, APP, None, None, True)) # A TRAILING SEPARATOR IS STRIPPED -- `x.rstrip(os.sep)' -- and it is rstrip, # not a single-character chop, so ALL of them go. A lone `/' therefore strips # to the EMPTY STRING and the join puts the result back at the root: an # XDG_DATA_DIRS of `/' answers `/MyApp'. with env(XDG_DATA_DIRS='/opt/share/'): emit('sdata_trailing_slash', call(site_data_dir, APP)) with env(XDG_DATA_DIRS='/opt/share///'): emit('sdata_trailing_slashes', call(site_data_dir, APP)) with env(XDG_DATA_DIRS='/'): emit('sdata_root_only', call(site_data_dir, APP)) with env(XDG_CONFIG_DIRS='/'): emit('sconfig_root_only', call(site_config_dir, APP)) # AN EMPTY XDG_DATA_DIRS DOES NOT FALL BACK EITHER, and here the empty string # becomes an absolute path rather than a relative one, because the elements are # joined with os.sep rather than os.path.join: `'' + '/' + 'MyApp''. So the two # empty-variable defects answer differently -- `MyApp' for the user directory # and `/MyApp' for the site one -- which is worth having side by side. with env(XDG_DATA_DIRS=''): emit('sdata_empty', call(site_data_dir, APP)) emit('sdata_empty_isabs', 'yes' if os.path.isabs(call(site_data_dir, APP)) else 'no') with env(XDG_CONFIG_DIRS=''): emit('sconfig_empty', call(site_config_dir, APP)) # EACH ELEMENT IS expanduser'd, unlike the user directories -- see # `udata_tilde'. The asymmetry is upstream's and is asserted from both sides. with env(XDG_DATA_DIRS='~/share:/srv/share'): emit('sdata_tilde', call(site_data_dir, APP)) emit('sdata_tilde_multi', call(site_data_dir, APP, None, None, True)) with env(XDG_CONFIG_DIRS='~/etc'): emit('sconfig_tilde', call(site_config_dir, APP)) # A SINGLE-ELEMENT LIST MAKES multipath INDISTINGUISHABLE from the default, # which is why the default site_config_dir (`/etc/xdg', one element) reads the # same both ways while site_data_dir (two elements) does not. with env(XDG_DATA_DIRS='/only/one'): emit('sdata_one_multi', call(site_data_dir, APP, None, None, True)) emit('sdata_one_single', call(site_data_dir, APP)) emit('sdata_one_same', 'yes' if call(site_data_dir, APP, None, None, True) == call(site_data_dir, APP) else 'no') # AN EMPTY ELEMENT IN THE MIDDLE is kept, not skipped: the list is split on # os.pathsep with no filtering, so `a::b' is three elements and the middle one # resolves to the root. with env(XDG_DATA_DIRS='/a::/b'): emit('sdata_empty_element_multi', call(site_data_dir, APP, None, None, True)) # ---------------------------------------------------------- the AppDirs class - # The class is a thin property wrapper. Every property is asserted to return # THE SAME STRING as the free function for the same arguments, because that # equivalence is the only thing the class promises -- and one of them does not # keep it, below. with env(): d = AppDirs(APP, AUTHOR, version=VER) emit('cls_udata', d.user_data_dir) emit('cls_uconfig', d.user_config_dir) emit('cls_ucache', d.user_cache_dir) emit('cls_ustate', d.user_state_dir) emit('cls_ulog', d.user_log_dir) emit('cls_sdata', d.site_data_dir) emit('cls_sconfig', d.site_config_dir) emit('cls_matches_free', 'yes' if (d.user_data_dir == user_data_dir(APP, AUTHOR, VER) and d.user_config_dir == user_config_dir(APP, AUTHOR, VER) and d.user_cache_dir == user_cache_dir(APP, AUTHOR, VER) and d.user_state_dir == user_state_dir(APP, AUTHOR, VER) and d.user_log_dir == user_log_dir(APP, AUTHOR, VER) and d.site_data_dir == site_data_dir(APP, AUTHOR, VER) and d.site_config_dir == site_config_dir(APP, AUTHOR, VER)) else 'no') # The attributes are plain and mutable, and the properties recompute on # every read -- so an AppDirs is a live view of its own fields rather than a # snapshot taken in __init__. d2 = AppDirs(APP) emit('cls_before_mutate', d2.user_data_dir) d2.version = VER emit('cls_after_mutate', d2.user_data_dir) # `multipath' IS A CONSTRUCTOR ARGUMENT and reaches only the two site # properties. dm = AppDirs(APP, AUTHOR, multipath=True) emit('cls_sdata_multi', dm.site_data_dir) emit('cls_sconfig_multi', dm.site_config_dir) # `roaming' REACHES ONLY THREE OF THE SEVEN PROPERTIES -- user_data_dir, # user_config_dir and site_data_dir take it through their free functions, # while `AppDirs.user_state_dir' does NOT forward it even though the free # `user_state_dir' accepts one. Invisible on this platform, where roaming # is ignored anyway, and asserted here as the shape it is so that the port's # record says the asymmetry was looked at rather than missed. dr = AppDirs(APP, AUTHOR, roaming=True) emit('cls_roaming_udata', dr.user_data_dir) emit('cls_roaming_ustate', dr.user_state_dir) emit('cls_roaming_same_as_not', 'yes' if dr.user_data_dir == AppDirs(APP, AUTHOR).user_data_dir else 'no') # An AppDirs with no appname at all -- the class does not require one, and # every property falls through to the base directory. dn = AppDirs() emit('cls_noname_udata', dn.user_data_dir) emit('cls_noname_ulog', dn.user_log_dir) # ------------------------------------------------------- the argument types -- # A non-string appname is JOINED, NOT VALIDATED, so the failure surfaces as a # TypeError out of os.path.join rather than as anything appdirs says. Asserted # by exception name, because that is the contract a caller has to code against. with env(): emit('udata_int_appname', call(user_data_dir, 7)) emit('sdata_int_appname', call(site_data_dir, 7)) # An EMPTY appname is falsy, so it behaves exactly like None rather than # adding an empty component. emit('udata_empty_appname', call(user_data_dir, '')) emit('udata_empty_appname_is_noname', 'yes' if call(user_data_dir, '') == call(user_data_dir) else 'no') # A version that is not a string reaches os.path.join too -- but only when # an appname is present, which is what makes `udata_int_version' a raise and # `udata_noname_versioned' above a quiet drop. emit('udata_int_version', call(user_data_dir, APP, None, 7)) # An appname with a separator in it is NOT sanitised: it becomes two # components. emit('udata_sep_appname', call(user_data_dir, 'My/App')) # --------------------------------------------------------------- filesystem -- # THE MODULE NEVER TOUCHES THE FILESYSTEM. Every path above was composed # against a HOME that does not exist, and after all of them it still does not: # no directory was created, and nothing raised for reading one that was absent. emit('home_env', os.environ.get('HOME', '')) emit('home_absent', 'yes' if not os.path.exists(HOME) else 'no') emit('cwd_unchanged', 'yes' if os.path.isabs(os.getcwd()) else 'no') emit('emitted_count', len(emitted)) print('probe_done=yes')