Package: pycallnumber
pycallnumberUse pycallnumber in your library's Python projects to parse, model, and manipulate any type of call number string. Support for Library of Congress, Dewey Decimal, SuDocs, and local call numbers is built in, and you can extend built-in classes to customize behavior or model other types of call numbers and formatted strings.InstallationWhat can you do with pycallnumber?Configurable settingsInstallationRequirementsTests pass on Linux and MacOS Python 2.7, 3.5, 3.6, 3.7, 3.8, 3.9, 3.10, and 3.11. Versions 3.4 and below may still work, but I'm unable to get these to compile any more so cannot test them.Warning: Outdated Python VersionsWarning— The next release, v1.0.0, will drop support for Python versions older than 3.7.DependenciesIf you're using Python >=3.8, there are no external dependencies beyond the standard library.For Python 2.7 to 3.7, theimportlib_metadatabackport is used forimportlib.metadatafunctionality (first available in Python 3.8).For Python 2.7, thefuturemodule is used to replicate various Python 3 behaviors.SetupInstalling to avirtualenvusing pip is recommended.$python-mpipinstallpycallnumberDevelopment setup and testingIf you want to contribute to pycallnumber, you should fork the project and then download and install your fork from GitHub. E.g.:gitclonehttps://github.com/[your-github-user]/pycallnumber.gitpycallnumberor (SSH)gitclonegit@github.com:[your-github-user]/pycallnumber.gitpycallnumberThen use pip to do an editable install of the package with thedevextras (which installs pytest).cdpycallnumber
python-mpipinstall-e.[dev]Running tests(The below commands assume you've installed from GitHub as described above and are in the repository root.)Invokepytestto run tests in your current Python environment.pytestToxYou can usetoxto run tests against multiple Python versions, provided you have them available on thePATH. An excellent tool for this ispyenvwithpyenv-virtualenv.The tox configuration is inpyproject.toml(see the[tool.tox]section), which defines several test environments. You can run them all at once or target specific environments.tox# run tests against all configured environmentstox-epy27-oldest# run tests against python 2.7 with oldest depstox-epy310-latest# run tests against python 3.10 with latest depstox-eflake8# run flake8 linting# etc.TopWhat can you do with pycallnumber?ParseYou can parse call number strings, like Library of Congress call numbers ...>>>importpycallnumberaspycn>>>cn=pycn.callnumber('MT 1001 .C35 B40 1992 no. 1')>>>cn<LC 'MT 1001 .C35 B40 1992 no. 1'>>>>cn.classification<LcClass 'MT 1001'>>>>cn.classification.letters<LcClass.ClassLetters 'MT'>>>>cn.classification.number<LcClass.ClassNumber '1001'>>>>cn.cutters[0]<Cutter 'C35'>>>>cn.cutters[1]<Cutter 'B40'>>>>cn.edition<Edition '1992'>>>>cn.item<Item 'no. 1'>... Dewey Decimal call numbers ...>>>cn=pycn.callnumber('500.1 C226t bk.2')>>>cn<Dewey '500.1 C226t bk.2'>>>>cn.classification<DeweyClass '500.1'>>>>cn.cutters[0]<DeweyCutter 'C226t'>>>>cn.cutters[0].workmark<Alphabetic 't'>>>>cn.item<Item 'bk.2'>... US SuDocs numbers ...>>>cn=pycn.callnumber('HI.F 3/178-8:A 44/2013 ardocs')>>>cn<SuDoc 'HI.F 3/178-8:A 44/2013 ardocs'>>>>cn.stem<AgencyDotSeries 'HI.F 3/178-8'>>>>cn.stem.agency<Agency 'HI'>>>>cn.stem.series<Series 'F 3/178-8'>>>>cn.stem.series.main_series<Cutter 'F 3'>>>>cn.stem.series.related_series<Series.RelatedSeries '178-8'>>>>cn.book_number<BookNumber 'A 44/2013 ardocs'>>>>cn.book_number.parts[0]<BookNumber.Component 'A 44'>>>>cn.book_number.parts[1]<BookNumber.Component '2013 ardocs'>... and other (i.e. local) call numbers that don't follow the above prescribed patterns.>>>cn=pycn.callnumber('LPCD 100,025-A')>>>cn<Local 'LPCD 100,025-A'>>>>cn.parts[0]<Alphabetic 'LPCD'>>>>cn.parts[1]<Number '100,025'>>>>cn.parts[2]<Formatting '-'>>>>cn.parts[3]<Alphabetic 'A'>When parsing, pycallnumber is as permissive as possible, allowing for differences in spacing, formatting, and case. As such, it's intended to be suitable for use in a real-world environment, requiring no pre-normalization of call number strings.>>>pycn.callnumber('mt 1001 c35 1992 no. 1')<LC 'mt 1001 c35 1992 no. 1'>>>>pycn.callnumber('mt 1001 c35 1992 no. 1').classification<LcClass 'mt 1001'>>>>pycn.callnumber('Mt1001 c35 1992 no. 1').classification<LcClass 'Mt1001'>>>>pycn.callnumber('Mt   1001 c35 1992 no. 1').classification<LcClass 'Mt   1001'>>>>pycn.callnumber('Mt   1001 c35 1992 no. 1').classification.letters<LcClass.ClassLetters 'Mt'>>>>pycn.callnumber('Mt   1001 c35 1992 no. 1').classification.number<LcClass.ClassNumber '1001'>>>>pycn.callnumber('mt 1001c35 1992 no. 1').cutters[0]<Cutter 'c35'>>>>pycn.callnumber('mt 1001.c35 1992 no. 1').cutters[0]<Cutter 'c35'>>>>pycn.callnumber('mt 1001 c35 1992 no. 1').cutters[0]<Cutter 'c35'>>>>pycn.callnumber('mt 1001 .c35 1992 no. 1').cutters[0]<Cutter 'c35'>>>>pycn.callnumber('mt 1001 .c 35 1992 no. 1').cutters[0]<Cutter 'c 35'>>>>pycn.callnumber('mt 1001 C 35 1992 no. 1').cutters[0]<Cutter 'C 35'>Finally, pycallnumber attempts to interpret and parse structured bits that you might find within less structured parts of call numbers, like item-specific information (volume and copy numbers, issue dates, etc.). Numbers may or may not include a thousands separator. Dates—even partial dates—if recognized, are parsed into a year, month, and day.>>>pycn.callnumber('LPCD 100,001')==pycn.callnumber('LPCD 100001')True>>>cn=pycn.callnumber('MT 1001 .C35 January 2012')>>>cn.item<Item 'January 2012'>>>>cn.item.parts[0]<DateString 'January 2012'>>>>cn.item.parts[0].year<Year '2012'>>>>cn.item.parts[0].month<Month 'January'>>>>cn.item.parts[0].day>>>cn=pycn.callnumber('MT 1001 .C35 01-31-2012')>>>cn.item.parts[0].year<Year '2012'>>>>cn.item.parts[0].month<Month '01'>>>>cn.item.parts[0].day<Day '31'>>>>NormalizeAny call number can be normalized for sorting ...>>>importpycallnumberaspycn>>>lc_cn=pycn.callnumber('MT 1001 .C35 B40 1992 no. 1')>>>dewey_cn=pycn.callnumber('500.1 c226t bk.2')>>>sudocs_cn=pycn.callnumber('HI.F 3/178-8:A 44/2013 ardocs')>>>local_cn=pycn.callnumber('LPCD 100,025-A')>>>lc_cn.for_sort()u'mt!1001!c!35!b!40!0000001992!!0000000001'>>>dewey_cn.for_sort()u'500.1!c!226!t!!0000000002'>>>sudocs_cn.for_sort()u'hi.f!3/0000000178-0000000008!!a!0000000044/0000002013!!ardocs'>>>local_cn.for_sort()u'lpcd!0000100025!a'... for left-anchored searching ...>>>lc_cn.for_search()u'mt1001c35b4019921'>>>dewey_cn.for_search()u'500.1c226t2'>>>sudocs_cn.for_search()u'hif31788a442013ardocs'>>>local_cn.for_search()u'lpcd100025a'... and for display.>>>lc_cn.for_print()u'MT 1001 .C35 B40 1992 no. 1'>>>dewey_cn.for_print()u'500.1 c226t bk.2'>>>sudocs_cn.for_print()u'HI.F 3/178-8:A 44/2013 ardocs'>>>local_cn.for_print()u'LPCD 100,025-A'OperateYou can compare call numbers using comparison operators, and the typical methods for sorting work as you'd expect. Comparison operators use the normalizedfor_sortversion of the call number as the basis for comparison, so call numbers expressed with differences in spacing or formatting won't throw off comparisons and sorting, as long as the call numbers are recognizable and are parsed correctly.>>>importpycallnumberaspycn>>>pycn.callnumber('Mt1001 c35 1992 no. 1')==pycn.callnumber('MT 1001 .C35 1992 #1')True>>>cnstrings=['MT 1001 .C35 B40 1992 no. 1',...'MT 1001 .C35 B40 1992 no. 2',...'MT 1001 .C35 B40 1990',...'M 120 .A20 2002 c.2',...'MT 100 .S23 1985',...'M 120 .A20 2002 copy 1',...'MT 1001 .C35 B100 2013',...'MT 1001 .C35 B40 1991',...'MT 1001 .C35 B40 1992 no. 2 copy 2']>>>lccns=[pycn.callnumber(cn)forcnincnstrings]>>>lccns[1]>lccns[2]True>>>lccns[1]<lccns[2]False>>>forcninsorted(lccns):printcn...M 120 .A20 2002 copy 1M 120 .A20 2002 c.2MT 100 .S23 1985MT 1001 .C35 B100 2013MT 1001 .C35 B40 1990MT 1001 .C35 B40 1991MT 1001 .C35 B40 1992 no. 1MT 1001 .C35 B40 1992 no. 2MT 1001 .C35 B40 1992 no. 2 copy 2You can also work withsetsof call numbers using the same operators you'd use forbuilt-in Python sets.E.g., given the following ranges:>>>MT0_MT500=pycn.cnrange('MT 0','MT 500')>>>MT500_MT1000=pycn.cnrange('MT 500','MT 1000')>>>MT300_MT800=pycn.cnrange('MT 300','MT 800')>>>MT0_N0=pycn.cnrange('MT 0','N 0')>>>MT2000_N0=pycn.cnrange('MT 2000','N 0')>>>forrgin(MT0_MT500,MT500_MT1000,MT300_MT800,MT0_N0,MT2000_N0):printrg...<LcClass RangeSet 'MT 0' to 'MT 500'><LcClass RangeSet 'MT 500' to 'MT 1000'><LcClass RangeSet 'MT 300' to 'MT 800'><LcClass RangeSet 'MT 0' to 'N 0'><LcClass RangeSet 'MT 2000' to 'N 0'>You can test whether a call number is in a particular range or set.>>>pycn.callnumber('MT 500 .A0 1900').classificationinMT0_MT500False>>>pycn.callnumber('MT 500 .A0 1900').classificationinMT500_MT1000True>>>pycn.callnumber('MS 9999.9999 .Z99 9999').classificationinMT0_MT500FalseTest how sets relate to one another.>>>MT0_MT500inMT500_MT1000False>>>MT0_MT500.issubset(MT500_MT1000)False>>>MT0_MT500>MT500_MT1000False>>>MT0_MT500<MT500_MT1000False>>>MT0_MT500.issuperset(MT500_MT1000)False>>>MT0_MT500.overlaps(MT500_MT1000)False>>>MT0_MT500.isdisjoint(MT500_MT1000)True>>>MT0_MT500.issequential(MT500_MT1000)True>>>MT0_MT500.isbefore(MT500_MT1000)True>>>MT0_MT500.extendslower(MT500_MT1000)True>>>MT0_MT500.overlaps(MT300_MT800)True>>>MT0_MT500.isdisjoint(MT300_MT800)False>>>MT0_MT500.isbefore(MT300_MT800)False>>>MT0_MT500.isafter(MT300_MT800)False>>>MT300_MT800.extendshigher(MT0_MT500)True>>>MT0_MT500.extendslower(MT300_MT800)True>>>MT0_MT500inMT300_MT800False>>>MT300_MT800inMT0_MT500False>>>MT0_MT500inMT0_N0True>>>MT0_MT500.issubset(MT0_N0)True>>>MT0_MT500<MT0_N0TrueJoin two or more sets.>>>MT0_MT500|MT300_MT800<LcClass RangeSet 'MT 0' to 'MT 800'>>>>MT0_MT500|MT2000_N0<LcClass RangeSet 'MT 0' to 'MT 500', 'MT 2000' to 'N 0'>>>>MT0_MT500|MT2000_N0|MT500_MT1000<LcClass RangeSet 'MT 0' to 'MT 1000', 'MT 2000' to 'N 0'>>>>MT0_MT500.union(MT500_MT1000,MT2000_N0,MT0_N0)<LcClass RangeSet 'MT 0' to 'N 0'>Intersect two or more sets.>>>MT0_MT500&MT300_MT800<LcClass RangeSet 'MT 300' to 'MT 500'>>>>MT0_MT500&MT500_MT1000<RangeSet >>>>MT300_MT800&MT500_MT1000&MT0_N0<LcClass RangeSet 'MT 500' to 'MT 800'>>>>MT300_MT800.intersection(MT500_MT1000,MT0_N0)<LcClass RangeSet 'MT 500' to 'MT 800'>Get the difference of two or more sets.>>>MT0_N0-MT0_MT500<LcClass RangeSet 'MT 500' to 'N 0'>>>>MT0_N0-MT2000_N0<LcClass RangeSet 'MT 0' to 'MT 2000'>>>>MT0_N0-MT2000_N0-MT300_MT800<LcClass RangeSet 'MT 0' to 'MT 300', 'MT 800' to 'MT 2000'>>>>MT0_N0.difference(MT2000_N0,MT300_MT800)<LcClass RangeSet 'MT 0' to 'MT 300', 'MT 800' to 'MT 2000'>Get the symmetric difference of two sets—i.e., the set of things in one or the other but not both.>>>MT300_MT800^MT0_N0<LcClass RangeSet 'MT 0' to 'MT 300', 'MT 800' to 'N 0'>>>>MT0_MT500^MT2000_N0<LcClass RangeSet 'MT 0' to 'MT 500', 'MT 2000' to 'N 0'>ExtendYou can subclass any of the call numberUnitclasses in your own projects if you need to customize their behavior.For example, if you want your LC call numbers to be normalized a particular way for display, you can override thefor_printmethod:importpycallnumberaspycnclassMyLC(pycn.units.LC):deffor_print(self):lcclass='{}{}'.format(str(self.classification.letters).upper(),self.classification.number)cutters=['{}{}'.format(str(c.letters.upper()),c.number)forcinself.cutters]output='{}.{}'.format(lcclass,' '.join(cutters))ifself.editionisnotNone:output='{}{}'.format(output,self.edition)ifself.itemisnotNone:output='{}{}'.format(output,self.item)returnoutput>>>MyLC('MT 100 .C35 1992').for_print()'MT100 .C35 1992'>>>MyLC('MT 100 c35 1992').for_print()'MT100 .C35 1992'>>>MyLC('mt 100 c35 1992 v. 1').for_print()'MT100 .C35 1992 v. 1'>>>MyLC('mt 100 c35 e20 1992 v. 1').for_print()'MT100 .C35 E20 1992 v. 1'Unitclasses also have aderiveclass factory method that makes deriving new unit types simpler and less verbose. This is useful if you need to represent call numbers and other formatted strings not included in the package. For example, you could create a unit type for US dollars:importpycallnumberaspycnDollarSign=pycn.units.Formatting.derive(classname='DollarSign',base_pattern=r'\$',min_length=1,max_length=1)DollarAmount=pycn.units.Number.derive(classname='DollarAmount',min_decimal_places=0,max_decimal_places=2)UsDollars=pycn.units.NumericSymbol.derive(classname='UsDollars',separator_type=None,groups=[{'name':'dollarsign','min':1,'max':1,'type':DollarSign},{'name':'amount','min':1,'max':1,'type':DollarAmount}])>>>UsDollars('$23')<UsDollars '$23'>>>>UsDollars('$23.00')<UsDollars '$23.00'>>>>UsDollars('$23.03')<UsDollars '$23.03'>>>>UsDollars('$23.030')Traceback (most recent call last):File"<stdin>", line1, in<module>File"pycallnumber/unit.py", line143, in__init__super(CompoundUnit,self).__init__(cnstr,name,**options)File"pycallnumber/unit.py", line28, in__init__self._validate_result=type(self).validate(cnstr,self.options)File"pycallnumber/unit.py", line74, invalidateraiseInvalidCallNumberStringError(msg)pycallnumber.exceptions.InvalidCallNumberStringError:'$23.030' is not a valid UsDollars Unit. It should be a string with 1 ``dollarsign`` grouping and 1 ``amount`` grouping.**** Here is what was found while attempting to parse '$23.030' ****'$' matched the dollarsign grouping.'23.03' matched the ``amount`` grouping.'0' does not match any grouping.>>>>>>UsDollars('23.00')Traceback (most recent call last):File"<stdin>", line1, in<module>File"pycallnumber/unit.py", line143, in__init__super(CompoundUnit,self).__init__(cnstr,name,**options)File"pycallnumber/unit.py", line28, in__init__self._validate_result=type(self).validate(cnstr,self.options)File"pycallnumber/unit.py", line74, invalidateraiseInvalidCallNumberStringError(msg)pycallnumber.exceptions.InvalidCallNumberStringError:'23.00' is not a valid UsDollars Unit. It should be a string with 1 ``dollarsign`` grouping and 1 ``amount`` grouping.**** Here is what was found while attempting to parse '23.00' ****'23.00' does not match any grouping.TopConfigurable settingsPycallnumber uses a package-widesettings.pyfile to store various default configuration settings. With one exception, the defaults should suffice for most uses. But, since youcanoverride certain settings, and the options aren't immediately obvious, I've documented them here.Overriding the list of Unit types that the factory functions detectBy far the most common thing that you will want to override is the list of default Unit types that the factory functions—pycallnumber.callnumber,pycallnumber.cnrange,pycallnumber.cnset—detect automatically. (The default list is inpycallnumber.settings.DEFAULT_UNIT_TYPES.)You can override the default list on a call-by-call basis. To do so, pass a list of the Unit classes you want to detect to one of the factory functions via theunittypeskwarg. Example:importpycallnumberclassMyDewey(pycallnumber.units.Dewey):# Defines local Dewey Unit type# ...my_unit_types=[MyDewey,pycallnumber.units.LC,pycallnumber.units.SuDoc,pycallnumber.units.Local]call=pycallnumber.callnumber('M 801.951 L544p',unittypes=my_unit_types)# ... rest of the scriptTwo important things to note.Unit type order matters.A string may match multiple Unit types, and the factory functions will use whatever type matches first. Make sure you have them listed in order of precedence. For instance, theLocaltype will match just about anything and serves as a catch-all, so it's listed last. Since you can vary the list on a call-by-call basis, you could tailor that list dynamically to help increase chances of matching a particular call number to the correct type.Yourunittypeslist should be a list of classes, not a list of class path strings.Thesettings.DEFAULT_UNIT_TYPESis a list of class path strings, but this was done to get around having circular imports in thesettingsmodule.Overriding certain Unit optionsEach Unit type has a list of options that you can pass via kwargs when you instantiate it. Children classes inherit options from their parents. Default values for each class are set via anoptions_defaultsclass attribute, and the default defaults are insettings.py. These values should work for 99% of uses, but you can override them if you need to.Alphabetic case optionsunits.simple.Alphabetic, all Unit types derived from that type, and allCompoundUnittypes that include a Unit derived from that type allow you to control how alphabetic case is normalized.Value'lower'normalizes alphabetic characters to lowercase;'upper'normalizes to uppercase. Anything else keeps the original case.display_casecontrols what case thefor_printUnit method outputs. Default is a blank string, to keep the original case (settings.DEFAULT_DISPLAY_CASE).search_casecontrols what case thefor_searchUnit method outputs. Default is'lower'(settings.DEFAULT_SEARCH_CASE).sort_casecontrols what case thefor_sortUnit method outputs. Default is'lower'(settings.DEFAULT_SORT_CASE).Formatting 'use in' optionsunits.simple.Formatting, all Unit types derived from that type, and allCompoundUnittypes that include a Unit derived from that type allow you to control whether or not formatting appears in normalized forms of that Unit.ValueTruemeans the formatting characters are included in the normalized string;Falsemeans they are not.use_formatting_in_searchcontrols whether thefor_searchUnit method output includes formatting characters. Default isFalse(settings.DEFAULT_USE_FORMATTING_IN_SEARCH).use_formatting_in_sortcontrols whether thefor_sortUnit method output includes formatting characters. Default isFalse(settings.DEFAULT_USE_FORMATTING_IN_SORT).How to override Unit optionsThere are four ways to override Unit options, listed here in order of precedence.Setting the relevant class attribute for a Unit type will force that type to use that particular value for that option, always. This overrides absolutely everything else.>>>pycallnumber.units.Cutter.sort_case='upper'>>>pycallnumber.units.Cutter('c35').for_sort()u'C!35'Set the option for an individual object by passing the option via a kwarg when you initialize the object. This will override any options defaults (see 4) butnotforced class attributes (see 1).>>>pycallnumber.units.Cutter('c35',sort_case='upper').for_sort()u'C!35'If you're using one of the factory functions, you can pass options in using a dict via theuseroptskwarg. The options get passed to the correct Unit object when it's initialized. This is equivalent to 2.>>>myopts={'sort_case':'upper'}>>>mytypes=[pycallnumber.units.Cutter]>>>pycallnumber.callnumber('c35',...unittypes=mytypes,...useropts=myopts).for_sort()u'C!35'You can set or change the default value for an option on a particular class by setting the relevant option in theoptions_defaultsclass attribute (a dict). This changes the default for that Unit type, which is what's used if nothing else overrides it.Caveat: be careful that you create a copy of theoptions_defaultsdict before making changes to it. Otherwise you will end up changing defaults for other Unit types.>>>pycallnumber.units.Cutter.options_defaults=\...pycallnumber.units.Cutter.options_defaults.copy()>>>pycallnumber.units.Cutter.options_defaults['sort_case']='upper'>>>pycallnumber.units.Cutter('c35').for_sort()u'C!35'>>>pycallnumber.units.Cutter('C35',sort_case='lower').for_sort()u'c!35'Default settings you cannot overrideCurrently there is one default value that you cannot override directly. That issettings.DEFAULT_MAX_NUMERIC_ZFILL, which is10. This means anyunits.simple.Numeric(or derived) class with nomax_lengthset will, by default, fill zeros to 10 digits. If you create a newNumericclass with a validmax_length, then the zero-padding (max_numeric_zfill) will be adjusted for you automatically based on the max length.Top