14
14
# along with this program; if not, write to the Free Software
15
15
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA
18
"""bzr python plugin support.
20
When load_plugins() is invoked, any python module in any directory in
21
$BZR_PLUGIN_PATH will be imported. The module will be imported as
22
'bzrlib.plugins.$BASENAME(PLUGIN)'. In the plugin's main body, it should
23
update any bzrlib registries it wants to extend.
17
"""Breezy plugin support.
19
Which plugins to load can be configured by setting these environment variables:
21
- BRZ_PLUGIN_PATH: Paths to look for plugins in.
22
- BRZ_DISABLE_PLUGINS: Plugin names to block from being loaded.
23
- BRZ_PLUGINS_AT: Name and paths for plugins to load from specific locations.
25
The interfaces this module exports include:
27
- disable_plugins: Load no plugins and stop future automatic loading.
28
- load_plugins: Load all plugins that can be found in configuration.
29
- describe_plugins: Generate text for each loaded (or failed) plugin.
30
- extend_path: Mechanism by which the plugins package path is set.
25
32
See the plugin-api developer documentation for information about writing
28
BZR_PLUGIN_PATH is also honoured for any plugins imported via
29
'import bzrlib.plugins.PLUGINNAME', as long as set_plugins_path has been
36
from __future__ import absolute_import
36
from bzrlib import osutils
38
from bzrlib.lazy_import import lazy_import
45
from .lazy_import import lazy_import
40
46
lazy_import(globals(), """
49
from importlib import util as importlib_util
46
_format_version_tuple,
52
from bzrlib import plugins as _mod_plugins
55
from bzrlib.symbol_versioning import (
61
DEFAULT_PLUGIN_PATH = None
63
_plugins_disabled = False
66
def are_plugins_disabled():
67
return _plugins_disabled
70
def disable_plugins():
61
_MODULE_PREFIX = "breezy.plugins."
63
if __debug__ or sys.version_info > (3,):
69
def disable_plugins(state=None):
71
70
"""Disable loading plugins.
73
72
Future calls to load_plugins() will be ignored.
74
:param state: The library state object that records loaded plugins.
75
global _plugins_disabled
76
_plugins_disabled = True
80
def _strip_trailing_sep(path):
81
return path.rstrip("\\/")
84
def set_plugins_path(path=None):
85
"""Set the path for plugins to be loaded from.
77
state = breezy.global_state
81
def load_plugins(path=None, state=None):
82
"""Load breezy plugins.
84
The environment variable BRZ_PLUGIN_PATH is considered a delimited
85
set of paths to look through. Each entry is searched for `*.py`
86
files (and whatever other extensions are used in the platform,
87
89
:param path: The list of paths to search for plugins. By default,
88
path will be determined using get_standard_plugins_path.
89
if path is [], no plugins can be loaded.
90
it is populated from the __path__ of the breezy.plugins package.
91
:param state: The library state object that records loaded plugins.
94
state = breezy.global_state
95
if getattr(state, 'plugins', None) is not None:
96
# People can make sure plugins are loaded, they just won't be twice
92
path = get_standard_plugins_path()
93
_mod_plugins.__path__ = path
94
PluginImporter.reset()
95
# Set up a blacklist for disabled plugins
96
disabled_plugins = os.environ.get('BZR_DISABLE_PLUGINS', None)
97
if disabled_plugins is not None:
98
for name in disabled_plugins.split(os.pathsep):
99
PluginImporter.blacklist.add('bzrlib.plugins.' + name)
100
# Set up a the specific paths for plugins
101
specific_plugins = os.environ.get('BZR_PLUGINS_AT', None)
102
if specific_plugins is not None:
103
for spec in specific_plugins.split(os.pathsep):
104
plugin_name, plugin_path = spec.split('@')
105
PluginImporter.specific_paths[
106
'bzrlib.plugins.%s' % plugin_name] = plugin_path
110
def _append_new_path(paths, new_path):
111
"""Append a new path if it set and not already known."""
112
if new_path is not None and new_path not in paths:
113
paths.append(new_path)
117
def get_core_plugin_path():
119
bzr_exe = bool(getattr(sys, 'frozen', None))
120
if bzr_exe: # expand path for bzr.exe
100
# Calls back into extend_path() here
101
from breezy.plugins import __path__ as path
103
state.plugin_warnings = {}
104
_load_plugins(state, path)
105
state.plugins = plugins()
108
def extend_path(path, name):
109
"""Helper so breezy.plugins can be a sort of namespace package.
111
To be used in similar fashion to pkgutil.extend_path:
113
from breezy.plugins import extend_path
114
__path__ = extend_path(__path__, __name__)
116
Inspects the BRZ_PLUGIN* envvars, sys.path, and the filesystem to find
117
plugins. May mutate sys.modules in order to block plugin loading, and may
118
append a new meta path finder to sys.meta_path for plugins@ loading.
120
Returns a list of paths to import from, as an enhanced object that also
121
contains details of the other configuration used.
123
blocks = _env_disable_plugins()
124
_block_plugins(blocks)
126
extra_details = _env_plugins_at()
127
_install_importer_if_needed(extra_details)
129
paths = _iter_plugin_paths(_env_plugin_path(), path)
131
return _Path(name, blocks, extra_details, paths)
135
"""List type to use as __path__ but containing additional details.
137
Python 3 allows any iterable for __path__ but Python 2 is more fussy.
140
def __init__(self, package_name, blocked, extra, paths):
141
super(_Path, self).__init__(paths)
142
self.package_name = package_name
143
self.blocked_names = blocked
144
self.extra_details = extra
147
return "%s(%r, %r, %r, %s)" % (
148
self.__class__.__name__, self.package_name, self.blocked_names,
149
self.extra_details, list.__repr__(self))
152
def _expect_identifier(name, env_key, env_value):
153
"""Validate given name from envvar is usable as a Python identifier.
155
Returns the name as a native str, or None if it was invalid.
157
Per PEP 3131 this is no longer strictly correct for Python 3, but as MvL
158
didn't include a neat way to check except eval, this enforces ascii.
160
if re.match(r"^[A-Za-z_][A-Za-z0-9_]*$", name) is None:
161
trace.warning("Invalid name '%s' in %s='%s'", name, env_key, env_value)
166
def _env_disable_plugins(key='BRZ_DISABLE_PLUGINS'):
167
"""Gives list of names for plugins to disable from environ key."""
169
env = osutils.path_from_environ(key)
171
for name in env.split(os.pathsep):
172
name = _expect_identifier(name, key, env)
174
disabled_names.append(name)
175
return disabled_names
178
def _env_plugins_at(key='BRZ_PLUGINS_AT'):
179
"""Gives list of names and paths of specific plugins from environ key."""
181
env = osutils.path_from_environ(key)
183
for pair in env.split(os.pathsep):
185
name, path = pair.split('@', 1)
188
name = osutils.basename(path).split('.', 1)[0]
189
name = _expect_identifier(name, key, env)
191
plugin_details.append((name, path))
192
return plugin_details
195
def _env_plugin_path(key='BRZ_PLUGIN_PATH'):
196
"""Gives list of paths and contexts for plugins from environ key.
198
Each entry is either a specific path to load plugins from and the value
199
'path', or None and one of the three values 'user', 'core', 'site'.
202
env = osutils.path_from_environ(key)
203
defaults = {"user": not env, "core": True, "site": True}
205
# Add paths specified by user in order
206
for p in env.split(os.pathsep):
207
flag, name = p[:1], p[1:]
208
if flag in ("+", "-") and name in defaults:
209
if flag == "+" and defaults[name] is not None:
210
path_details.append((None, name))
211
defaults[name] = None
213
path_details.append((p, 'path'))
215
# Add any remaining default paths
216
for name in ('user', 'core', 'site'):
218
path_details.append((None, name))
223
def _iter_plugin_paths(paths_from_env, core_paths):
224
"""Generate paths using paths_from_env and core_paths."""
225
# GZ 2017-06-02: This is kinda horrid, should make better.
226
for path, context in paths_from_env:
227
if context == 'path':
229
elif context == 'user':
230
path = get_user_plugin_path()
231
if os.path.isdir(path):
233
elif context == 'core':
234
for path in _get_core_plugin_paths(core_paths):
236
elif context == 'site':
237
for path in _get_site_plugin_paths(sys.path):
238
if os.path.isdir(path):
242
def _install_importer_if_needed(plugin_details):
243
"""Install a meta path finder to handle plugin_details if any."""
245
finder = _PluginsAtFinder(_MODULE_PREFIX, plugin_details)
246
# For Python 3, must insert before default PathFinder to override.
247
sys.meta_path.insert(2, finder)
250
def _load_plugins(state, paths):
251
"""Do the importing all plugins from paths."""
252
imported_names = set()
253
for name, path in _iter_possible_plugins(paths):
254
if name not in imported_names:
255
msg = _load_plugin_module(name, path)
257
state.plugin_warnings.setdefault(name, []).append(msg)
258
imported_names.add(name)
261
def _block_plugins(names):
262
"""Add names to sys.modules to block future imports."""
264
package_name = _MODULE_PREFIX + name
265
if sys.modules.get(package_name) is not None:
266
trace.mutter("Blocked plugin %s already loaded.", name)
267
sys.modules[package_name] = None
270
def _get_package_init(package_path):
271
"""Get path of __init__ file from package_path or None if not a package."""
272
init_path = osutils.pathjoin(package_path, "__init__.py")
273
if os.path.exists(init_path):
275
init_path = init_path[:-3] + COMPILED_EXT
276
if os.path.exists(init_path):
281
def _iter_possible_plugins(plugin_paths):
282
"""Generate names and paths of possible plugins from plugin_paths."""
283
# Inspect any from BRZ_PLUGINS_AT first.
284
for name, path in getattr(plugin_paths, "extra_details", ()):
286
# Then walk over files and directories in the paths from the package.
287
for path in plugin_paths:
288
if os.path.isfile(path):
289
if path.endswith(".zip"):
290
trace.mutter("Don't yet support loading plugins from zip.")
292
for name, path in _walk_modules(path):
296
def _walk_modules(path):
297
"""Generate name and path of modules and packages on path."""
298
for root, dirs, files in os.walk(path):
302
if f.endswith((".py", COMPILED_EXT)):
303
yield f.rsplit(".", 1)[0], root
307
package_dir = osutils.pathjoin(root, d)
308
fullpath = _get_package_init(package_dir)
309
if fullpath is not None:
311
# Don't descend into subdirectories
315
def describe_plugins(show_paths=False, state=None):
316
"""Generate text description of plugins.
318
Includes both those that have loaded, and those that failed to load.
320
:param show_paths: If true, include the plugin path.
321
:param state: The library state object to inspect.
322
:returns: Iterator of text lines (including newlines.)
325
state = breezy.global_state
326
all_names = sorted(set(state.plugins).union(state.plugin_warnings))
327
for name in all_names:
328
if name in state.plugins:
329
plugin = state.plugins[name]
330
version = plugin.__version__
331
if version == 'unknown':
333
yield '%s %s\n' % (name, version)
334
d = plugin.module.__doc__
336
doc = d.split('\n')[0]
338
doc = '(no description)'
339
yield (" %s\n" % doc)
341
yield (" %s\n" % plugin.path())
343
yield "%s (failed to load)\n" % name
344
if name in state.plugin_warnings:
345
for line in state.plugin_warnings[name]:
346
yield " ** " + line + '\n'
350
def _get_core_plugin_paths(existing_paths):
351
"""Generate possible locations for plugins based on existing_paths."""
352
if getattr(sys, 'frozen', False):
121
353
# We need to use relative path to system-wide plugin
122
# directory because bzrlib from standalone bzr.exe
354
# directory because breezy from standalone brz.exe
123
355
# could be imported by another standalone program
124
356
# (e.g. bzr-config; or TortoiseBzr/Olive if/when they
125
357
# will become standalone exe). [bialix 20071123]
126
358
# __file__ typically is
127
# C:\Program Files\Bazaar\lib\library.zip\bzrlib\plugin.pyc
359
# C:\Program Files\Bazaar\lib\library.zip\breezy\plugin.pyc
128
360
# then plugins directory is
129
361
# C:\Program Files\Bazaar\plugins
130
362
# so relative path is ../../../plugins
131
core_path = osutils.abspath(osutils.pathjoin(
132
osutils.dirname(__file__), '../../../plugins'))
363
yield osutils.abspath(osutils.pathjoin(
364
osutils.dirname(__file__), '../../../plugins'))
133
365
else: # don't look inside library.zip
134
# search the plugin path before the bzrlib installed dir
135
core_path = os.path.dirname(_mod_plugins.__file__)
139
def get_site_plugin_path():
140
"""Returns the path for the site installed plugins."""
141
if sys.platform == 'win32':
142
# We don't have (yet) a good answer for windows since that is certainly
143
# related to the way we build the installers. -- vila20090821
147
from distutils.sysconfig import get_python_lib
149
# If distutuils is not available, we just don't know where they are
152
site_path = osutils.pathjoin(get_python_lib(), 'bzrlib', 'plugins')
366
for path in existing_paths:
370
def _get_site_plugin_paths(sys_paths):
371
"""Generate possible locations for plugins from given sys_paths."""
372
for path in sys_paths:
373
if os.path.basename(path) in ('dist-packages', 'site-packages'):
374
yield osutils.pathjoin(path, 'breezy', 'plugins')
156
377
def get_user_plugin_path():
157
378
return osutils.pathjoin(config.config_dir(), 'plugins')
160
def get_standard_plugins_path():
161
"""Determine a plugin path suitable for general use."""
162
# Ad-Hoc default: core is not overriden by site but user can overrides both
163
# The rationale is that:
164
# - 'site' comes last, because these plugins should always be available and
165
# are supposed to be in sync with the bzr installed on site.
166
# - 'core' comes before 'site' so that running bzr from sources or a user
167
# installed version overrides the site version.
168
# - 'user' comes first, because... user is always right.
169
# - the above rules clearly defines which plugin version will be loaded if
170
# several exist. Yet, it is sometimes desirable to disable some directory
171
# so that a set of plugins is disabled as once. This can be done via
172
# -site, -core, -user.
174
env_paths = os.environ.get('BZR_PLUGIN_PATH', '+user').split(os.pathsep)
175
defaults = ['+core', '+site']
177
# The predefined references
178
refs = dict(core=get_core_plugin_path(),
179
site=get_site_plugin_path(),
180
user=get_user_plugin_path())
182
# Unset paths that should be removed
183
for k,v in refs.iteritems():
185
# defaults can never mention removing paths as that will make it
186
# impossible for the user to revoke these removals.
187
if removed in env_paths:
188
env_paths.remove(removed)
193
for p in env_paths + defaults:
194
if p.startswith('+'):
195
# Resolve references if they are known
199
# Leave them untouched so user can still use paths starting
202
_append_new_path(paths, p)
204
# Get rid of trailing slashes, since Python can't handle them when
205
# it tries to import modules.
206
paths = map(_strip_trailing_sep, paths)
210
def load_plugins(path=None):
211
"""Load bzrlib plugins.
213
The environment variable BZR_PLUGIN_PATH is considered a delimited
214
set of paths to look through. Each entry is searched for *.py
215
files (and whatever other extensions are used in the platform,
218
load_from_path() provides the underlying mechanism and is called with
219
the default directory list to provide the normal behaviour.
221
:param path: The list of paths to search for plugins. By default,
222
path will be determined using get_standard_plugins_path.
223
if path is [], no plugins can be loaded.
227
# People can make sure plugins are loaded, they just won't be twice
231
# scan for all plugins in the path.
232
load_from_path(set_plugins_path(path))
235
def load_from_path(dirs):
236
"""Load bzrlib plugins found in each dir in dirs.
238
Loading a plugin means importing it into the python interpreter.
239
The plugin is expected to make calls to register commands when
240
it's loaded (or perhaps access other hooks in future.)
242
Plugins are loaded into bzrlib.plugins.NAME, and can be found there
243
for future reference.
245
The python module path for bzrlib.plugins will be modified to be 'dirs'.
247
# Explicitly load the plugins with a specific path
248
for fullname, path in PluginImporter.specific_paths.iteritems():
249
name = fullname[len('bzrlib.plugins.'):]
250
_load_plugin_module(name, path)
252
# We need to strip the trailing separators here as well as in the
253
# set_plugins_path function because calling code can pass anything in to
254
# this function, and since it sets plugins.__path__, it should set it to
255
# something that will be valid for Python to use (in case people try to
256
# run "import bzrlib.plugins.PLUGINNAME" after calling this function).
257
_mod_plugins.__path__ = map(_strip_trailing_sep, dirs)
261
trace.mutter('looking for plugins in %s', d)
266
# backwards compatability: load_from_dirs was the old name
267
# This was changed in 0.15
268
load_from_dirs = load_from_path
271
def _find_plugin_module(dir, name):
272
"""Check if there is a valid python module that can be loaded as a plugin.
274
:param dir: The directory where the search is performed.
275
:param path: An existing file path, either a python file or a package
278
:return: (name, path, description) name is the module name, path is the
279
file to load and description is the tuple returned by
282
path = osutils.pathjoin(dir, name)
283
if os.path.isdir(path):
284
# Check for a valid __init__.py file, valid suffixes depends on -O and
285
# can be .py, .pyc and .pyo
286
for suffix, mode, kind in imp.get_suffixes():
287
if kind not in (imp.PY_SOURCE, imp.PY_COMPILED):
288
# We don't recognize compiled modules (.so, .dll, etc)
290
init_path = osutils.pathjoin(path, '__init__' + suffix)
291
if os.path.isfile(init_path):
292
return name, init_path, (suffix, mode, kind)
294
for suffix, mode, kind in imp.get_suffixes():
295
if name.endswith(suffix):
296
# Clean up the module name
297
name = name[:-len(suffix)]
298
if kind == imp.C_EXTENSION and name.endswith('module'):
299
name = name[:-len('module')]
300
return name, path, (suffix, mode, kind)
301
# There is no python module here
302
return None, None, (None, None, None)
381
def record_plugin_warning(warning_message):
382
trace.mutter(warning_message)
383
return warning_message
305
386
def _load_plugin_module(name, dir):
306
"""Load plugin name from dir.
387
"""Load plugin by name.
308
:param name: The plugin name in the bzrlib.plugins namespace.
389
:param name: The plugin name in the breezy.plugins namespace.
309
390
:param dir: The directory the plugin is loaded from for error messages.
311
if ('bzrlib.plugins.%s' % name) in PluginImporter.blacklist:
392
if _MODULE_PREFIX + name in sys.modules:
314
exec "import bzrlib.plugins.%s" % name in {}
315
except KeyboardInterrupt:
317
except errors.IncompatibleAPI, e:
318
trace.warning("Unable to load plugin %r. It requested API version "
395
__import__(_MODULE_PREFIX + name)
396
except errors.IncompatibleAPI as e:
398
"Unable to load plugin %r. It requested API version "
319
399
"%s of module %s but the minimum exported version is %s, and "
320
400
"the maximum is %s" %
321
401
(name, e.wanted, e.api, e.minimum, e.current))
323
trace.warning("%s" % e)
402
return record_plugin_warning(warning_message)
403
except Exception as e:
404
trace.log_exception_quietly()
405
if 'error' in debug.debug_flags:
406
trace.print_exception(sys.exc_info(), sys.stderr)
407
# GZ 2017-06-02: Move this name checking up a level, no point trying
408
# to import things with bad names.
324
409
if re.search('\.|-| ', name):
325
410
sanitised_name = re.sub('[-. ]', '_', name)
326
if sanitised_name.startswith('bzr_'):
327
sanitised_name = sanitised_name[len('bzr_'):]
411
if sanitised_name.startswith('brz_'):
412
sanitised_name = sanitised_name[len('brz_'):]
328
413
trace.warning("Unable to load %r in %r as a plugin because the "
329
414
"file path isn't a valid module name; try renaming "
330
415
"it to %r." % (name, dir, sanitised_name))
332
trace.warning('Unable to load plugin %r from %r' % (name, dir))
333
trace.log_exception_quietly()
334
if 'error' in debug.debug_flags:
335
trace.print_exception(sys.exc_info(), sys.stderr)
338
def load_from_dir(d):
339
"""Load the plugins in directory d.
341
d must be in the plugins module path already.
342
This function is called once for each directory in the module path.
345
for p in os.listdir(d):
346
name, path, desc = _find_plugin_module(d, p)
348
if name == '__init__':
349
# We do nothing with the __init__.py file in directories from
350
# the bzrlib.plugins module path, we may want to, one day
352
continue # We don't load __init__.py in the plugins dirs
353
elif getattr(_mod_plugins, name, None) is not None:
354
# The module has already been loaded from another directory
355
# during a previous call.
356
# FIXME: There should be a better way to report masked plugins
358
trace.mutter('Plugin name %s already loaded', name)
360
plugin_names.add(name)
362
for name in plugin_names:
363
_load_plugin_module(name, d)
417
return record_plugin_warning(
418
'Unable to load plugin %r from %r: %s' % (name, dir, e))
499
553
version_info = getattr(self.module, 'version_info', None)
500
554
if version_info is not None:
502
if isinstance(version_info, types.StringType):
556
if isinstance(version_info, str):
503
557
version_info = version_info.split('.')
504
558
elif len(version_info) == 3:
505
559
version_info = tuple(version_info) + ('final', 0)
507
561
# The given version_info isn't even iteratible
508
562
trace.log_exception_quietly()
509
563
version_info = (version_info,)
510
564
return version_info
512
def _get__version__(self):
567
def __version__(self):
513
568
version_info = self.version_info()
514
569
if version_info is None or len(version_info) == 0:
517
version_string = _format_version_tuple(version_info)
518
except (ValueError, TypeError, IndexError), e:
572
version_string = breezy._format_version_tuple(version_info)
573
except (ValueError, TypeError, IndexError):
519
574
trace.log_exception_quietly()
520
# try to return something usefull for bad plugins, in stead of
575
# Try to show something for the version anyway
522
576
version_string = '.'.join(map(str, version_info))
523
577
return version_string
525
__version__ = property(_get__version__)
528
class _PluginImporter(object):
529
"""An importer tailored to bzr specific needs.
531
This is a singleton that takes care of:
532
- disabled plugins specified in 'blacklist',
533
- plugins that needs to be loaded from specific directories.
540
self.blacklist = set()
541
self.specific_paths = {}
543
def find_module(self, fullname, parent_path=None):
544
"""Search a plugin module.
546
Disabled plugins raise an import error, plugins with specific paths
547
returns a specific loader.
549
:return: None if the plugin doesn't need special handling, self
552
if not fullname.startswith('bzrlib.plugins.'):
554
if fullname in self.blacklist:
555
raise ImportError('%s is disabled' % fullname)
556
if fullname in self.specific_paths:
580
class _PluginsAtFinder(object):
581
"""Meta path finder to support BRZ_PLUGINS_AT configuration."""
583
def __init__(self, prefix, names_and_paths):
585
self.names_to_path = dict((prefix + n, p) for n, p in names_and_paths)
588
return "<%s %r>" % (self.__class__.__name__, self.prefix)
590
def find_spec(self, fullname, paths, target=None):
591
"""New module spec returning find method."""
592
if fullname not in self.names_to_path:
594
path = self.names_to_path[fullname]
595
if os.path.isdir(path):
596
path = _get_package_init(path)
598
# GZ 2017-06-02: Any reason to block loading of the name from
599
# further down the path like this?
600
raise ImportError("Not loading namespace package %s as %s" % (
602
return importlib_util.spec_from_file_location(fullname, path)
604
def find_module(self, fullname, path):
605
"""Old PEP 302 import hook find_module method."""
606
if fullname not in self.names_to_path:
608
return _LegacyLoader(self.names_to_path[fullname])
611
class _LegacyLoader(object):
612
"""Source loader implementation for Python versions without importlib."""
614
def __init__(self, filepath):
615
self.filepath = filepath
618
return "<%s %r>" % (self.__class__.__name__, self.filepath)
560
620
def load_module(self, fullname):
561
"""Load a plugin from a specific directory."""
562
# We are called only for specific paths
563
plugin_path = self.specific_paths[fullname]
621
"""Load a plugin from a specific directory (or file)."""
622
plugin_path = self.filepath
564
623
loading_path = None
566
624
if os.path.isdir(plugin_path):
567
for suffix, mode, kind in imp.get_suffixes():
568
if kind not in (imp.PY_SOURCE, imp.PY_COMPILED):
569
# We don't recognize compiled modules (.so, .dll, etc)
571
init_path = osutils.pathjoin(plugin_path, '__init__' + suffix)
572
if os.path.isfile(init_path):
573
loading_path = init_path
625
init_path = _get_package_init(plugin_path)
626
if init_path is not None:
627
loading_path = plugin_path
630
kind = imp.PKG_DIRECTORY
577
632
for suffix, mode, kind in imp.get_suffixes():
578
633
if plugin_path.endswith(suffix):