Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion CHANGELOG.rst
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,9 @@ Release notes
vNext
-----

- Add preserve_spaces argument in commoncode.paths.portable_filename.
This argument will prevent the replacement of spaces in filenames.


Version 21.6.11
---------------
Expand Down Expand Up @@ -32,7 +35,7 @@ Version 21.5.12

- Add new function to find a command or shared object file in the PATH (e.g. in
environment variables). See commoncode.command.find_in_path()
- Add new simplified the commoncode.command.execute() function.
- Add new simplified the commoncode.command.execute() function.
- Add support for Python 3.10
- Update tests to cope with Python 3.6 bug https://bugs.python.org/issue26919
- Adopt latest skeleton with configure scripts updates
Expand Down
19 changes: 15 additions & 4 deletions src/commoncode/paths.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,14 +26,16 @@
# Build OS-portable and safer paths


def safe_path(path, posix=False):
def safe_path(path, posix=False, preserve_spaces=False):
"""
Convert `path` to a safe and portable POSIX path usable on multiple OSes.
The returned path is an ASCII-only byte string, resolved for relative
segments and itself relative.

The `path` is treated as a POSIX path if `posix` is True or as a Windows
path with blackslash separators otherwise.

If `preserve_spaces` is True, then the spaces in `path` will not be replaced.
"""
# if the path is UTF, try to use unicode instead
if not isinstance(path, str):
Expand All @@ -50,7 +52,7 @@ def safe_path(path, posix=False):
_pathmod, path_sep = path_handlers(path, posix)

segments = [s.strip() for s in path.split(path_sep) if s.strip()]
segments = [portable_filename(s) for s in segments]
segments = [portable_filename(s, preserve_spaces=preserve_spaces) for s in segments]

if not segments:
return '_'
Expand Down Expand Up @@ -133,12 +135,16 @@ def resolve(path, posix=True):


legal_punctuation = r"!\#$%&\(\)\+,\-\.;\=@\[\]_\{\}\~"
legal_spaces = r" "
legal_chars = r'A-Za-z0-9' + legal_punctuation
legal_chars_inc_spaces = legal_chars + legal_spaces
illegal_chars_re = r'[^' + legal_chars + r']'
illegal_chars_exc_spaces_re = r'[^' + legal_chars_inc_spaces + r']'
replace_illegal_chars = re.compile(illegal_chars_re).sub
replace_illegal_chars_exc_spaces = re.compile(illegal_chars_exc_spaces_re).sub


def portable_filename(filename):
def portable_filename(filename, preserve_spaces=False):
"""
Return a new name for `filename` that is portable across operating systems.

Expand All @@ -156,13 +162,18 @@ def portable_filename(filename):

Also inspired by Werkzeug:
https://raw.githubusercontent.com/pallets/werkzeug/8c2d63ce247ba1345e1b9332a68ceff93b2c07ab/werkzeug/utils.py

If `preserve_spaces` is True, then spaces in `filename` will not be replaced.
"""
filename = toascii(filename, translit=True)

if not filename:
return '_'

filename = replace_illegal_chars('_', filename)
if preserve_spaces:
filename = replace_illegal_chars_exc_spaces('_', filename)
else:
filename = replace_illegal_chars('_', filename)

# these are illegal both upper and lowercase and with or without an extension
# we insert an underscore after the base name.
Expand Down
4 changes: 4 additions & 0 deletions tests/test_paths.py
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,10 @@ def test_portable_filename(self):
expected = 'A___file__with_Spaces.mov'
assert paths.portable_filename("A:\\ file/ with Spaces.mov") == expected

# Test `preserve_spaces` option. Spaces should not be replaced
expected = 'Program Files (x86)'
assert paths.portable_filename("Program Files (x86)", preserve_spaces=True) == expected

# Unresolved relative paths will be treated as a single filename. Use
# resolve instead if you want to resolve paths:
expected = '___.._.._etc_passwd'
Expand Down