I will answer my own question since I dug around and found answer.
Problem is with function _escape(text) in module plistlib. It escapes only &, < and > although Xcode with its plist reader escapes all five characters (&, <, >, ' and "), that is why I think this module should also. It would also be a good addition to module plistlib something like ElementTree's parameter entities in function escape(). That parameter is a dict with additional characters to replace. Similar addition to plistlib's function save_plist() would be a good idea so that we can escape additional characters.
My solution is based on so called monkey patching. Basically, I copied whole function _escape(text) and just added additional escapes (' and "):
from plistlib import _controlCharPat
def patched_escape(text):
m = _controlCharPat.search(text)
if m is not None:
raise ValueError("strings can't contains control characters; "
"use bytes instead")
text = text.replace("\r\n", "\n") # convert DOS line endings
text = text.replace("\r", "\n") # convert Mac line endings
text = text.replace("&", "&") # escape '&'
text = text.replace("<", "<") # escape '<'
text = text.replace(">", ">") # escape '>'
text = text.replace("'", "'") # escape '''
text = text.replace("\"", """) # escape '"'
return text
And now in my script I replaced plistlib's function _escape(text) with mine:
plistlib._escape = patched_escape
Now plistlib correctly escapes and saves plists. Usual warnings regarding monkey patching apply also here. I don't have other callers, just this script so it is fine to do this.