# ------------------------------------------------------------------------------
# Name: common/stringTools.py
# Purpose: Utilities for strings
#
# Authors: Michael Scott Asato Cuthbert
# Christopher Ariza
#
# Copyright: Copyright © 2009-2015 Michael Scott Asato Cuthbert
# License: BSD, see license.txt
# ------------------------------------------------------------------------------
'''
Tools for working with strings
'''
from __future__ import annotations
__all__ = [
'whitespaceEqual',
'getNumFromStr',
'hyphenToCamelCase',
'camelCaseToHyphen',
'spaceCamelCase',
'getMd5',
'formatStr',
'stripAccents',
'normalizeFilename',
'removePunctuation',
'parenthesesMatch',
'ParenthesesMatch',
]
import dataclasses
import hashlib
import random
import re
import time
import string
import unicodedata
# ------------------------------------------------------------------------------
WHITESPACE = re.compile(r'\s+')
LINEFEED = re.compile('\n+')
[docs]
def whitespaceEqual(a: str, b: str) -> bool:
# noinspection PyShadowingNames
r'''
returns True if a and b are equal except for whitespace differences
>>> a = ' hello \n there '
>>> b = 'hello there'
>>> c = ' bye there '
>>> common.whitespaceEqual(a, b)
True
>>> common.whitespaceEqual(a, c)
False
'''
a = WHITESPACE.sub('', a)
b = WHITESPACE.sub('', b)
a = LINEFEED.sub('', a)
b = LINEFEED.sub('', b)
if a == b:
return True
else:
return False
[docs]
def getNumFromStr(usrStr: str, numbers: str = '0123456789') -> tuple[str, str]:
'''
Given a string, extract any numbers.
Return two strings, the numbers (as strings) and the remaining characters.
>>> common.getNumFromStr('23a')
('23', 'a')
>>> common.getNumFromStr('23a954Hello')
('23954', 'aHello')
>>> common.getNumFromStr('')
('', '')
'''
found = []
remain = []
for char in usrStr:
if char in numbers:
found.append(char)
else:
remain.append(char)
# returns numbers and then characters
return ''.join(found), ''.join(remain)
[docs]
def hyphenToCamelCase(usrStr: str, replacement: str = '-') -> str:
'''
Given a hyphen-connected-string, change it to
a camelCaseConnectedString.
The replacement can be specified to be something besides a hyphen.
>>> common.hyphenToCamelCase('movement-name')
'movementName'
>>> common.hyphenToCamelCase('movement_name', replacement='_')
'movementName'
Safe to call on a string lacking the replacement character:
>>> common.hyphenToCamelCase('voice')
'voice'
And on "words" beginning with numbers:
>>> common.hyphenToCamelCase('music-21')
'music21'
'''
post = ''
for i, word in enumerate(usrStr.split(replacement)):
if i == 0:
post = word
else:
post += word.capitalize()
return post
[docs]
def camelCaseToHyphen(usrStr: str, replacement: str = '-') -> str:
# pylint: disable=line-too-long
'''
Given a camel-cased string, or a mixture of numbers and characters,
create a space separated string.
The replacement can be specified to be something besides a hyphen, but only
a single character and not (for internal reasons) an uppercase character.
code from https://stackoverflow.com/questions/1175208/elegant-python-function-to-convert-camelcase-to-snake-case
>>> common.camelCaseToHyphen('movementName')
'movement-name'
First letter can be uppercase as well:
>>> common.camelCaseToHyphen('MovementName')
'movement-name'
>>> common.camelCaseToHyphen('movementNameName')
'movement-name-name'
>>> common.camelCaseToHyphen('fileName', replacement='_')
'file_name'
Some things you cannot do:
>>> common.camelCaseToHyphen('fileName', replacement='NotFound')
Traceback (most recent call last):
ValueError: Replacement must be a single character.
>>> common.camelCaseToHyphen('fileName', replacement='A')
Traceback (most recent call last):
ValueError: Replacement cannot be an uppercase character.
'''
if len(replacement) != 1:
raise ValueError('Replacement must be a single character.')
if replacement.lower() != replacement:
raise ValueError('Replacement cannot be an uppercase character.')
s1 = re.sub('(.)([A-Z][a-z]+)', r'\1' + replacement + r'\2', usrStr)
return re.sub('([a-z0-9])([A-Z])', r'\1' + replacement + r'\2', s1).lower()
[docs]
def spaceCamelCase(usrStr: str, replaceUnderscore=True, fixMeList=None) -> str:
'''
Given a camel-cased string, or a mixture of numbers and characters,
create a space separated string.
If replaceUnderscore is True (default) then underscores also become spaces (but without the _)
>>> common.spaceCamelCase('thisIsATest')
'this Is A Test'
>>> common.spaceCamelCase('ThisIsATest')
'This Is A Test'
>>> common.spaceCamelCase('movement3')
'movement 3'
>>> common.spaceCamelCase('opus41no1')
'opus 41 no 1'
>>> common.spaceCamelCase('opus23402no219235')
'opus 23402 no 219235'
>>> common.spaceCamelCase('opus23402no219235').title()
'Opus 23402 No 219235'
There is a small list called fixMeList that can fix mistakes.
>>> common.spaceCamelCase('PMFC22')
'PMFC 22'
>>> common.spaceCamelCase('hello_myke')
'hello myke'
>>> common.spaceCamelCase('hello_myke', replaceUnderscore=False)
'hello_myke'
'''
numbers = '0123456789.'
firstNum = False
firstChar = False
isNumber = False
lastIsNum = False
post: list[str] = []
# do not split these
if fixMeList is None:
fixupList = ('PMFC',)
else:
fixupList = fixMeList
for char in usrStr:
if char in numbers:
isNumber = True
else:
isNumber = False
if isNumber and not firstNum and not lastIsNum:
firstNum = True
else:
firstNum = False
# for chars
if not isNumber and not firstChar and lastIsNum:
firstChar = True
else:
firstChar = False
if post:
if char.isupper() or firstNum or firstChar:
post.append(' ')
post.append(char)
else: # first character
post.append(char)
if isNumber:
lastIsNum = True
else:
lastIsNum = False
postStr = ''.join(post)
for fixMe in fixupList:
fixMeSpaced = ' '.join(fixMe)
postStr = postStr.replace(fixMeSpaced, fixMe)
if replaceUnderscore:
postStr = postStr.replace('_', ' ')
return postStr
[docs]
def getMd5(value=None) -> str:
# noinspection SpellCheckingInspection
'''
Return an md5 hash from a string. If no value is given then
the current time plus a random number is encoded.
>>> common.getMd5('test')
'098f6bcd4621d373cade4e832627b4f6'
'''
if value is None:
value = str(time.time()) + str(random.random())
m = hashlib.md5()
try:
m.update(value)
except TypeError: # unicode
m.update(value.encode('UTF-8'))
return m.hexdigest()
[docs]
def stripAccents(inputString: str) -> str:
r'''
removes accents from unicode strings.
>>> s = 'trés vite'
>>> 'é' in s
True
>>> common.stripAccents(s)
'tres vite'
Also handles the German Eszett and smart quotes
>>> common.stripAccents('Muß')
'Muss'
>>> common.stripAccents('Süss, “êtré”')
'Suss, "etre"'
Note -- it is still possible to have non-Ascii characters after this,
like in this Japanese expression for music:
>>> common.stripAccents('音楽')
'音楽'
'''
nfkd_form = (
unicodedata.normalize('NFKD', inputString)
.replace('ß', 'ss')
.replace('“', '"')
.replace('”', '"')
.replace('‘', "'")
.replace('’', "'")
)
return ''.join([c for c in nfkd_form if not unicodedata.combining(c)])
[docs]
def normalizeFilename(name: str) -> str:
'''
take a name that might contain unicode characters, punctuation,
or spaces and
normalize it so that it is POSIX compliant (except for the limit
on length).
Takes in a string or unicode string and returns a string (unicode in Py3)
without any accented characters.
>>> common.normalizeFilename('03-Niccolò all’lessandra.not really.xml')
'03-Niccolo_all_lessandra_not_really.xml'
'''
extension = None
lenName = len(name)
if lenName > 5 and name[-4] == '.':
extension = str(name[lenName - 4:])
name = name[:lenName - 4]
name = stripAccents(name)
name = name.encode('ascii', 'ignore').decode('UTF-8')
name = re.sub(r'[^\w-]', '_', name).strip()
if extension is not None:
name += extension
return name
[docs]
def removePunctuation(s: str) -> str:
'''
Remove all punctuation from a string.
>>> common.removePunctuation('This, is! my (face).')
'This is my face'
'''
maketrans = str.maketrans('', '', string.punctuation)
out = s.translate(maketrans)
return out
[docs]
@dataclasses.dataclass
class ParenthesesMatch:
start: int
end: int
text: str
nested: list[ParenthesesMatch]
[docs]
def parenthesesMatch(
s: str,
open: str = '(', # pylint: disable=redefined-builtin
close: str = ')',
) -> list[ParenthesesMatch]:
r'''
Utility tool to return a list of parentheses matches for a string using a dataclass
called `ParenthesesMatch` which has indices of the `start` and `end`
of the match, and the `text` of the match, and a set of `nested`
ParenthesesMatch objects (which may have their own nested objects).
>>> st = r'Bologne wrote (a (whole) (lot) \(of\)) sym\(ph(on)ies\) concertantes.'
>>> common.stringTools.parenthesesMatch(st)
[ParenthesesMatch(start=15, end=37, text='a (whole) (lot) \\(of\\)',
nested=[ParenthesesMatch(start=18, end=23, text='whole', nested=[]),
ParenthesesMatch(start=26, end=29, text='lot', nested=[])]),
ParenthesesMatch(start=47, end=49, text='on', nested=[])]
Other brackets can be used:
>>> st = r'[Whammy bars] and [oboes] do [not [mix] very] [well.]'
>>> common.stringTools.parenthesesMatch(st, open='[', close=']')
[ParenthesesMatch(start=1, end=12, text='Whammy bars', nested=[]),
ParenthesesMatch(start=19, end=24, text='oboes', nested=[]),
ParenthesesMatch(start=30, end=44, text='not [mix] very',
nested=[ParenthesesMatch(start=35, end=38, text='mix', nested=[])]),
ParenthesesMatch(start=47, end=52, text='well.', nested=[])]
The `open` and `close` parameters can be multiple characters:
>>> st = r'Did you eat <<beans>> today <<Pythagoreas<<?>>>>'
>>> common.stringTools.parenthesesMatch(st, open='<<', close='>>')
[ParenthesesMatch(start=14, end=19, text='beans', nested=[]),
ParenthesesMatch(start=30, end=46, text='Pythagoreas<<?>>',
nested=[ParenthesesMatch(start=43, end=44, text='?', nested=[])])]
They cannot, however, be empty:
>>> common.stringTools.parenthesesMatch(st, open='', close='')
Traceback (most recent call last):
ValueError: Neither open nor close can be empty.
Unmatched opening or closing parentheses will raise a ValueError:
>>> common.stringTools.parenthesesMatch('My (parentheses (sometimes (continue',)
Traceback (most recent call last):
ValueError: Opening '(' at index 3 was never closed
>>> common.stringTools.parenthesesMatch('This is a <bad> example>', open='<', close='>')
Traceback (most recent call last):
ValueError: Closing '>' without '<' at index 23.
Note that using multiple characters like a prefix can have unintended consequences:
>>> st = r'[Pitch("C4"), [Pitch("D5"), Pitch("E6")], Pitch("Pity("Z9")")]'
>>> common.stringTools.parenthesesMatch(st, open='Pitch("', close='")')
Traceback (most recent call last):
ValueError: Closing '")' without 'Pitch("' at index 59.
So to do something like this, you might need to get creative:
>>> out = common.stringTools.parenthesesMatch(st, open='("', close='")')
>>> out
[ParenthesesMatch(start=8, end=10, text='C4', nested=[]),
ParenthesesMatch(start=22, end=24, text='D5', nested=[]),
ParenthesesMatch(start=35, end=37, text='E6', nested=[]),
ParenthesesMatch(start=49, end=59, text='Pity("Z9")',
nested=[ParenthesesMatch(start=55, end=57, text='Z9', nested=[])])]
>>> extractedPitches = []
>>> for match in out:
... if st[match.start - 7:match.start] == 'Pitch("':
... extractedPitches.append(match.text)
>>> extractedPitches
['C4', 'D5', 'E6', 'Pity("Z9")']
* New in v9.3.
'''
if not open or not close:
raise ValueError('Neither open nor close can be empty.')
mainMatch = ParenthesesMatch(-1, -1, '', [])
stack: list[ParenthesesMatch] = [mainMatch]
lastCharWasBackslash = False
i = 0
while i < len(s):
if (not lastCharWasBackslash
and s[i:i + len(open)] == open):
curPM = ParenthesesMatch(i + len(open), -1, '', [])
stack.append(curPM)
i += len(open)
continue
elif (not lastCharWasBackslash
and s[i:i + len(close)] == close):
if len(stack) <= 1:
raise ValueError(f'Closing {close!r} without {open!r} at index {i}.')
curPM = stack.pop()
curPM.end = i
curPM.text = s[curPM.start:i]
stack[-1].nested.append(curPM)
i += len(close)
continue
if s[i] == '\\':
lastCharWasBackslash = not lastCharWasBackslash
else:
lastCharWasBackslash = False
i += 1
if len(stack) > 1:
raise ValueError(f'Opening {open!r} at index {stack[1].start - 1} was never closed')
return mainMatch.nested
# -----------------------------------------------------------------------------
if __name__ == '__main__':
import music21
music21.mainTest()