diff --git a/.github/workflows/python-package.yml b/.github/workflows/python-package.yml new file mode 100644 index 0000000..0cc23c2 --- /dev/null +++ b/.github/workflows/python-package.yml @@ -0,0 +1,39 @@ +# This workflow will install Python dependencies, run tests and lint with a variety of Python versions +# For more information see: https://help.github.com/actions/language-and-framework-guides/using-python-with-github-actions + +name: Test the Python package + +on: + workflow_dispatch: + push: + branches: [ master ] + pull_request: + branches: [ master ] + +jobs: + build: + + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + python-version: ["3.7", "3.8", "3.9", "3.10", "3.11"] + + steps: + - uses: actions/checkout@v2 + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v2 + with: + python-version: ${{ matrix.python-version }} + - name: Install dependencies + run: | + python -m pip install --upgrade pip + python -m pip install twine + python -m pip install sphinx + if [ -f dev-requirements.txt ]; then pip install -r dev-requirements.txt; fi + - name: Run Tests + run: | + python tests.py + python setup.py sdist + twine check dist/* + sphinx-build -b html docs dist/docs diff --git a/.github/workflows/python-publish.yml b/.github/workflows/python-publish.yml new file mode 100644 index 0000000..b6b3cb1 --- /dev/null +++ b/.github/workflows/python-publish.yml @@ -0,0 +1,36 @@ +# This workflow will upload a Python Package using Twine when a release is created +# For more information see: https://help.github.com/en/actions/language-and-framework-guides/using-python-with-github-actions#publishing-to-package-registries + +# This workflow uses actions that are not certified by GitHub. +# They are provided by a third-party and are governed by +# separate terms of service, privacy policy, and support +# documentation. + +name: Upload Published Python Package + +on: + release: + types: [published] + +jobs: + deploy: + + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v2 + - name: Set up Python + uses: actions/setup-python@v2 + with: + python-version: '3.x' + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install build + - name: Build package + run: python -m build + - name: Publish package + uses: pypa/gh-action-pypi-publish@27b31702a0e7fc50959f5ad993c78deac1bdfc29 + with: + user: __token__ + password: ${{ secrets.PYPI_API_TOKEN }} diff --git a/.travis.yml b/.travis.yml index 45394bd..42fadab 100644 --- a/.travis.yml +++ b/.travis.yml @@ -9,7 +9,7 @@ python: # command to install dependencies install: - if [[ $TRAVIS_PYTHON_VERSION == '2.6' ]]; then pip install unittest2; fi - - "pip install dill" + - if [[ $TRAVIS_PYTHON_VERSION -ne '3.4' ]]; then pip install dill; fi - "python setup.py install" # command to run tests script: python tests.py diff --git a/README.rst b/README.rst index b347593..eebde5b 100644 --- a/README.rst +++ b/README.rst @@ -13,6 +13,7 @@ individual components. * hn.suffix * hn.nickname * hn.surnames *(middle + last)* +* hn.initials *(first initial of each name part)* Supported Name Structures ~~~~~~~~~~~~~~~~~~~~~~~~~ @@ -135,9 +136,8 @@ https://github.com/derek73/python-nameparser .. _Start a New Issue: https://github.com/derek73/python-nameparser/issues .. _click here to propose changes to the titles: https://github.com/derek73/python-nameparser/edit/master/nameparser/config/titles.py - -.. |Build Status| image:: https://travis-ci.org/derek73/python-nameparser.svg?branch=master - :target: https://travis-ci.org/derek73/python-nameparser +.. |Build Status| image:: https://github.com/derek73/python-nameparser/actions/workflows/python-package.yml/badge.svg + :target: https://github.com/derek73/python-nameparser/actions/workflows/python-package.yml .. |PyPI| image:: https://img.shields.io/pypi/v/nameparser.svg :target: https://pypi.org/project/nameparser/ .. |Documentation| image:: https://readthedocs.org/projects/nameparser/badge/?version=latest diff --git a/dev-requirements.txt b/dev-requirements.txt index 8aab0b6..edd07b3 100644 --- a/dev-requirements.txt +++ b/dev-requirements.txt @@ -1,6 +1,2 @@ -ipdb -nose>=1.3.7 -coverage>=4.0.3 dill>=0.2.5 -twine Sphinx diff --git a/docs/modules.rst b/docs/modules.rst index eaf3240..2056330 100644 --- a/docs/modules.rst +++ b/docs/modules.rst @@ -7,6 +7,7 @@ HumanName.parser .. py:module:: nameparser.parser .. py:class:: HumanName + :noindex: .. autoclass:: HumanName :members: diff --git a/docs/release_log.rst b/docs/release_log.rst index f53cbcc..a0ab7ee 100644 --- a/docs/release_log.rst +++ b/docs/release_log.rst @@ -1,5 +1,18 @@ Release Log =========== +* 1.1.3 - September 20, 2023 + - Fix case when we have two same prefixes in the name ()#147) +* 1.1.2 - November 13, 2022 + - Add support for attributes in constructor (#140) + - Make HumanName instances hashable (#138) + - Update repr for names with single quotes (#137) +* 1.1.1 - January 28, 2022 + - Fix bug in is_suffix handling of lists (#129) +* 1.1.0 - January 3, 2022 + - Add initials support (#128) + - Add more titles and prefixes (#120, #127, #128, #119) +* 1.0.6 - February 8, 2020 + - Fix Python 3.8 syntax error (#104) * 1.0.5 - Dec 12, 2019 - Fix suffix parsing bug in comma parts (#98) - Fix deprecation warning on Python 3.7 (#94) @@ -110,7 +123,7 @@ Release Log - Generate documentation using sphinx and host on readthedocs. * 0.2.10 - May 6, 2014 - If name is only a title and one part, assume it's a last name instead of a first name, with exceptions for some titles like 'Sir'. (`#7 `_). - - Add some judicial and other common titles. (#9) + - Add some judicial and other common titles. (#9) * 0.2.9 - Apr 1, 2014 - Add a new nickname attribute containing anything in parenthesis or double quotes (`Issue 33 `_). * 0.2.8 - Oct 25, 2013 @@ -123,7 +136,7 @@ Release Log * 0.2.5 - Feb 11, 2013 - Set logging handler to NullHandler - Remove 'ben' from PREFIXES because it's more common as a name than a prefix. - - Deprecate BlankHumanNameError. Do not raise exceptions if full_name is empty string. + - Deprecate BlankHumanNameError. Do not raise exceptions if full_name is empty string. * 0.2.4 - Feb 10, 2013 - Adjust logging, don't set basicConfig. Fix `Issue 10 `_ and `Issue 26 `_. - Fix handling of single lower case initials that are also conjunctions, e.g. "john e smith". Re `Issue 11 `_. @@ -134,12 +147,12 @@ Release Log - tests/test.py can now take an optional name argument that will return repr() for that name. * 0.2.3 - Fix overzealous "Mac" regex * 0.2.2 - Fix parsing error -* 0.2.0 +* 0.2.0 - Significant refactor of parsing logic. Handle conjunctions and prefixes before parsing into attribute buckets. - Support attribute overriding by assignment. - - Support multiple titles. - - Lowercase titles constants to fix bug with comparison. + - Support multiple titles. + - Lowercase titles constants to fix bug with comparison. - Move documentation to README.rst, add release log. * 0.1.4 - Use set() in constants for improved speed. setuptools compatibility - sketerpot * 0.1.3 - Add capitalization feature - twotwo diff --git a/docs/resources.rst b/docs/resources.rst index 0c70695..8934aae 100644 --- a/docs/resources.rst +++ b/docs/resources.rst @@ -2,13 +2,19 @@ Naming Practices and Resources ============================== * US_Census_Surname_Data_2000_ + * US_Social_Security_Administration_Baby_Names_Index_ * Naming_practice_guide_UK_2006_ * Wikipedia_Anthroponymy_ * Wikipedia_Naming_conventions_ * Wikipedia_List_Of_Titles_ + * Tussenvoegsel_ + * Family_Name_Affixes_ -.. _US_Census_Surname_Data_2000: http://www.census.gov/genealogy/www/data/2000surnames/index.html +.. _US_Census_Surname_Data_2000: https://www.census.gov/data/developers/data-sets/surnames/2000.html +.. _US_Social_Security_Administration_Baby_Names_Index: https://www.ssa.gov/oact/babynames/limits.html .. _Naming_practice_guide_UK_2006: https://www.fbiic.gov/public/2008/nov/Naming_practice_guide_UK_2006.pdf .. _Wikipedia_Anthroponymy: https://en.wikipedia.org/wiki/Anthroponymy .. _Wikipedia_Naming_conventions: http://en.wikipedia.org/wiki/Wikipedia:Naming_conventions_(people) .. _Wikipedia_List_Of_Titles: https://en.wikipedia.org/wiki/Title +.. _Tussenvoegsel: https://en.wikipedia.org/wiki/Tussenvoegsel +.. _Family_Name_Affixes : https://en.wikipedia.org/wiki/List_of_family_name_affixes diff --git a/docs/usage.rst b/docs/usage.rst index 6a65c4e..7fbe274 100644 --- a/docs/usage.rst +++ b/docs/usage.rst @@ -176,3 +176,41 @@ Don't want to include nicknames in your output? No problem. Just omit that keywo 'Dr. Juan de la Vega' +Initials Support +---------------- + +The HumanName class can try to get the correct representation of initials. +Initials can be tricky as different format usages exist. +To exclude any of the name parts from the initials, change the initials format string: +:py:attr:`~nameparser.config.Constants.initials_format` +Three attributes exist for the format, `first`, `middle` and `last`. + +.. doctest:: initials format + + >>> from nameparser.config import CONSTANTS + >>> CONSTANTS.initials_format = "{first} {middle}" + >>> HumanName("Doe, John A. Kenneth, Jr.").initials() + 'J. A. K.' + >>> HumanName("Doe, John A. Kenneth, Jr.", initials_format="{last}, {first}).initials() + 'D., J.' + + +Furthermore, the delimiter for the string output can be set through: +:py:attr:`~nameparser.config.Constants.initials_delimiter` + +.. doctest:: initials delimiter + + >>> HumanName("Doe, John A. Kenneth, Jr.", initials_delimiter=";").initials() + "J; A; K;" + >>> from nameparser.config import CONSTANTS + >>> CONSTANTS.initials_delimiter = "." + >>> HumanName("Doe, John A. Kenneth, Jr.", initials_format="{first}{middle}{last}).initials() + "J.A.K.D." + +To get a list representation of the initials, use :py:meth:`~nameparser.HumanName.initials_list`. +This function is unaffected by :py:attr:`~nameparser.config.Constants.initials_format` + +.. doctest:: list format + >>> HumanName("Doe, John A. Kenneth, Jr.", initials_delimiter=";").initials_list() + ["J", "A", "K", "D"] + diff --git a/nameparser/__init__.py b/nameparser/__init__.py index b2644e7..ab914e9 100644 --- a/nameparser/__init__.py +++ b/nameparser/__init__.py @@ -1,4 +1,4 @@ -VERSION = (1, 0, 5) +VERSION = (1, 1, 3) __version__ = '.'.join(map(str, VERSION)) __author__ = "Derek Gulbranson" __author_email__ = 'derek73@gmail.com' diff --git a/nameparser/config/__init__.py b/nameparser/config/__init__.py index 4f1e4f2..7b2baef 100644 --- a/nameparser/config/__init__.py +++ b/nameparser/config/__init__.py @@ -49,35 +49,37 @@ DEFAULT_ENCODING = 'UTF-8' + class SetManager(Set): ''' Easily add and remove config variables per module or instance. Subclass of ``collections.abc.Set``. - + Only special functionality beyond that provided by set() is to normalize constants for comparison (lower case, no periods) when they are add()ed and remove()d and allow passing multiple string arguments to the :py:func:`add()` and :py:func:`remove()` methods. - + ''' + def __init__(self, elements): self.elements = set(elements) - + def __call__(self): return self.elements - + def __repr__(self): - return "SetManager({})".format(self.elements) # used for docs - + return "SetManager({})".format(self.elements) # used for docs + def __iter__(self): return iter(self.elements) - + def __contains__(self, value): return value in self.elements - + def __len__(self): return len(self.elements) - + def next(self): return self.__next__() @@ -89,7 +91,7 @@ def __next__(self): c = self.count self.count = c + 1 return getattr(self, self.elements[c]) or next(self) - + def add_with_encoding(self, s, encoding=None): """ Add the lower case and no-period version of the string to the set. Pass an @@ -111,7 +113,7 @@ def add(self, *strings): """ [self.add_with_encoding(s) for s in strings] return self - + def remove(self, *strings): """ Remove the lower case and no-period version of the string arguments from the set. @@ -126,10 +128,11 @@ class TupleManager(dict): A dictionary with dot.notation access. Subclass of ``dict``. Makes the tuple constants more friendly. ''' + def __getattr__(self, attr): return self.get(attr) - __setattr__= dict.__setitem__ - __delattr__= dict.__delitem__ + __setattr__ = dict.__setitem__ + __delattr__ = dict.__delitem__ def __getstate__(self): return dict(self) @@ -140,6 +143,7 @@ def __setstate__(self, state): def __reduce__(self): return (TupleManager, (), self.__getstate__()) + class Constants(object): """ An instance of this class hold all of the configuration constants for the parser. @@ -163,11 +167,23 @@ class Constants(object): :param regexes: :py:attr:`regexes` wrapped with :py:class:`TupleManager`. """ - + string_format = "{title} {first} {middle} {last} {suffix} ({nickname})" """ The default string format use for all new `HumanName` instances. """ + + initials_format = "{first} {middle} {last}" + """ + The default initials format used for all new `HumanName` instances. + """ + + initials_delimiter = "." + """ + The default initials delimiter used for all new `HumanName` instances. + Will be used to add a delimiter between each initial. + """ + empty_attribute_default = '' """ Default return value for empty attributes. @@ -183,6 +199,7 @@ class Constants(object): 'John' """ + capitalize_name = False """ If set, applies :py:meth:`~nameparser.parser.HumanName.capitalize` to @@ -197,6 +214,7 @@ class Constants(object): 'Bob V. de la MacDole-Eisenhower Ph.D.' """ + force_mixed_case_capitalization = False """ If set, forces the capitalization of mixed case strings when @@ -213,27 +231,26 @@ class Constants(object): """ - - def __init__(self, - prefixes=PREFIXES, - suffix_acronyms=SUFFIX_ACRONYMS, - suffix_not_acronyms=SUFFIX_NOT_ACRONYMS, - titles=TITLES, - first_name_titles=FIRST_NAME_TITLES, - conjunctions=CONJUNCTIONS, - capitalization_exceptions=CAPITALIZATION_EXCEPTIONS, - regexes=REGEXES - ): - self.prefixes = SetManager(prefixes) - self.suffix_acronyms = SetManager(suffix_acronyms) + def __init__(self, + prefixes=PREFIXES, + suffix_acronyms=SUFFIX_ACRONYMS, + suffix_not_acronyms=SUFFIX_NOT_ACRONYMS, + titles=TITLES, + first_name_titles=FIRST_NAME_TITLES, + conjunctions=CONJUNCTIONS, + capitalization_exceptions=CAPITALIZATION_EXCEPTIONS, + regexes=REGEXES + ): + self.prefixes = SetManager(prefixes) + self.suffix_acronyms = SetManager(suffix_acronyms) self.suffix_not_acronyms = SetManager(suffix_not_acronyms) - self.titles = SetManager(titles) - self.first_name_titles = SetManager(first_name_titles) - self.conjunctions = SetManager(conjunctions) + self.titles = SetManager(titles) + self.first_name_titles = SetManager(first_name_titles) + self.conjunctions = SetManager(conjunctions) self.capitalization_exceptions = TupleManager(capitalization_exceptions) - self.regexes = TupleManager(regexes) + self.regexes = TupleManager(regexes) self._pst = None - + @property def suffixes_prefixes_titles(self): if not self._pst: @@ -242,15 +259,16 @@ def suffixes_prefixes_titles(self): def __repr__(self): return "" - + def __setstate__(self, state): self.__init__(state) - + def __getstate__(self): attrs = [x for x in dir(self) if not x.startswith('_')] - return dict([(a,getattr(self, a)) for a in attrs]) + return dict([(a, getattr(self, a)) for a in attrs]) + -#: A module-level instance of the :py:class:`Constants()` class. +#: A module-level instance of the :py:class:`Constants()` class. #: Provides a common instance for the module to share #: to easily adjust configuration for the entire module. #: See `Customizing the Parser with Your Own Configuration `_. diff --git a/nameparser/config/prefixes.py b/nameparser/config/prefixes.py index 2f5eb31..0334f83 100644 --- a/nameparser/config/prefixes.py +++ b/nameparser/config/prefixes.py @@ -12,11 +12,13 @@ #: correct parsing of the last name "von bergen wessels". PREFIXES = set([ 'abu', + 'al', 'bin', 'bon', 'da', 'dal', 'de', + 'de\'', 'degli', 'dei', 'del', @@ -34,11 +36,15 @@ 'ibn', 'la', 'le', + 'mac', + 'mc', 'san', 'santa', 'st', 'ste', 'van', + 'vander', 'vel', 'von', + 'vom', ]) diff --git a/nameparser/config/suffixes.py b/nameparser/config/suffixes.py index 9765b92..804f2b5 100644 --- a/nameparser/config/suffixes.py +++ b/nameparser/config/suffixes.py @@ -239,6 +239,7 @@ 'cpm', 'cpo', 'cpp', + 'cppm', 'cprc', 'cpre', 'cprp', diff --git a/nameparser/config/titles.py b/nameparser/config/titles.py index 3d5892f..04746bc 100644 --- a/nameparser/config/titles.py +++ b/nameparser/config/titles.py @@ -117,6 +117,7 @@ 'banner', 'bard', 'baron', + 'baroness', 'barrister', 'baseball', 'bearer', @@ -136,6 +137,7 @@ 'bodhisattva', 'bookseller', 'botanist', + 'bp', 'brigadier', 'briggen', 'british', @@ -223,6 +225,7 @@ 'cwo5', 'cyclist', 'dancer', + 'dcn', 'deacon', 'delegate', 'deputy', @@ -249,7 +252,7 @@ 'druid', 'drummer', 'duchesse', - 'duke', + # 'duke', # a common first name 'dutchess', 'ecologist', 'economist', @@ -288,6 +291,7 @@ 'foreign', 'forester', 'founder', + 'fr', 'friar', 'gaf', 'gen', @@ -395,6 +399,7 @@ 'member', 'memoirist', 'merchant', + 'met', 'metropolitan', 'mg', 'mgr', @@ -425,6 +430,7 @@ 'murshid', 'musician', 'musicologist', + 'mx', 'mystery', 'nanny', 'narrator', @@ -568,6 +574,7 @@ 'srta', 'ssg', 'ssgt', + 'st', 'staff', 'state', 'states', diff --git a/nameparser/parser.py b/nameparser/parser.py index caf4ded..a5eb352 100644 --- a/nameparser/parser.py +++ b/nameparser/parser.py @@ -2,6 +2,7 @@ from __future__ import unicode_literals import sys +import re from operator import itemgetter from itertools import groupby @@ -15,9 +16,10 @@ ENCODING = 'utf-8' + def group_contiguous_integers(data): """ - return list of tuples containing first and last index + return list of tuples containing first and last index position of contiguous numbers in a series """ ranges = [] @@ -27,16 +29,20 @@ def group_contiguous_integers(data): ranges.append((group[0], group[-1])) return ranges + class HumanName(object): """ Parse a person's name into individual components. - + Instantiation assigns to ``full_name``, and assignment to :py:attr:`full_name` triggers :py:func:`parse_full_name`. After parsing the - name, these instance attributes are available. - + name, these instance attributes are available. Alternatively, you can pass + any of the instance attributes to the constructor method and skip the parsing + process. If any of the the instance attributes are passed to the constructor + as keywords, :py:func:`parse_full_name` will not be performed. + **HumanName Instance Attributes** - + * :py:attr:`title` * :py:attr:`first` * :py:attr:`middle` @@ -46,61 +52,82 @@ class HumanName(object): * :py:attr:`surnames` :param str full_name: The name string to be parsed. - :param constants constants: - a :py:class:`~nameparser.config.Constants` instance. Pass ``None`` for - `per-instance config `_. + :param constants constants: + a :py:class:`~nameparser.config.Constants` instance. Pass ``None`` for + `per-instance config `_. :param str encoding: string representing the encoding of your input - :param str string_format: python string formatting + :param str string_format: python string formatting + :param str initials_format: python initials string formatting + :param str initials_delimter: string delimiter for initials + :param str first: first name + :param str middle: middle name + :param str last: last name + :param str title: The title or prenominal + :param str suffix: The suffix or postnominal + :param str nickname: Nicknames """ - + C = CONSTANTS """ A reference to the configuration for this instance, which may or may not be - a reference to the shared, module-wide instance at - :py:mod:`~nameparser.config.CONSTANTS`. See `Customizing the Parser + a reference to the shared, module-wide instance at + :py:mod:`~nameparser.config.CONSTANTS`. See `Customizing the Parser `_. """ - + original = '' """ The original string, untouched by the parser. """ - + _count = 0 - _members = ['title','first','middle','last','suffix','nickname'] + _members = ['title', 'first', 'middle', 'last', 'suffix', 'nickname'] unparsable = True _full_name = '' - + def __init__(self, full_name="", constants=CONSTANTS, encoding=DEFAULT_ENCODING, - string_format=None): + string_format=None, initials_format=None, initials_delimiter=None, + first=None, middle=None, last=None, title=None, suffix=None, + nickname=None): self.C = constants if type(self.C) is not type(CONSTANTS): self.C = Constants() - + self.encoding = encoding self.string_format = string_format or self.C.string_format - # full_name setter triggers the parse - self.full_name = full_name - + self.initials_format = initials_format or self.C.initials_format + self.initials_delimiter = initials_delimiter or self.C.initials_delimiter + if (first or middle or last or title or suffix or nickname): + self.first = first + self.middle = middle + self.last = last + self.title = title + self.suffix = suffix + self.nickname = nickname + self.unparsable = False + else: + # full_name setter triggers the parse + self.full_name = full_name + def __iter__(self): return self - + def __len__(self): l = 0 for x in self: l += 1 return l - + def __eq__(self, other): """ - HumanName instances are equal to other objects whose + HumanName instances are equal to other objects whose lower case unicode representation is the same. """ return (u(self)).lower() == (u(other)).lower() - + def __ne__(self, other): return not (u(self)).lower() == (u(other)).lower() - + def __getitem__(self, key): if isinstance(key, slice): return [getattr(self, x) for x in self._members[key]] @@ -130,20 +157,23 @@ def __unicode__(self): # string_format = "{title} {first} {middle} {last} {suffix} ({nickname})" _s = self.string_format.format(**self.as_dict()) # remove trailing punctuation from missing nicknames - _s = _s.replace(str(self.C.empty_attribute_default),'').replace(" ()","").replace(" ''","").replace(' ""',"") + _s = _s.replace(str(self.C.empty_attribute_default), '').replace(" ()", "").replace(" ''", "").replace(' ""', "") return self.collapse_whitespace(_s).strip(', ') return " ".join(self) - + + def __hash__(self): + return hash(str(self)) + def __str__(self): if sys.version_info[0] >= 3: return self.__unicode__() return self.__unicode__().encode(self.encoding) - + def __repr__(self): if self.unparsable: - _string = "<%(class)s : [ Unparsable ] >" % {'class': self.__class__.__name__,} + _string = "<%(class)s : [ Unparsable ] >" % {'class': self.__class__.__name__, } else: - _string = "<%(class)s : [\n\ttitle: '%(title)s' \n\tfirst: '%(first)s' \n\tmiddle: '%(middle)s' \n\tlast: '%(last)s' \n\tsuffix: '%(suffix)s'\n\tnickname: '%(nickname)s'\n]>" % { + _string = "<%(class)s : [\n\ttitle: %(title)r \n\tfirst: %(first)r \n\tmiddle: %(middle)r \n\tlast: %(last)r \n\tsuffix: %(suffix)r\n\tnickname: %(nickname)r\n]>" % { 'class': self.__class__.__name__, 'title': self.title or '', 'first': self.first or '', @@ -155,22 +185,22 @@ def __repr__(self): if sys.version_info[0] >= 3: return _string return _string.encode(self.encoding) - + def as_dict(self, include_empty=True): """ Return the parsed name as a dictionary of its attributes. - + :param bool include_empty: Include keys in the dictionary for empty name attributes. :rtype: dict - + .. doctest:: - + >>> name = HumanName("Bob Dole") >>> name.as_dict() {'last': 'Dole', 'suffix': '', 'title': '', 'middle': '', 'nickname': '', 'first': 'Bob'} >>> name.as_dict(False) {'last': 'Dole', 'first': 'Bob'} - + """ d = {} for m in self._members: @@ -181,66 +211,133 @@ def as_dict(self, include_empty=True): if val: d[m] = val return d - + + def __process_initial__(self, name_part, firstname=False): + """ + Name parts may include prefixes or conjunctions. This function filters these from the name unless it is + a first name, since first names cannot be conjunctions or prefixes. + """ + parts = name_part.split(" ") + initials = [] + if len(parts) and isinstance(parts, list): + for part in parts: + if not (self.is_prefix(part) or self.is_conjunction(part)) or firstname == True: + initials.append(part[0]) + if len(initials) > 0: + return " ".join(initials) + else: + return self.C.empty_attribute_default + + def initials_list(self): + """ + Returns the initials as a list + + .. doctest:: + + >>> name = HumanName("Sir Bob Andrew Dole") + >>> name.initials_list() + ["B", "A", "D"] + >>> name = HumanName("J. Doe") + >>> name.initials_list() + ["J", "D"] + """ + first_initials_list = [self.__process_initial__(name, True) for name in self.first_list if name] + middle_initials_list = [self.__process_initial__(name) for name in self.middle_list if name] + last_initials_list = [self.__process_initial__(name) for name in self.last_list if name] + return first_initials_list + middle_initials_list + last_initials_list + + def initials(self): + """ + Return period-delimited initials of the first, middle and optionally last name. + + :param bool include_last_name: Include the last name as part of the initials + :rtype: str + + .. doctest:: + + >>> name = HumanName("Sir Bob Andrew Dole") + >>> name.initials() + "B. A. D." + >>> name = HumanName("Sir Bob Andrew Dole", initials_format="{first} {middle}") + >>> name.initials() + "B. A." + """ + + first_initials_list = [self.__process_initial__(name, True) for name in self.first_list if name] + middle_initials_list = [self.__process_initial__(name) for name in self.middle_list if name] + last_initials_list = [self.__process_initial__(name) for name in self.last_list if name] + + initials_dict = { + "first": (self.initials_delimiter + " ").join(first_initials_list) + self.initials_delimiter + if len(first_initials_list) else self.C.empty_attribute_default, + "middle": (self.initials_delimiter + " ").join(middle_initials_list) + self.initials_delimiter + if len(middle_initials_list) else self.C.empty_attribute_default, + "last": (self.initials_delimiter + " ").join(last_initials_list) + self.initials_delimiter + if len(last_initials_list) else self.C.empty_attribute_default + } + + _s = self.initials_format.format(**initials_dict) + return self.collapse_whitespace(_s) + @property def has_own_config(self): """ - True if this instance is not using the shared module-level + True if this instance is not using the shared module-level configuration. """ return self.C is not CONSTANTS - - ### attributes - + + # attributes + @property def title(self): """ - The person's titles. Any string of consecutive pieces in - :py:mod:`~nameparser.config.titles` or + The person's titles. Any string of consecutive pieces in + :py:mod:`~nameparser.config.titles` or :py:mod:`~nameparser.config.conjunctions` at the beginning of :py:attr:`full_name`. """ return " ".join(self.title_list) or self.C.empty_attribute_default - + @property def first(self): """ - The person's first name. The first name piece after any known + The person's first name. The first name piece after any known :py:attr:`title` pieces parsed from :py:attr:`full_name`. """ return " ".join(self.first_list) or self.C.empty_attribute_default - + @property def middle(self): """ - The person's middle names. All name pieces after the first name and + The person's middle names. All name pieces after the first name and before the last name parsed from :py:attr:`full_name`. """ return " ".join(self.middle_list) or self.C.empty_attribute_default - + @property def last(self): """ - The person's last name. The last name piece parsed from + The person's last name. The last name piece parsed from :py:attr:`full_name`. """ return " ".join(self.last_list) or self.C.empty_attribute_default - + @property def suffix(self): """ The persons's suffixes. Pieces at the end of the name that are found in :py:mod:`~nameparser.config.suffixes`, or pieces that are at the end - of comma separated formats, e.g. - "Lastname, Title Firstname Middle[,] Suffix [, Suffix]" parsed + of comma separated formats, e.g. + "Lastname, Title Firstname Middle[,] Suffix [, Suffix]" parsed from :py:attr:`full_name`. """ return ", ".join(self.suffix_list) or self.C.empty_attribute_default - + @property def nickname(self): """ - The person's nicknames. Any text found inside of quotes (``""``) or + The person's nicknames. Any text found inside of quotes (``""``) or parenthesis (``()``) """ return " ".join(self.nickname_list) or self.C.empty_attribute_default @@ -259,8 +356,8 @@ def surnames(self): """ return " ".join(self.surnames_list) or self.C.empty_attribute_default - ### setter methods - + # setter methods + def _set_list(self, attr, value): if isinstance(value, list): val = value @@ -270,70 +367,85 @@ def _set_list(self, attr, value): val = [] else: raise TypeError( - "Can only assign strings, lists or None to name attributes." - " Got {0}".format(type(value))) + "Can only assign strings, lists or None to name attributes." + " Got {0}".format(type(value))) setattr(self, attr+"_list", self.parse_pieces(val)) - + @title.setter def title(self, value): self._set_list('title', value) - + @first.setter def first(self, value): self._set_list('first', value) - + @middle.setter def middle(self, value): self._set_list('middle', value) - + @last.setter def last(self, value): self._set_list('last', value) - + @suffix.setter def suffix(self, value): self._set_list('suffix', value) - + @nickname.setter def nickname(self, value): self._set_list('nickname', value) - - ### Parse helpers - + + # Parse helpers + def is_title(self, value): """Is in the :py:data:`~nameparser.config.titles.TITLES` set.""" return lc(value) in self.C.titles - + def is_conjunction(self, piece): - """Is in the conjuctions set and not :py:func:`is_an_initial()`.""" - return piece.lower() in self.C.conjunctions and not self.is_an_initial(piece) - + """Is in the conjunctions set and not :py:func:`is_an_initial()`.""" + if isinstance(piece, list): + for item in piece: + if self.is_conjunction(item): + return True + else: + return piece.lower() in self.C.conjunctions and not self.is_an_initial(piece) + def is_prefix(self, piece): """ - Lowercase and no periods version of piece is in the + Lowercase and no periods version of piece is in the :py:data:`~nameparser.config.prefixes.PREFIXES` set. """ - return lc(piece) in self.C.prefixes + if isinstance(piece, list): + for item in piece: + if self.is_prefix(item): + return True + else: + return lc(piece) in self.C.prefixes def is_roman_numeral(self, value): """ - Matches the ``roman_numeral`` regular expression in + Matches the ``roman_numeral`` regular expression in :py:data:`~nameparser.config.regexes.REGEXES`. """ return bool(self.C.regexes.roman_numeral.match(value)) - + def is_suffix(self, piece): """ - Is in the suffixes set and not :py:func:`is_an_initial()`. - - Some suffixes may be acronyms (M.B.A) while some are not (Jr.), + Is in the suffixes set and not :py:func:`is_an_initial()`. + + Some suffixes may be acronyms (M.B.A) while some are not (Jr.), so we remove the periods from `piece` when testing against `C.suffix_acronyms`. """ # suffixes may have periods inside them like "M.D." - return ((lc(piece).replace('.','') in self.C.suffix_acronyms) \ - or (lc(piece) in self.C.suffix_not_acronyms)) \ - and not self.is_an_initial(piece) + if isinstance(piece, list): + for item in piece: + if self.is_suffix(item): + return True + else: + return ((lc(piece).replace('.', '') in self.C.suffix_acronyms) + or (lc(piece) in self.C.suffix_not_acronyms)) \ + and not self.is_an_initial(piece) def are_suffixes(self, pieces): """Return True if all pieces are suffixes.""" @@ -341,31 +453,30 @@ def are_suffixes(self, pieces): if not self.is_suffix(piece): return False return True - + def is_rootname(self, piece): """ Is not a known title, suffix or prefix. Just first, middle, last names. """ return lc(piece) not in self.C.suffixes_prefixes_titles \ - and not self.is_an_initial(piece) - + and not self.is_an_initial(piece) + def is_an_initial(self, value): """ Words with a single period at the end, or a single uppercase letter. - - Matches the ``initial`` regular expression in + + Matches the ``initial`` regular expression in :py:data:`~nameparser.config.regexes.REGEXES`. """ return bool(self.C.regexes.initial.match(value)) - - ### full_name parser - + # full_name parser + @property def full_name(self): """The string output of the HumanName instance.""" return self.__str__() - + @full_name.setter def full_name(self, value): self.original = value @@ -373,22 +484,22 @@ def full_name(self, value): if isinstance(value, binary_type): self._full_name = value.decode(self.encoding) self.parse_full_name() - + def collapse_whitespace(self, string): # collapse multiple spaces into single space - string = self.C.regexes.spaces.sub(" ", string.strip()) + string = self.C.regexes.spaces.sub(" ", string.strip()) if string.endswith(","): string = string[:-1] return string def pre_process(self): """ - + This method happens at the beginning of the :py:func:`parse_full_name` before any other processing of the string aside from unicode normalization, so it's a good place to do any custom handling in a subclass. Runs :py:func:`parse_nicknames` and :py:func:`squash_emoji`. - + """ self.fix_phd() self.parse_nicknames() @@ -404,28 +515,33 @@ def post_process(self): self.handle_capitalization() def fix_phd(self): - _re = self.C.regexes.phd - match = _re.search(self._full_name) - if match: - self.suffix_list.append(match.group(1)) - self._full_name = _re.sub('', self._full_name) + try: + _re = self.C.regexes.phd + match = _re.search(self._full_name) + if match: + self.suffix_list.append(match.group(1)) + self._full_name = _re.sub('', self._full_name) + except AttributeError: + pass def parse_nicknames(self): """ - The content of parenthesis or quotes in the name will be added to the + The content of parenthesis or quotes in the name will be added to the nicknames list. This happens before any other processing of the name. - + Single quotes cannot span white space characters and must border white space to allow for quotes in names like O'Connor and Kawai'ae'a. Double quotes and parenthesis can span white space. - - Loops through 3 :py:data:`~nameparser.config.regexes.REGEXES`; + + Loops through 3 :py:data:`~nameparser.config.regexes.REGEXES`; `quoted_word`, `double_quotes` and `parenthesis`. """ - - re_quoted_word = self.C.regexes.quoted_word - re_double_quotes = self.C.regexes.double_quotes - re_parenthesis = self.C.regexes.parenthesis + + empty_re = re.compile("") + + re_quoted_word = self.C.regexes.quoted_word or empty_re + re_double_quotes = self.C.regexes.double_quotes or empty_re + re_parenthesis = self.C.regexes.parenthesis or empty_re for _re in (re_quoted_word, re_double_quotes, re_parenthesis): if _re.search(self._full_name): @@ -445,7 +561,7 @@ def handle_firstnames(self): If there are only two parts and one is a title, assume it's a last name instead of a first name. e.g. Mr. Johnson. Unless it's a special title like "Sir", then when it's followed by a single name that name is always - a first name. + a first name. """ if self.title \ and len(self) == 2 \ @@ -454,18 +570,18 @@ def handle_firstnames(self): def parse_full_name(self): """ - + The main parse method for the parser. This method is run upon assignment to the :py:attr:`full_name` attribute or instantiation. Basic flow is to hand off to :py:func:`pre_process` to handle nicknames. It then splits on commas and chooses a code path depending on the number of commas. - + :py:func:`parse_pieces` then splits those parts on spaces and - :py:func:`join_on_conjunctions` joins any pieces next to conjunctions. + :py:func:`join_on_conjunctions` joins any pieces next to conjunctions. """ - + self.title_list = [] self.first_list = [] self.middle_list = [] @@ -473,23 +589,22 @@ def parse_full_name(self): self.suffix_list = [] self.nickname_list = [] self.unparsable = True - - + self.pre_process() - + self._full_name = self.collapse_whitespace(self._full_name) - + # break up full_name by commas parts = [x.strip() for x in self._full_name.split(",")] - + log.debug("full_name: %s", self._full_name) log.debug("parts: %s", parts) - + if len(parts) == 1: - + # no commas, title first middle middle middle last suffix # part[0] - + pieces = self.parse_pieces(parts) p_len = len(pieces) for i, piece in enumerate(pieces): @@ -497,11 +612,11 @@ def parse_full_name(self): nxt = pieces[i + 1] except IndexError: nxt = None - + # title must have a next piece, unless it's just a title - if self.is_title(piece) \ + if not self.first \ and (nxt or p_len == 1) \ - and not self.first: + and self.is_title(piece): self.title_list.append(piece) continue if not self.first: @@ -511,19 +626,19 @@ def parse_full_name(self): self.first_list.append(piece) continue if self.are_suffixes(pieces[i+1:]) or \ - ( + ( # if the next piece is the last piece and a roman # numeral but this piece is not an initial - self.is_roman_numeral(nxt) and i == p_len - 2 + self.is_roman_numeral(nxt) and i == p_len - 2 and not self.is_an_initial(piece) - ): + ): self.last_list.append(piece) self.suffix_list += pieces[i+1:] break if not nxt: self.last_list.append(piece) continue - + self.middle_list.append(piece) else: # if all the end parts are suffixes and there is more than one piece @@ -535,12 +650,11 @@ def parse_full_name(self): if self.are_suffixes(parts[1].split(' ')) \ and len(parts[0].split(' ')) > 1: - - # suffix comma: + + # suffix comma: # title first middle last [suffix], suffix [suffix] [, suffix] # parts[0], parts[1:...] - - + self.suffix_list += parts[1:] pieces = self.parse_pieces(parts[0].split(' ')) log.debug("pieces: %s", u(pieces)) @@ -550,9 +664,9 @@ def parse_full_name(self): except IndexError: nxt = None - if self.is_title(piece) \ + if not self.first \ and (nxt or len(pieces) == 1) \ - and not self.first: + and self.is_title(piece): self.title_list.append(piece) continue if not self.first: @@ -567,13 +681,13 @@ def parse_full_name(self): continue self.middle_list.append(piece) else: - - # lastname comma: + + # lastname comma: # last [suffix], title first middles[,] suffix [,suffix] # parts[0], parts[1], parts[2:...] - + log.debug("post-comma pieces: %s", u(post_comma_pieces)) - + # lastname part may have suffixes in it lastname_pieces = self.parse_pieces(parts[0].split(' '), 1) for piece in lastname_pieces: @@ -583,16 +697,16 @@ def parse_full_name(self): self.suffix_list.append(piece) else: self.last_list.append(piece) - + for i, piece in enumerate(post_comma_pieces): try: nxt = post_comma_pieces[i + 1] except IndexError: nxt = None - - if self.is_title(piece) \ + + if not self.first \ and (nxt or len(post_comma_pieces) == 1) \ - and not self.first: + and self.is_title(piece): self.title_list.append(piece) continue if not self.first: @@ -607,50 +721,49 @@ def parse_full_name(self): self.suffix_list += parts[2:] except IndexError: pass - + if len(self) < 0: log.info("Unparsable: \"%s\" ", self.original) else: self.unparsable = False self.post_process() - def parse_pieces(self, parts, additional_parts_count=0): """ Split parts on spaces and remove commas, join on conjunctions and lastname prefixes. If parts have periods in the middle, try splitting on periods and check if the parts are titles or suffixes. If they are add to the constant so they will be found. - + :param list parts: name part strings from the comma split - :param int additional_parts_count: - - if the comma format contains other parts, we need to know - how many there are to decide if things should be considered a + :param int additional_parts_count: + + if the comma format contains other parts, we need to know + how many there are to decide if things should be considered a conjunction. :return: pieces split on spaces and joined on conjunctions :rtype: list """ - + output = [] for part in parts: if not isinstance(part, text_types): raise TypeError("Name parts must be strings. " "Got {0}".format(type(part))) output += [x.strip(' ,') for x in part.split(' ')] - + # If part contains periods, check if it's multiple titles or suffixes # together without spaces if so, add the new part with periods to the # constants so they get parsed correctly later for part in output: # if this part has a period not at the beginning or end - if self.C.regexes.period_not_at_end.match(part): + if self.C.regexes.period_not_at_end and self.C.regexes.period_not_at_end.match(part): # split on periods, any of the split pieces titles or suffixes? # ("Lt.Gov.") period_chunks = part.split(".") - titles = list(filter(self.is_title, period_chunks)) + titles = list(filter(self.is_title, period_chunks)) suffixes = list(filter(self.is_suffix, period_chunks)) - + # add the part to the constant so it will be found if len(list(titles)): self.C.titles.add(part) @@ -658,27 +771,27 @@ def parse_pieces(self, parts, additional_parts_count=0): if len(list(suffixes)): self.C.suffix_not_acronyms.add(part) continue - + return self.join_on_conjunctions(output, additional_parts_count) - + def join_on_conjunctions(self, pieces, additional_parts_count=0): """ Join conjunctions to surrounding pieces. Title- and prefix-aware. e.g.: - + ['Mr.', 'and'. 'Mrs.', 'John', 'Doe'] ==> ['Mr. and Mrs.', 'John', 'Doe'] - + ['The', 'Secretary', 'of', 'State', 'Hillary', 'Clinton'] ==> ['The Secretary of State', 'Hillary', 'Clinton'] - + When joining titles, saves newly formed piece to the instance's titles constant so they will be parsed correctly later. E.g. after parsing the example names above, 'The Secretary of State' and 'Mr. and Mrs.' would be present in the titles constant set. - + :param list pieces: name pieces strings after split on spaces - :param int additional_parts_count: - :return: new list with piece next to conjunctions merged into one piece + :param int additional_parts_count: + :return: new list with piece next to conjunctions merged into one piece with spaces in it. :rtype: list @@ -695,7 +808,7 @@ def join_on_conjunctions(self, pieces, additional_parts_count=0): # other, then join those newly joined conjunctions and any single # conjunctions to the piece before and after it conj_index = [i for i, piece in enumerate(pieces) - if self.is_conjunction(piece)] + if self.is_conjunction(piece)] contiguous_conj_i = [] for i, val in enumerate(conj_index): @@ -710,14 +823,14 @@ def join_on_conjunctions(self, pieces, additional_parts_count=0): delete_i = [] for i in contiguous_conj_i: if type(i) == tuple: - new_piece = " ".join(pieces[ i[0] : i[1]+1] ) - delete_i += list(range( i[0]+1, i[1]+1 )) + new_piece = " ".join(pieces[i[0]: i[1]+1]) + delete_i += list(range(i[0]+1, i[1]+1)) pieces[i[0]] = new_piece else: - new_piece = " ".join(pieces[ i : i+2 ]) + new_piece = " ".join(pieces[i: i+2]) delete_i += [i+1] pieces[i] = new_piece - #add newly joined conjunctions to constants to be found later + # add newly joined conjunctions to constants to be found later self.C.conjunctions.add(new_piece) for i in reversed(delete_i): @@ -739,7 +852,7 @@ def join_on_conjunctions(self, pieces, additional_parts_count=0): # http://code.google.com/p/python-nameparser/issues/detail?id=11 continue - if i is 0: + if i == 0: new_piece = " ".join(pieces[i:i+2]) if self.is_title(pieces[i+1]): # when joining to a title, make new_piece a title too @@ -747,9 +860,9 @@ def join_on_conjunctions(self, pieces, additional_parts_count=0): pieces[i] = new_piece pieces.pop(i+1) # subtract 1 from the index of all the remaining conjunctions - for j,val in enumerate(conj_index): + for j, val in enumerate(conj_index): if val > i: - conj_index[j]=val-1 + conj_index[j] = val-1 else: new_piece = " ".join(pieces[i-1:i+2]) @@ -766,11 +879,10 @@ def join_on_conjunctions(self, pieces, additional_parts_count=0): # subtract the number of removed pieces from the index # of all the remaining conjunctions - for j,val in enumerate(conj_index): + for j, val in enumerate(conj_index): if val > i: conj_index[j] = val - rm_count - # join prefixes to following lastnames: ['de la Vega'], ['van Buren'] prefixes = list(filter(self.is_prefix, pieces)) if prefixes: @@ -790,8 +902,11 @@ def join_on_conjunctions(self, pieces, additional_parts_count=0): # join everything after the prefix until the next prefix or suffix try: + if i == 0 and total_length >= 1: + # If it's the first piece and there are more than 1 rootnames, assume it's a first name + continue next_prefix = next(iter(filter(self.is_prefix, pieces[i + 1:]))) - j = pieces.index(next_prefix) + j = pieces.index(next_prefix, i + 1) if j == i + 1: # if there are two prefixes in sequence, join to the following piece j += 1 @@ -812,13 +927,12 @@ def join_on_conjunctions(self, pieces, additional_parts_count=0): log.debug("pieces: %s", pieces) return pieces - - - ### Capitalization Support - + + # Capitalization Support + def cap_word(self, word, attribute): - if (self.is_prefix(word) and attribute in ('last','middle')) \ - or self.is_conjunction(word): + if (self.is_prefix(word) and attribute in ('last', 'middle')) \ + or self.is_conjunction(word): return word.lower() exceptions = self.C.capitalization_exceptions if lc(word) in exceptions: @@ -834,7 +948,8 @@ def cap_after_mac(m): def cap_piece(self, piece, attribute): if not piece: return "" - replacement = lambda m: self.cap_word(m.group(0), attribute) + + def replacement(m): return self.cap_word(m.group(0), attribute) return self.C.regexes.word.sub(replacement, piece) def capitalize(self, force=None): @@ -843,15 +958,15 @@ def capitalize(self, force=None): entered in all upper or lower case. By default, it will not adjust the case of names entered in mixed case. To run capitalization on all names pass the parameter `force=True`. - + :param bool force: Forces capitalization of mixed case strings. This parameter overrides rules set within :py:class:`~nameparser.config.CONSTANTS`. **Usage** - + .. doctest:: capitalize - + >>> name = HumanName('bob v. de la macdole-eisenhower phd') >>> name.capitalize() >>> str(name) @@ -859,12 +974,12 @@ def capitalize(self, force=None): >>> # Don't touch good names >>> name = HumanName('Shirley Maclaine') >>> name.capitalize() - >>> str(name) + >>> str(name) 'Shirley Maclaine' >>> name.capitalize(force=True) - >>> str(name) + >>> str(name) 'Shirley MacLaine' - + """ name = u(self) force = self.C.force_mixed_case_capitalization \ @@ -872,10 +987,10 @@ def capitalize(self, force=None): if not force and not (name == name.upper() or name == name.lower()): return - self.title_list = self.cap_piece(self.title , 'title').split(' ') - self.first_list = self.cap_piece(self.first , 'first').split(' ') + self.title_list = self.cap_piece(self.title, 'title').split(' ') + self.first_list = self.cap_piece(self.first, 'first').split(' ') self.middle_list = self.cap_piece(self.middle, 'middle').split(' ') - self.last_list = self.cap_piece(self.last , 'last').split(' ') + self.last_list = self.cap_piece(self.last, 'last').split(' ') self.suffix_list = self.cap_piece(self.suffix, 'suffix').split(', ') def handle_capitalization(self): diff --git a/setup.py b/setup.py index ba0cc5a..2067716 100755 --- a/setup.py +++ b/setup.py @@ -15,6 +15,7 @@ def read(fname): packages = ['nameparser','nameparser.config'], description = 'A simple Python module for parsing human names into their individual components.', long_description = README, + long_description_content_type = "text/x-rst", version = nameparser.__version__, url = nameparser.__url__, author = nameparser.__author__, diff --git a/tests.py b/tests.py index 5f976b8..2cdd526 100644 --- a/tests.py +++ b/tests.py @@ -1,5 +1,6 @@ # -*- coding: utf-8 -*- from __future__ import unicode_literals +import unittest """ Run this file to run the tests. @@ -19,6 +20,7 @@ """ import logging +import re try: import dill except ImportError: @@ -26,11 +28,10 @@ from nameparser import HumanName from nameparser.util import u -from nameparser.config import Constants +from nameparser.config import Constants, TupleManager log = logging.getLogger('HumanName') -import unittest try: unittest.expectedFailure except AttributeError: @@ -114,7 +115,6 @@ def test_get_full_name_attribute_references_internal_lists(self): hn.first_list = ["Larry"] self.m(hn.full_name, "Larry Williams", hn) - def test_assignment_to_attribute(self): hn = HumanName("John A. Kenneth Doe, Jr.") hn.last = "de la Vega" @@ -200,6 +200,71 @@ def test_surnames_attribute(self): hn = HumanName("John Edgar Casey Williams III") self.m(hn.surnames, "Edgar Casey Williams", hn) + def test_is_prefix_with_list(self): + hn = HumanName() + items = ['firstname', 'lastname', 'del'] + self.assertTrue(hn.is_prefix(items)) + self.assertTrue(hn.is_prefix(items[1:])) + + def test_is_conjunction_with_list(self): + hn = HumanName() + items = ['firstname', 'lastname', 'and'] + self.assertTrue(hn.is_conjunction(items)) + self.assertTrue(hn.is_conjunction(items[1:])) + + def test_override_constants(self): + C = Constants() + hn = HumanName(constants=C) + self.assertTrue(hn.C is C) + + def test_override_regex(self): + var = TupleManager([("spaces", re.compile(r"\s+", re.U)),]) + C = Constants(regexes=var) + hn = HumanName(constants=C) + self.assertTrue(hn.C.regexes == var) + + def test_override_titles(self): + var = ["abc","def"] + C = Constants(titles=var) + hn = HumanName(constants=C) + self.assertTrue(sorted(hn.C.titles) == sorted(var)) + + def test_override_first_name_titles(self): + var = ["abc","def"] + C = Constants(first_name_titles=var) + hn = HumanName(constants=C) + self.assertTrue(sorted(hn.C.first_name_titles) == sorted(var)) + + def test_override_prefixes(self): + var = ["abc","def"] + C = Constants(prefixes=var) + hn = HumanName(constants=C) + self.assertTrue(sorted(hn.C.prefixes) == sorted(var)) + + def test_override_suffix_acronyms(self): + var = ["abc","def"] + C = Constants(suffix_acronyms=var) + hn = HumanName(constants=C) + self.assertTrue(sorted(hn.C.suffix_acronyms) == sorted(var)) + + def test_override_suffix_not_acronyms(self): + var = ["abc","def"] + C = Constants(suffix_not_acronyms=var) + hn = HumanName(constants=C) + self.assertTrue(sorted(hn.C.suffix_not_acronyms) == sorted(var)) + + def test_override_conjunctions(self): + var = ["abc","def"] + C = Constants(conjunctions=var) + hn = HumanName(constants=C) + self.assertTrue(sorted(hn.C.conjunctions) == sorted(var)) + + def test_override_capitalization_exceptions(self): + var = TupleManager([("spaces", re.compile(r"\s+", re.U)),]) + C = Constants(capitalization_exceptions=var) + hn = HumanName(constants=C) + self.assertTrue(hn.C.capitalization_exceptions == var) + class FirstNameHandlingTests(HumanNameTestBase): def test_first_name(self): @@ -210,16 +275,16 @@ def test_assume_title_and_one_other_name_is_last_name(self): hn = HumanName("Rev Andrews") self.m(hn.title, "Rev", hn) self.m(hn.last, "Andrews", hn) - + # TODO: Seems "Andrews, M.D.", Andrews should be treated as a last name - # but other suffixes like "George Jr." should be first names. Might be + # but other suffixes like "George Jr." should be first names. Might be # related to https://github.com/derek73/python-nameparser/issues/2 @unittest.expectedFailure def test_assume_suffix_title_and_one_other_name_is_last_name(self): hn = HumanName("Andrews, M.D.") self.m(hn.suffix, "M.D.", hn) self.m(hn.last, "Andrews", hn) - + def test_suffix_in_lastname_part_of_lastname_comma_format(self): hn = HumanName("Smith Jr., John") self.m(hn.last, "Smith", hn) @@ -230,22 +295,22 @@ def test_sir_exception_to_first_name_rule(self): hn = HumanName("Sir Gerald") self.m(hn.title, "Sir", hn) self.m(hn.first, "Gerald", hn) - + def test_king_exception_to_first_name_rule(self): hn = HumanName("King Henry") self.m(hn.title, "King", hn) self.m(hn.first, "Henry", hn) - + def test_queen_exception_to_first_name_rule(self): hn = HumanName("Queen Elizabeth") self.m(hn.title, "Queen", hn) self.m(hn.first, "Elizabeth", hn) - + def test_dame_exception_to_first_name_rule(self): hn = HumanName("Dame Mary") self.m(hn.title, "Dame", hn) self.m(hn.first, "Mary", hn) - + def test_first_name_is_not_prefix_if_only_two_parts(self): """When there are only two parts, don't join prefixes or conjunctions""" hn = HumanName("Van Nguyen") @@ -263,7 +328,7 @@ def test_first_name_is_prefix_if_three_parts(self): hn = HumanName("Mr. Van Nguyen") self.m(hn.first, "Van", hn) self.m(hn.last, "Nguyen", hn) - + class HumanNameBruteForceTests(HumanNameTestBase): @@ -1084,7 +1149,7 @@ def test_multiple_conjunctions(self): def test_multiple_conjunctions2(self): hn = HumanName("part1 of and The part2 of the part3 And part4") self.m(hn.first, "part1 of and The part2 of the part3 And part4", hn) - + def test_ends_with_conjunction(self): hn = HumanName("Jon Dough and") self.m(hn.first, "Jon", hn) @@ -1242,12 +1307,12 @@ def test_le_as_last_name_with_middle_initial(self): self.m(hn.first, "Yin", hn) self.m(hn.middle, "a", hn) self.m(hn.last, "Le", hn) - + def test_conjunction_in_an_address_with_a_title(self): hn = HumanName("His Excellency Lord Duncan") self.m(hn.title, "His Excellency Lord", hn) self.m(hn.last, "Duncan", hn) - + @unittest.expectedFailure def test_conjunction_in_an_address_with_a_first_name_title(self): hn = HumanName("Her Majesty Queen Elizabeth") @@ -1272,7 +1337,7 @@ def test_add_title(self): self.m(hn.title, "Te", hn) self.m(hn.first, "Awanui-a-Rangi", hn) self.m(hn.last, "Black", hn) - + def test_remove_title(self): hn = HumanName("Hon Solo", constants=None) start_len = len(hn.C.titles) @@ -1282,7 +1347,7 @@ def test_remove_title(self): hn.parse_full_name() self.m(hn.first, "Hon", hn) self.m(hn.last, "Solo", hn) - + def test_add_multiple_arguments(self): hn = HumanName("Assoc Dean of Chemistry Robert Johns", constants=None) hn.C.titles.add('dean', 'Chemistry') @@ -1310,7 +1375,7 @@ def test_can_change_global_constants(self): self.assertEqual(hn2.has_own_config, False) # clean up so we don't mess up other tests hn.C.titles.add('hon') - + def test_remove_multiple_arguments(self): hn = HumanName("Ms Hon Solo", constants=None) hn.C.titles.remove('hon', 'ms') @@ -1370,7 +1435,7 @@ def test_nickname_in_parenthesis(self): self.m(hn.middle, "", hn) self.m(hn.last, "Franklin", hn) self.m(hn.nickname, "Ben", hn) - + def test_two_word_nickname_in_parenthesis(self): hn = HumanName("Benjamin (Big Ben) Franklin") self.m(hn.first, "Benjamin", hn) @@ -1391,7 +1456,7 @@ def test_nickname_in_parenthesis_with_comma(self): self.m(hn.middle, "", hn) self.m(hn.last, "Franklin", hn) self.m(hn.nickname, "Ben", hn) - + def test_nickname_in_parenthesis_with_comma_and_suffix(self): hn = HumanName("Franklin, Benjamin (Ben), Jr.") self.m(hn.first, "Benjamin", hn) @@ -1399,7 +1464,7 @@ def test_nickname_in_parenthesis_with_comma_and_suffix(self): self.m(hn.last, "Franklin", hn) self.m(hn.suffix, "Jr.", hn) self.m(hn.nickname, "Ben", hn) - + def test_nickname_in_single_quotes(self): hn = HumanName("Benjamin 'Ben' Franklin") self.m(hn.first, "Benjamin", hn) @@ -1413,28 +1478,28 @@ def test_nickname_in_double_quotes(self): self.m(hn.middle, "", hn) self.m(hn.last, "Franklin", hn) self.m(hn.nickname, "Ben", hn) - + def test_single_quotes_on_first_name_not_treated_as_nickname(self): hn = HumanName("Brian Andrew O'connor") self.m(hn.first, "Brian", hn) self.m(hn.middle, "Andrew", hn) self.m(hn.last, "O'connor", hn) self.m(hn.nickname, "", hn) - + def test_single_quotes_on_both_name_not_treated_as_nickname(self): hn = HumanName("La'tanya O'connor") self.m(hn.first, "La'tanya", hn) self.m(hn.middle, "", hn) self.m(hn.last, "O'connor", hn) self.m(hn.nickname, "", hn) - + def test_single_quotes_on_end_of_last_name_not_treated_as_nickname(self): hn = HumanName("Mari' Aube'") self.m(hn.first, "Mari'", hn) self.m(hn.middle, "", hn) self.m(hn.last, "Aube'", hn) self.m(hn.nickname, "", hn) - + def test_okina_inside_name_not_treated_as_nickname(self): hn = HumanName("Harrieta Keōpūolani Nāhiʻenaʻena") self.m(hn.first, "Harrieta", hn) @@ -1492,7 +1557,6 @@ def test_nickname_and_last_name_with_title(self): self.m(hn.nickname, "Rick", hn) - # class MaidenNameTestCase(HumanNameTestBase): # # def test_parenthesis_and_quotes_together(self): @@ -1542,17 +1606,28 @@ def test_prefix(self): hn = HumanName("Juan del Sur") self.m(hn.first, "Juan", hn) self.m(hn.last, "del Sur", hn) - + def test_prefix_with_period(self): hn = HumanName("Jill St. John") self.m(hn.first, "Jill", hn) self.m(hn.last, "St. John", hn) - + def test_prefix_before_two_part_last_name(self): hn = HumanName("pennie von bergen wessels") self.m(hn.first, "pennie", hn) self.m(hn.last, "von bergen wessels", hn) + def test_prefix_is_first_name(self): + hn = HumanName("Van Johnson") + self.m(hn.first, "Van", hn) + self.m(hn.last, "Johnson", hn) + + def test_prefix_is_first_name_with_middle_name(self): + hn = HumanName("Van Jeremy Johnson") + self.m(hn.first, "Van", hn) + self.m(hn.middle, "Jeremy", hn) + self.m(hn.last, "Johnson", hn) + def test_prefix_before_two_part_last_name_with_suffix(self): hn = HumanName("pennie von bergen wessels III") self.m(hn.first, "pennie", hn) @@ -1641,7 +1716,7 @@ def test_comma_three_conjunctions(self): class SuffixesTestCase(HumanNameTestBase): - + def test_suffix(self): hn = HumanName("Joe Franklin Jr") self.m(hn.first, "Joe", hn) @@ -1716,13 +1791,13 @@ def test_phd_conflict(self): self.m(hn.first, "Adolph", hn) self.m(hn.last, "D", hn) - # http://en.wikipedia.org/wiki/Ma_(surname) + def test_potential_suffix_that_is_also_last_name(self): hn = HumanName("Jack Ma") self.m(hn.first, "Jack", hn) self.m(hn.last, "Ma", hn) - + def test_potential_suffix_that_is_also_last_name_comma(self): hn = HumanName("Ma, Jack") self.m(hn.first, "Jack", hn) @@ -1784,8 +1859,8 @@ def test_last_name_is_also_title_no_comma(self): self.m(hn.suffix, "Jr.", hn) def test_last_name_is_also_title_with_comma(self): - hn = HumanName("Duke Martin Luther King, Jr.") - self.m(hn.title, "Duke", hn) + hn = HumanName("Dr Martin Luther King, Jr.") + self.m(hn.title, "Dr", hn) self.m(hn.first, "Martin", hn) self.m(hn.middle, "Luther", hn) self.m(hn.last, "King", hn) @@ -1820,27 +1895,27 @@ def test_chained_title_first_name_title_is_initials(self): self.m(hn.first, "Marc", hn) self.m(hn.middle, "Thomas", hn) self.m(hn.last, "Treadwell", hn) - + def test_conflict_with_chained_title_first_name_initial(self): hn = HumanName("U. S. Grant") self.m(hn.first, "U.", hn) self.m(hn.middle, "S.", hn) self.m(hn.last, "Grant", hn) - + def test_chained_title_first_name_initial_with_no_period(self): hn = HumanName("US Magistrate Judge T Michael Putnam") self.m(hn.title, "US Magistrate Judge", hn) self.m(hn.first, "T", hn) self.m(hn.middle, "Michael", hn) self.m(hn.last, "Putnam", hn) - + def test_chained_hyphenated_title(self): hn = HumanName("US Magistrate-Judge Elizabeth E Campbell") self.m(hn.title, "US Magistrate-Judge", hn) self.m(hn.first, "Elizabeth", hn) self.m(hn.middle, "E", hn) self.m(hn.last, "Campbell", hn) - + def test_chained_hyphenated_title_with_comma_suffix(self): hn = HumanName("Mag-Judge Harwell G Davis, III") self.m(hn.title, "Mag-Judge", hn) @@ -1883,7 +1958,7 @@ def test_title_with_last_initial_is_suffix(self): self.m(hn.title, "King", hn) self.m(hn.first, "John", hn) self.m(hn.last, "V.", hn) - + def test_initials_also_suffix(self): hn = HumanName("Smith, J.R.") self.m(hn.first, "J.R.", hn) @@ -1981,6 +2056,26 @@ def test_title_with_periods_lastname_comma(self): self.m(hn.first, "John", hn) self.m(hn.last, "Doe", hn) + def test_mac_with_spaces(self): + hn = HumanName("Jane Mac Beth") + self.m(hn.first, "Jane", hn) + self.m(hn.last, "Mac Beth", hn) + + def test_mac_as_first_name(self): + hn = HumanName("Mac Miller") + self.m(hn.first, "Mac", hn) + self.m(hn.last, "Miller", hn) + + def test_multiple_prefixes(self): + hn = HumanName("Mike van der Velt") + self.m(hn.first, "Mike", hn) + self.m(hn.last, "van der Velt", hn) + + def test_2_same_prefixes_in_the_name(self): + hh = HumanName("Vincent van Gogh van Beethoven") + self.m(hh.first, "Vincent", hh) + self.m(hh.middle, "van Gogh", hh) + self.m(hh.last, "van Beethoven", hh) class HumanNameCapitalizationTestCase(HumanNameTestBase): def test_capitalization_exception_for_III(self): @@ -2062,10 +2157,10 @@ def test_capitalize_prefix_clash_on_first_name(self): class HumanNameOutputFormatTests(HumanNameTestBase): - + def test_formatting_init_argument(self): hn = HumanName("Rev John A. Kenneth Doe III (Kenny)", - string_format="TEST1") + string_format="TEST1") self.assertEqual(u(hn), "TEST1") def test_formatting_constants_attribute(self): @@ -2160,7 +2255,7 @@ def test_formating_of_nicknames_in_middle(self): self.assertEqual(u(hn), "Rev John (Kenny) A. Kenneth Doe III") hn.nickname = '' self.assertEqual(u(hn), "Rev John A. Kenneth Doe III") - + def test_remove_emojis(self): hn = HumanName("Sam Smith 😊") self.m(hn.first, "Sam", hn) @@ -2184,6 +2279,114 @@ def test_keep_emojis(self): # test cleanup +class InitialsTestCase(HumanNameTestBase): + def test_initials(self): + hn = HumanName("Andrew Boris Petersen") + self.m(hn.initials(), "A. B. P.", hn) + + def test_initials_simple_name(self): + hn = HumanName("John Doe") + self.m(hn.initials(), "J. D.", hn) + hn = HumanName("John Doe", initials_format="{first} {last}") + self.m(hn.initials(), "J. D.", hn) + hn = HumanName("John Doe", initials_format="{last}") + self.m(hn.initials(), "D.", hn) + hn = HumanName("John Doe", initials_format="{first}") + self.m(hn.initials(), "J.", hn) + hn = HumanName("John Doe", initials_format="{middle}") + self.m(hn.initials(), "", hn) + + def test_initials_complex_name(self): + hn = HumanName("Doe, John A. Kenneth, Jr.") + self.m(hn.initials(), "J. A. K. D.", hn) + + def test_initials_format(self): + hn = HumanName("Doe, John A. Kenneth, Jr.", initials_format="{first} {middle}") + self.m(hn.initials(), "J. A. K.", hn) + hn = HumanName("Doe, John A. Kenneth, Jr.", initials_format="{first} {last}") + self.m(hn.initials(), "J. D.", hn) + hn = HumanName("Doe, John A. Kenneth, Jr.", initials_format="{middle} {last}") + self.m(hn.initials(), "A. K. D.", hn) + hn = HumanName("Doe, John A. Kenneth, Jr.", initials_format="{first}, {last}") + self.m(hn.initials(), "J., D.", hn) + + def test_initials_format_constants(self): + from nameparser.config import CONSTANTS + _orig = CONSTANTS.initials_format + CONSTANTS.initials_format = "{first} {last}" + hn = HumanName("Doe, John A. Kenneth, Jr.") + self.m(hn.initials(), "J. D.", hn) + CONSTANTS.initials_format = "{first} {last}" + hn = HumanName("Doe, John A. Kenneth, Jr.") + self.m(hn.initials(), "J. D.", hn) + CONSTANTS.initials_format = _orig + + def test_initials_delimiter(self): + hn = HumanName("Doe, John A. Kenneth, Jr.", initials_delimiter=";") + self.m(hn.initials(), "J; A; K; D;", hn) + + def test_initials_delimiter_constants(self): + from nameparser.config import CONSTANTS + _orig = CONSTANTS.initials_delimiter + CONSTANTS.initials_delimiter = ";" + hn = HumanName("Doe, John A. Kenneth, Jr.") + self.m(hn.initials(), "J; A; K; D;", hn) + CONSTANTS.initials_delimiter = _orig + + def test_initials_list(self): + hn = HumanName("Andrew Boris Petersen") + self.m(hn.initials_list(), ["A", "B", "P"], hn) + + def test_initials_list_complex_name(self): + hn = HumanName("Doe, John A. Kenneth, Jr.") + self.m(hn.initials_list(), ["J", "A", "K", "D"], hn) + + def test_initials_with_prefix_firstname(self): + hn = HumanName("Van Jeremy Johnson") + self.m(hn.initials_list(), ["V", "J", "J"], hn) + + def test_initials_with_prefix(self): + hn = HumanName("Alex van Johnson") + self.m(hn.initials_list(), ["A", "J"], hn) + + def test_constructor_first(self): + hn = HumanName(first="TheName") + self.assertFalse(hn.unparsable) + self.m(hn.first, "TheName", hn) + + def test_constructor_middle(self): + hn = HumanName(middle="TheName") + self.assertFalse(hn.unparsable) + self.m(hn.middle, "TheName", hn) + + def test_constructor_last(self): + hn = HumanName(last="TheName") + self.assertFalse(hn.unparsable) + self.m(hn.last, "TheName", hn) + + def test_constructor_title(self): + hn = HumanName(title="TheName") + self.assertFalse(hn.unparsable) + self.m(hn.title, "TheName", hn) + + def test_constructor_suffix(self): + hn = HumanName(suffix="TheName") + self.assertFalse(hn.unparsable) + self.m(hn.suffix, "TheName", hn) + + def test_constructor_nickname(self): + hn = HumanName(nickname="TheName") + self.assertFalse(hn.unparsable) + self.m(hn.nickname, "TheName", hn) + + def test_constructor_multiple(self): + hn = HumanName(first="TheName", last="lastname", title="mytitle", full_name="donotparse") + self.assertFalse(hn.unparsable) + self.m(hn.first, "TheName", hn) + self.m(hn.last, "lastname", hn) + self.m(hn.title, "mytitle", hn) + + TEST_NAMES = ( "John Doe", "John Doe, Jr.", @@ -2359,7 +2562,7 @@ def test_keep_emojis(self): "U.S. District Judge Marc Thomas Treadwell", "Dra. Andréia da Silva", "Srta. Andréia da Silva", - + ) @@ -2411,6 +2614,7 @@ def test_variations_of_TEST_NAMES(self): print((repr(hn_instance))) hn_instance.capitalize() print((repr(hn_instance))) + print("Initials: " + hn_instance.initials()) else: print("-"*80) print("Running tests")