├── .github └── workflows │ ├── do-checks-and-tests.yml │ └── publish.yml ├── .gitignore ├── LICENSE ├── README.md ├── pyproject.toml ├── pytest.ini ├── setup.cfg ├── setup.py ├── src └── pycallnumber │ ├── __init__.py │ ├── exceptions.py │ ├── factories.py │ ├── options.py │ ├── set.py │ ├── settings.py │ ├── template.py │ ├── unit.py │ ├── units │ ├── __init__.py │ ├── callnumbers │ │ ├── __init__.py │ │ ├── dewey.py │ │ ├── lc.py │ │ ├── local.py │ │ ├── parts.py │ │ └── sudoc.py │ ├── compound.py │ ├── dates │ │ ├── __init__.py │ │ ├── base.py │ │ ├── datestring.py │ │ └── parts.py │ ├── numbers.py │ └── simple.py │ └── utils.py └── tests ├── helpers.py ├── test_factories.py ├── test_options.py ├── test_set.py ├── test_template.py ├── test_unit.py ├── test_units.py └── test_utils.py /.github/workflows/do-checks-and-tests.yml: -------------------------------------------------------------------------------- 1 | name: Run linters and tests 2 | on: [push, workflow_call, workflow_dispatch] 3 | jobs: 4 | 5 | run-linters: 6 | runs-on: ubuntu-20.04 7 | steps: 8 | - uses: actions/checkout@v3 9 | - name: Set up Python 10 | uses: actions/setup-python@v4 11 | with: 12 | python-version: "3.10" 13 | - name: Upgrade pip and install tox 14 | run: | 15 | python -m pip install --upgrade pip 16 | python -m pip install tox 17 | - name: Run linter 18 | run: tox -e flake8 19 | 20 | run-tests: 21 | runs-on: ubuntu-20.04 22 | strategy: 23 | matrix: 24 | include: 25 | - python: '2.7' 26 | tox-env: '27' 27 | - python: '3.5' 28 | tox-env: '35' 29 | - python: '3.6' 30 | tox-env: '36' 31 | - python: '3.7' 32 | tox-env: '37' 33 | - python: '3.8' 34 | tox-env: '38' 35 | - python: '3.9' 36 | tox-env: '39' 37 | - python: '3.10' 38 | tox-env: '310' 39 | - python: '3.11' 40 | tox-env: '311' 41 | steps: 42 | - uses: actions/checkout@v3 43 | - name: Set up Python 44 | uses: actions/setup-python@v4 45 | with: 46 | python-version: ${{ matrix.python }} 47 | - name: Upgrade pip and install tox 48 | run: | 49 | python -m pip install --upgrade pip 50 | python -m pip install tox 51 | - name: Run tests 52 | run: | 53 | tox -e "py${{ matrix.tox-env }}-oldest" 54 | tox -e "py${{ matrix.tox-env }}-latest" 55 | 56 | trigger-publish: 57 | if: ${{ github.ref_type == 'tag' && startsWith(github.ref_name, 'v') }} 58 | needs: [run-linters, run-tests] 59 | uses: ./.github/workflows/publish.yml 60 | secrets: 61 | TEST_PYPI_API_TOKEN: ${{ secrets.TEST_PYPI_API_TOKEN }} 62 | PYPI_API_TOKEN: ${{ secrets.PYPI_API_TOKEN }} 63 | -------------------------------------------------------------------------------- /.github/workflows/publish.yml: -------------------------------------------------------------------------------- 1 | name: Build and publish package 2 | on: 3 | workflow_call: 4 | inputs: 5 | skipTestUpload: 6 | description: 'Skip uploading the package to Test PyPI?' 7 | required: false 8 | default: false 9 | type: boolean 10 | skipLiveUpload: 11 | description: 'Skip uploading the package to Live PyPI?' 12 | required: false 13 | default: false 14 | type: boolean 15 | secrets: 16 | TEST_PYPI_API_TOKEN: 17 | required: true 18 | PYPI_API_TOKEN: 19 | required: true 20 | workflow_dispatch: 21 | inputs: 22 | skipTestUpload: 23 | description: 'Skip uploading the package to Test PyPI?' 24 | required: false 25 | default: false 26 | type: boolean 27 | skipLiveUpload: 28 | description: 'Skip uploading the package to Live PyPI?' 29 | required: false 30 | default: false 31 | type: boolean 32 | 33 | jobs: 34 | build: 35 | runs-on: ubuntu-20.04 36 | steps: 37 | - name: Check out repository 38 | uses: actions/checkout@v3 39 | with: 40 | fetch-depth: 0 41 | - name: Set up Python 42 | uses: actions/setup-python@v4 43 | with: 44 | python-version: "3.10" 45 | - name: Upgrade pip and install tox 46 | run: | 47 | python -m pip install --upgrade pip 48 | python -m pip install tox 49 | - name: Build the package 50 | run: tox -e build_package 51 | - name: Tar the dist directory 52 | run: tar -cvf dist.tar dist 53 | - name: Upload dist.tar 54 | uses: actions/upload-artifact@v3 55 | with: 56 | name: pycallnumber-dist 57 | path: dist.tar 58 | retention-days: 1 59 | 60 | test-built-package: 61 | needs: build 62 | runs-on: ubuntu-20.04 63 | strategy: 64 | matrix: 65 | include: 66 | - python: '2.7' 67 | tox-env: '27' 68 | - python: '3.5' 69 | tox-env: '35' 70 | - python: '3.6' 71 | tox-env: '36' 72 | - python: '3.7' 73 | tox-env: '37' 74 | - python: '3.8' 75 | tox-env: '38' 76 | - python: '3.9' 77 | tox-env: '39' 78 | - python: '3.10' 79 | tox-env: '310' 80 | - python: '3.11' 81 | tox-env: '311' 82 | steps: 83 | - name: Check out repository 84 | uses: actions/checkout@v3 85 | - name: Set up Python 86 | uses: actions/setup-python@v4 87 | with: 88 | python-version: ${{ matrix.python }} 89 | - name: Upgrade pip and install tox 90 | run: | 91 | python -m pip install --upgrade pip 92 | python -m pip install tox 93 | - name: Download dist.tar 94 | uses: actions/download-artifact@v3 95 | with: 96 | name: pycallnumber-dist 97 | - name: Un-tar built package 98 | run: | 99 | tar -xvf dist.tar 100 | ls -Rl 101 | - name: Install built package and run tests 102 | run: tox -e "py${{ matrix.tox-env }}-test_built_package" 103 | 104 | publish: 105 | needs: test-built-package 106 | runs-on: ubuntu-20.04 107 | steps: 108 | - name: Download built package 109 | uses: actions/download-artifact@v3 110 | with: 111 | name: pycallnumber-dist 112 | - name: Un-tar built package 113 | run: | 114 | tar -xvf dist.tar 115 | ls -Rl 116 | - name: Publish package to Test PyPI 117 | if: ${{ !inputs.skipTestUpload }} 118 | uses: pypa/gh-action-pypi-publish@release/v1 119 | with: 120 | password: ${{ secrets.TEST_PYPI_API_TOKEN }} 121 | repository_url: https://test.pypi.org/legacy/ 122 | - name: Publish package to Live PyPI 123 | if: ${{ !inputs.skipLiveUpload && github.ref_type == 'tag' && startsWith(github.ref_name, 'v') && !(contains(github.ref_name, 'dev') || contains(github.ref_name, 'a') || contains(github.ref_name, 'b') || contains(github.ref_name, 'rc')) }} 124 | uses: pypa/gh-action-pypi-publish@release/v1 125 | with: 126 | password: ${{ secrets.PYPI_API_TOKEN }} 127 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.pyc 2 | dist/ 3 | dist.tar 4 | build/ 5 | *.egg-info 6 | *.egg 7 | .cache/ 8 | __pycache__/ 9 | .python-version 10 | .eggs/ 11 | .tox/ 12 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright © 2016, Regents of the University of North Texas 2 | All rights reserved. 3 | 4 | Redistribution and use in source and binary forms, with or without 5 | modification, are permitted provided that the following conditions are 6 | met: 7 | 8 | * Redistributions of source code must retain the above copyright notice, 9 | this list of conditions and the following disclaimer. 10 | 11 | * Redistributions in binary form must reproduce the above copyright 12 | notice, this list of conditions and the following disclaimer in the 13 | documentation and/or other materials provided with the distribution. 14 | 15 | * Neither the name of the University of North Texas Libraries nor the 16 | names of its contributors may be used to endorse or promote products 17 | derived from this software without specific prior written permission. 18 | 19 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS 20 | “AS IS” AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, 21 | BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND 22 | FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE 23 | COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, 24 | INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, 25 | BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS 26 | OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND 27 | ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR 28 | TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE 29 | USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH 30 | DAMAGE. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # pycallnumber 2 | 3 | [![Build Status](https://github.com/unt-libraries/pycallnumber/actions/workflows/do-checks-and-tests.yml/badge.svg?branch=master)](https://github.com/unt-libraries/pycallnumber/actions) 4 | 5 | Use 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. 6 | 7 | * [Installation](#Installation) 8 | * [What can you do with pycallnumber?](#what-can-you-do-with-pycallnumber) 9 | * [Configurable settings](#configurable-settings) 10 | 11 | ## Installation 12 | 13 | ### Requirements 14 | 15 | Tests 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. 16 | 17 | #### Warning: Outdated Python Versions 18 | 19 | ***Warning*** — The next release, v1.0.0, will drop support for Python versions older than 3.7. 20 | 21 | ### Dependencies 22 | 23 | If you're using Python >=3.8, there are no external dependencies beyond the standard library. 24 | 25 | For Python 2.7 to 3.7, the `importlib_metadata` backport is used for `importlib.metadata` functionality (first available in Python 3.8). 26 | 27 | For Python 2.7, the `future` module is used to replicate various Python 3 behaviors. 28 | 29 | ### Setup 30 | 31 | Installing to a [virtualenv](https://docs.python-guide.org/en/latest/dev/virtualenvs/) using pip is recommended. 32 | 33 | ```sh 34 | $ python -m pip install pycallnumber 35 | ``` 36 | 37 | #### Development setup and testing 38 | 39 | If you want to contribute to pycallnumber, you should fork the project and then download and install your fork from GitHub. E.g.: 40 | 41 | ```sh 42 | git clone https://github.com/[your-github-user]/pycallnumber.git pycallnumber 43 | ``` 44 | or (SSH) 45 | ```sh 46 | git clone git@github.com:[your-github-user]/pycallnumber.git pycallnumber 47 | ``` 48 | 49 | Then use pip to do an editable install of the package with the `dev` extras (which installs pytest). 50 | 51 | ```sh 52 | cd pycallnumber 53 | python -m pip install -e .[dev] 54 | ``` 55 | 56 | ##### Running tests 57 | 58 | (The below commands assume you've installed from GitHub as described above and are in the repository root.) 59 | 60 | Invoke [pytest](http://doc.pytest.org/) to run tests in your current Python environment. 61 | ```sh 62 | pytest 63 | ``` 64 | 65 | ##### Tox 66 | 67 | You can use [tox](https://tox.wiki/en/latest/) to run tests against multiple Python versions, provided you have them available on the `PATH`. An excellent tool for this is [pyenv](https://github.com/pyenv/pyenv) with [pyenv-virtualenv](https://github.com/pyenv/pyenv-virtualenv). 68 | 69 | The tox configuration is in `pyproject.toml` (see the `[tool.tox]` section), which defines several test environments. You can run them all at once or target specific environments. 70 | 71 | ```sh 72 | tox # run tests against all configured environments 73 | tox -e py27-oldest # run tests against python 2.7 with oldest deps 74 | tox -e py310-latest # run tests against python 3.10 with latest deps 75 | tox -e flake8 # run flake8 linting 76 | # etc. 77 | ``` 78 | 79 | [Top](#top) 80 | 81 | ## What can you do with pycallnumber? 82 | 83 | #### Parse 84 | 85 | You can parse call number strings, like Library of Congress call numbers ... 86 | 87 | ```pycon 88 | >>> import pycallnumber as pycn 89 | >>> cn = pycn.callnumber('MT 1001 .C35 B40 1992 no. 1') 90 | >>> cn 91 | 92 | >>> cn.classification 93 | 94 | >>> cn.classification.letters 95 | 96 | >>> cn.classification.number 97 | 98 | >>> cn.cutters[0] 99 | 100 | >>> cn.cutters[1] 101 | 102 | >>> cn.edition 103 | 104 | >>> cn.item 105 | 106 | ``` 107 | 108 | ... Dewey Decimal call numbers ... 109 | 110 | ```pycon 111 | >>> cn = pycn.callnumber('500.1 C226t bk.2') 112 | >>> cn 113 | 114 | >>> cn.classification 115 | 116 | >>> cn.cutters[0] 117 | 118 | >>> cn.cutters[0].workmark 119 | 120 | >>> cn.item 121 | 122 | ``` 123 | 124 | ... US SuDocs numbers ... 125 | 126 | ```pycon 127 | >>> cn = pycn.callnumber('HI.F 3/178-8:A 44/2013 ardocs') 128 | >>> cn 129 | 130 | >>> cn.stem 131 | 132 | >>> cn.stem.agency 133 | 134 | >>> cn.stem.series 135 | 136 | >>> cn.stem.series.main_series 137 | 138 | >>> cn.stem.series.related_series 139 | 140 | >>> cn.book_number 141 | 142 | >>> cn.book_number.parts[0] 143 | 144 | >>> cn.book_number.parts[1] 145 | 146 | ``` 147 | 148 | ... and other (i.e. local) call numbers that don't follow the above prescribed patterns. 149 | 150 | ```pycon 151 | >>> cn = pycn.callnumber('LPCD 100,025-A') 152 | >>> cn 153 | 154 | >>> cn.parts[0] 155 | 156 | >>> cn.parts[1] 157 | 158 | >>> cn.parts[2] 159 | 160 | >>> cn.parts[3] 161 | 162 | ``` 163 | 164 | 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. 165 | 166 | ```pycon 167 | >>> pycn.callnumber('mt 1001 c35 1992 no. 1') 168 | 169 | >>> pycn.callnumber('mt 1001 c35 1992 no. 1').classification 170 | 171 | >>> pycn.callnumber('Mt1001 c35 1992 no. 1').classification 172 | 173 | >>> pycn.callnumber('Mt 1001 c35 1992 no. 1').classification 174 | 175 | >>> pycn.callnumber('Mt 1001 c35 1992 no. 1').classification.letters 176 | 177 | >>> pycn.callnumber('Mt 1001 c35 1992 no. 1').classification.number 178 | 179 | >>> pycn.callnumber('mt 1001c35 1992 no. 1').cutters[0] 180 | 181 | >>> pycn.callnumber('mt 1001.c35 1992 no. 1').cutters[0] 182 | 183 | >>> pycn.callnumber('mt 1001 c35 1992 no. 1').cutters[0] 184 | 185 | >>> pycn.callnumber('mt 1001 .c35 1992 no. 1').cutters[0] 186 | 187 | >>> pycn.callnumber('mt 1001 .c 35 1992 no. 1').cutters[0] 188 | 189 | >>> pycn.callnumber('mt 1001 C 35 1992 no. 1').cutters[0] 190 | 191 | ``` 192 | 193 | 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. 194 | 195 | ```pycon 196 | >>> pycn.callnumber('LPCD 100,001') == pycn.callnumber('LPCD 100001') 197 | True 198 | >>> cn = pycn.callnumber('MT 1001 .C35 January 2012') 199 | >>> cn.item 200 | 201 | >>> cn.item.parts[0] 202 | 203 | >>> cn.item.parts[0].year 204 | 205 | >>> cn.item.parts[0].month 206 | 207 | >>> cn.item.parts[0].day 208 | >>> cn = pycn.callnumber('MT 1001 .C35 01-31-2012') 209 | >>> cn.item.parts[0].year 210 | 211 | >>> cn.item.parts[0].month 212 | 213 | >>> cn.item.parts[0].day 214 | 215 | >>> 216 | ``` 217 | 218 | #### Normalize 219 | 220 | Any call number can be normalized for sorting ... 221 | 222 | ```pycon 223 | >>> import pycallnumber as pycn 224 | >>> lc_cn = pycn.callnumber('MT 1001 .C35 B40 1992 no. 1') 225 | >>> dewey_cn = pycn.callnumber('500.1 c226t bk.2') 226 | >>> sudocs_cn = pycn.callnumber('HI.F 3/178-8:A 44/2013 ardocs') 227 | >>> local_cn = pycn.callnumber('LPCD 100,025-A') 228 | >>> lc_cn.for_sort() 229 | u'mt!1001!c!35!b!40!0000001992!!0000000001' 230 | >>> dewey_cn.for_sort() 231 | u'500.1!c!226!t!!0000000002' 232 | >>> sudocs_cn.for_sort() 233 | u'hi.f!3/0000000178-0000000008!!a!0000000044/0000002013!!ardocs' 234 | >>> local_cn.for_sort() 235 | u'lpcd!0000100025!a' 236 | ``` 237 | 238 | ... for left-anchored searching ... 239 | 240 | ```pycon 241 | >>> lc_cn.for_search() 242 | u'mt1001c35b4019921' 243 | >>> dewey_cn.for_search() 244 | u'500.1c226t2' 245 | >>> sudocs_cn.for_search() 246 | u'hif31788a442013ardocs' 247 | >>> local_cn.for_search() 248 | u'lpcd100025a' 249 | ``` 250 | 251 | ... and for display. 252 | 253 | ```pycon 254 | >>> lc_cn.for_print() 255 | u'MT 1001 .C35 B40 1992 no. 1' 256 | >>> dewey_cn.for_print() 257 | u'500.1 c226t bk.2' 258 | >>> sudocs_cn.for_print() 259 | u'HI.F 3/178-8:A 44/2013 ardocs' 260 | >>> local_cn.for_print() 261 | u'LPCD 100,025-A' 262 | ``` 263 | 264 | #### Operate 265 | 266 | You can compare call numbers using comparison operators, and the typical methods for sorting work as you'd expect. Comparison operators use the normalized `for_sort` version 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. 267 | 268 | ```pycon 269 | >>> import pycallnumber as pycn 270 | >>> pycn.callnumber('Mt1001 c35 1992 no. 1') == pycn.callnumber('MT 1001 .C35 1992 #1') 271 | True 272 | >>> cnstrings = ['MT 1001 .C35 B40 1992 no. 1', 273 | ... 'MT 1001 .C35 B40 1992 no. 2', 274 | ... 'MT 1001 .C35 B40 1990', 275 | ... 'M 120 .A20 2002 c.2', 276 | ... 'MT 100 .S23 1985', 277 | ... 'M 120 .A20 2002 copy 1', 278 | ... 'MT 1001 .C35 B100 2013', 279 | ... 'MT 1001 .C35 B40 1991', 280 | ... 'MT 1001 .C35 B40 1992 no. 2 copy 2'] 281 | >>> lccns = [pycn.callnumber(cn) for cn in cnstrings] 282 | >>> lccns[1] > lccns[2] 283 | True 284 | >>> lccns[1] < lccns[2] 285 | False 286 | >>> for cn in sorted(lccns): print cn 287 | ... 288 | M 120 .A20 2002 copy 1 289 | M 120 .A20 2002 c.2 290 | MT 100 .S23 1985 291 | MT 1001 .C35 B100 2013 292 | MT 1001 .C35 B40 1990 293 | MT 1001 .C35 B40 1991 294 | MT 1001 .C35 B40 1992 no. 1 295 | MT 1001 .C35 B40 1992 no. 2 296 | MT 1001 .C35 B40 1992 no. 2 copy 2 297 | ``` 298 | 299 | You can also work with ***sets*** of call numbers using the same operators you'd use for [built-in Python sets](https://docs.python.org/2/library/stdtypes.html#set). 300 | 301 | E.g., given the following ranges: 302 | 303 | ```pycon 304 | >>> MT0_MT500 = pycn.cnrange('MT 0', 'MT 500') 305 | >>> MT500_MT1000 = pycn.cnrange('MT 500', 'MT 1000') 306 | >>> MT300_MT800 = pycn.cnrange('MT 300', 'MT 800') 307 | >>> MT0_N0 = pycn.cnrange('MT 0', 'N 0') 308 | >>> MT2000_N0 = pycn.cnrange('MT 2000', 'N 0') 309 | >>> for rg in (MT0_MT500, MT500_MT1000, MT300_MT800, MT0_N0, MT2000_N0): print rg 310 | ... 311 | 312 | 313 | 314 | 315 | 316 | ``` 317 | 318 | You can test whether a call number is in a particular range or set. 319 | 320 | ```pycon 321 | >>> pycn.callnumber('MT 500 .A0 1900').classification in MT0_MT500 322 | False 323 | >>> pycn.callnumber('MT 500 .A0 1900').classification in MT500_MT1000 324 | True 325 | >>> pycn.callnumber('MS 9999.9999 .Z99 9999').classification in MT0_MT500 326 | False 327 | ``` 328 | 329 | Test how sets relate to one another. 330 | 331 | ```pycon 332 | >>> MT0_MT500 in MT500_MT1000 333 | False 334 | >>> MT0_MT500.issubset(MT500_MT1000) 335 | False 336 | >>> MT0_MT500 > MT500_MT1000 337 | False 338 | >>> MT0_MT500 < MT500_MT1000 339 | False 340 | >>> MT0_MT500.issuperset(MT500_MT1000) 341 | False 342 | >>> MT0_MT500.overlaps(MT500_MT1000) 343 | False 344 | >>> MT0_MT500.isdisjoint(MT500_MT1000) 345 | True 346 | >>> MT0_MT500.issequential(MT500_MT1000) 347 | True 348 | >>> MT0_MT500.isbefore(MT500_MT1000) 349 | True 350 | >>> MT0_MT500.extendslower(MT500_MT1000) 351 | True 352 | >>> MT0_MT500.overlaps(MT300_MT800) 353 | True 354 | >>> MT0_MT500.isdisjoint(MT300_MT800) 355 | False 356 | >>> MT0_MT500.isbefore(MT300_MT800) 357 | False 358 | >>> MT0_MT500.isafter(MT300_MT800) 359 | False 360 | >>> MT300_MT800.extendshigher(MT0_MT500) 361 | True 362 | >>> MT0_MT500.extendslower(MT300_MT800) 363 | True 364 | >>> MT0_MT500 in MT300_MT800 365 | False 366 | >>> MT300_MT800 in MT0_MT500 367 | False 368 | >>> MT0_MT500 in MT0_N0 369 | True 370 | >>> MT0_MT500.issubset(MT0_N0) 371 | True 372 | >>> MT0_MT500 < MT0_N0 373 | True 374 | ``` 375 | 376 | Join two or more sets. 377 | 378 | ```pycon 379 | >>> MT0_MT500 | MT300_MT800 380 | 381 | >>> MT0_MT500 | MT2000_N0 382 | 383 | >>> MT0_MT500 | MT2000_N0 | MT500_MT1000 384 | 385 | >>> MT0_MT500.union(MT500_MT1000, MT2000_N0, MT0_N0) 386 | 387 | ``` 388 | 389 | Intersect two or more sets. 390 | 391 | ```pycon 392 | >>> MT0_MT500 & MT300_MT800 393 | 394 | >>> MT0_MT500 & MT500_MT1000 395 | 396 | >>> MT300_MT800 & MT500_MT1000 & MT0_N0 397 | 398 | >>> MT300_MT800.intersection(MT500_MT1000, MT0_N0) 399 | 400 | ``` 401 | 402 | Get the difference of two or more sets. 403 | 404 | ```pycon 405 | >>> MT0_N0 - MT0_MT500 406 | 407 | >>> MT0_N0 - MT2000_N0 408 | 409 | >>> MT0_N0 - MT2000_N0 - MT300_MT800 410 | 411 | >>> MT0_N0.difference(MT2000_N0, MT300_MT800) 412 | 413 | ``` 414 | 415 | Get the symmetric difference of two sets—i.e., the set of things in one or the other but not both. 416 | 417 | ```pycon 418 | >>> MT300_MT800 ^ MT0_N0 419 | 420 | >>> MT0_MT500 ^ MT2000_N0 421 | 422 | ``` 423 | 424 | 425 | #### Extend 426 | 427 | You can subclass any of the call number `Unit` classes in your own projects if you need to customize their behavior. 428 | 429 | For example, if you want your LC call numbers to be normalized a particular way for display, you can override the `for_print` method: 430 | 431 | ```python 432 | import pycallnumber as pycn 433 | 434 | class MyLC(pycn.units.LC): 435 | def for_print(self): 436 | lcclass = '{}{}'.format(str(self.classification.letters).upper(), 437 | self.classification.number) 438 | cutters = ['{}{}'.format(str(c.letters.upper()), c.number) 439 | for c in self.cutters] 440 | output = '{} .{}'.format(lcclass, ' '.join(cutters)) 441 | if self.edition is not None: 442 | output = '{} {}'.format(output, self.edition) 443 | if self.item is not None: 444 | output = '{} {}'.format(output, self.item) 445 | return output 446 | ``` 447 | ```pycon 448 | >>> MyLC('MT 100 .C35 1992').for_print() 449 | 'MT100 .C35 1992' 450 | >>> MyLC('MT 100 c35 1992').for_print() 451 | 'MT100 .C35 1992' 452 | >>> MyLC('mt 100 c35 1992 v. 1').for_print() 453 | 'MT100 .C35 1992 v. 1' 454 | >>> MyLC('mt 100 c35 e20 1992 v. 1').for_print() 455 | 'MT100 .C35 E20 1992 v. 1' 456 | ``` 457 | 458 | `Unit` classes also have a `derive` class 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: 459 | 460 | ```python 461 | import pycallnumber as pycn 462 | 463 | DollarSign = pycn.units.Formatting.derive( 464 | classname='DollarSign', base_pattern=r'\$', min_length=1, max_length=1 465 | ) 466 | DollarAmount = pycn.units.Number.derive( 467 | classname='DollarAmount', min_decimal_places=0, max_decimal_places=2 468 | ) 469 | UsDollars = pycn.units.NumericSymbol.derive( 470 | classname='UsDollars', separator_type=None, 471 | groups=[{'name': 'dollarsign', 'min': 1, 'max': 1, 'type': DollarSign}, 472 | {'name': 'amount', 'min': 1, 'max': 1, 'type': DollarAmount}] 473 | ) 474 | ``` 475 | ```pycon 476 | >>> UsDollars('$23') 477 | 478 | >>> UsDollars('$23.00') 479 | 480 | >>> UsDollars('$23.03') 481 | 482 | >>> UsDollars('$23.030') 483 | Traceback (most recent call last): 484 | File "", line 1, in 485 | File "pycallnumber/unit.py", line 143, in __init__ 486 | super(CompoundUnit, self).__init__(cnstr, name, **options) 487 | File "pycallnumber/unit.py", line 28, in __init__ 488 | self._validate_result = type(self).validate(cnstr, self.options) 489 | File "pycallnumber/unit.py", line 74, in validate 490 | raise InvalidCallNumberStringError(msg) 491 | pycallnumber.exceptions.InvalidCallNumberStringError: '$23.030' is not a valid UsDollars Unit. It should be a string with 1 ``dollarsign`` grouping and 1 ``amount`` grouping. 492 | 493 | **** Here is what was found while attempting to parse '$23.030' **** 494 | 495 | '$' matched the dollarsign grouping. 496 | '23.03' matched the ``amount`` grouping. 497 | '0' does not match any grouping. 498 | >>> 499 | >>> UsDollars('23.00') 500 | Traceback (most recent call last): 501 | File "", line 1, in 502 | File "pycallnumber/unit.py", line 143, in __init__ 503 | super(CompoundUnit, self).__init__(cnstr, name, **options) 504 | File "pycallnumber/unit.py", line 28, in __init__ 505 | self._validate_result = type(self).validate(cnstr, self.options) 506 | File "pycallnumber/unit.py", line 74, in validate 507 | raise InvalidCallNumberStringError(msg) 508 | pycallnumber.exceptions.InvalidCallNumberStringError: '23.00' is not a valid UsDollars Unit. It should be a string with 1 ``dollarsign`` grouping and 1 ``amount`` grouping. 509 | 510 | **** Here is what was found while attempting to parse '23.00' **** 511 | 512 | '23.00' does not match any grouping. 513 | ``` 514 | 515 | [Top](#top) 516 | 517 | ## Configurable settings 518 | 519 | Pycallnumber uses a package-wide `settings.py` file to store various default configuration settings. With one exception, the defaults should suffice for most uses. But, since you _can_ override certain settings, and the options aren't immediately obvious, I've documented them here. 520 | 521 | ### Overriding the list of Unit types that the factory functions detect 522 | 523 | By 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 in `pycallnumber.settings.DEFAULT_UNIT_TYPES`.) 524 | 525 | 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 the `unittypes` kwarg. Example: 526 | 527 | ```python 528 | import pycallnumber 529 | 530 | class MyDewey(pycallnumber.units.Dewey): 531 | # Defines local Dewey Unit type 532 | # ... 533 | 534 | my_unit_types = [ 535 | MyDewey, 536 | pycallnumber.units.LC, 537 | pycallnumber.units.SuDoc, 538 | pycallnumber.units.Local 539 | ] 540 | call = pycallnumber.callnumber( 541 | 'M 801.951 L544p', 542 | unittypes=my_unit_types) 543 | # ... rest of the script 544 | ``` 545 | 546 | Two important things to note. 547 | 548 | 1. **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, the `Local` type 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. 549 | 550 | 2. **Your `unittypes` list should be a list of classes, not a list of class path strings.** The `settings.DEFAULT_UNIT_TYPES` is a list of class path strings, but this was done to get around having circular imports in the `settings` module. 551 | 552 | ### Overriding certain Unit options 553 | 554 | Each 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 an `options_defaults` class attribute, and the default defaults are in `settings.py`. These values should work for 99% of uses, but you can override them if you need to. 555 | 556 | #### Alphabetic case options 557 | 558 | `units.simple.Alphabetic`, all Unit types derived from that type, and all `CompoundUnit` types that include a Unit derived from that type allow you to control how alphabetic case is normalized. 559 | 560 | Value `'lower'` normalizes alphabetic characters to lowercase; `'upper'` normalizes to uppercase. Anything else keeps the original case. 561 | 562 | * `display_case` controls what case the `for_print` Unit method outputs. Default is a blank string, to keep the original case (`settings.DEFAULT_DISPLAY_CASE`). 563 | 564 | * `search_case` controls what case the `for_search` Unit method outputs. Default is `'lower'` (`settings.DEFAULT_SEARCH_CASE`). 565 | 566 | * `sort_case` controls what case the `for_sort` Unit method outputs. Default is `'lower'` (`settings.DEFAULT_SORT_CASE`). 567 | 568 | #### Formatting 'use in' options 569 | 570 | `units.simple.Formatting`, all Unit types derived from that type, and all `CompoundUnit` types that include a Unit derived from that type allow you to control whether or not formatting appears in normalized forms of that Unit. 571 | 572 | Value `True` means the formatting characters are included in the normalized string; `False` means they are not. 573 | 574 | * `use_formatting_in_search` controls whether the `for_search` Unit method output includes formatting characters. Default is `False` (`settings.DEFAULT_USE_FORMATTING_IN_SEARCH`). 575 | * `use_formatting_in_sort` controls whether the `for_sort` Unit method output includes formatting characters. Default is `False` (`settings.DEFAULT_USE_FORMATTING_IN_SORT`). 576 | 577 | #### How to override Unit options 578 | 579 | There are four ways to override Unit options, listed here in order of precedence. 580 | 581 | 1. 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. 582 | 583 | ```pycon 584 | >>> pycallnumber.units.Cutter.sort_case = 'upper' 585 | >>> pycallnumber.units.Cutter('c35').for_sort() 586 | u'C!35' 587 | ``` 588 | 589 | 2. 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) but **not** forced class attributes (see 1). 590 | 591 | ```pycon 592 | >>> pycallnumber.units.Cutter('c35', sort_case='upper').for_sort() 593 | u'C!35' 594 | ``` 595 | 596 | 3. If you're using one of the factory functions, you can pass options in using a dict via the `useropts` kwarg. The options get passed to the correct Unit object when it's initialized. This is equivalent to 2. 597 | 598 | ```pycon 599 | >>> myopts = {'sort_case': 'upper'} 600 | >>> mytypes = [pycallnumber.units.Cutter] 601 | >>> pycallnumber.callnumber('c35', 602 | ... unittypes=mytypes, 603 | ... useropts=myopts).for_sort() 604 | u'C!35' 605 | ``` 606 | 607 | 4. You can set or change the default value for an option on a particular class by setting the relevant option in the `options_defaults` class 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 the `options_defaults` dict before making changes to it. Otherwise you will end up changing defaults for other Unit types. 608 | 609 | ```pycon 610 | >>> pycallnumber.units.Cutter.options_defaults =\ 611 | ... pycallnumber.units.Cutter.options_defaults.copy() 612 | >>> pycallnumber.units.Cutter.options_defaults['sort_case'] = 'upper' 613 | >>> pycallnumber.units.Cutter('c35').for_sort() 614 | u'C!35' 615 | >>> pycallnumber.units.Cutter('C35', sort_case='lower').for_sort() 616 | u'c!35' 617 | ``` 618 | 619 | #### Default settings you cannot override 620 | 621 | Currently there is one default value that you cannot override directly. That is `settings.DEFAULT_MAX_NUMERIC_ZFILL`, which is `10`. This means any `units.simple.Numeric` (or derived) class with no `max_length` set will, by default, fill zeros to 10 digits. If you create a new `Numeric` class with a valid `max_length`, then the zero-padding (`max_numeric_zfill`) will be adjusted for you automatically based on the max length. 622 | 623 | [Top](#top) 624 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = ['setuptools>=44.0.0', 'wheel', 'setuptools_scm[toml]>=5.0.2'] 3 | build-backend = "setuptools.build_meta" 4 | 5 | [tool.setuptools_scm] 6 | local_scheme = "no-local-version" 7 | 8 | [tool.tox] 9 | legacy_tox_ini = """ 10 | [tox] 11 | envlist = flake8,py{27,35,36,37,38,39,310,311}-{oldest,latest} 12 | isolated_build = True 13 | 14 | [testenv] 15 | extras = 16 | dev 17 | commands = 18 | pytest 19 | 20 | [testenv:py27-oldest] 21 | deps = 22 | pytest==3.5.0 23 | importlib_metadata==2.0.0 24 | 25 | [testenv:py35-oldest] 26 | deps = 27 | pytest==3.5.0 28 | importlib_metadata==2.0.0 29 | 30 | [testenv:py36-oldest] 31 | deps = 32 | pytest==3.5.0 33 | importlib_metadata==2.0.0 34 | 35 | [testenv:py37-oldest] 36 | deps = 37 | pytest==3.5.0 38 | importlib_metadata==2.0.0 39 | 40 | [testenv:py{38,39}-oldest] 41 | deps = 42 | pytest==3.5.0 43 | 44 | [testenv:py{310,311}-oldest] 45 | deps = 46 | pytest==6.2.4 47 | 48 | [testenv:flake8] 49 | basepython = python3.10 50 | skip_install = True 51 | deps = 52 | flake8 53 | commands = 54 | flake8 src/pycallnumber tests --exclude=__pycache__ 55 | 56 | [testenv:build_package] 57 | basepython = python3.10 58 | skip_install = true 59 | deps = 60 | build 61 | twine 62 | allowlist_externals = 63 | bash 64 | commands = 65 | bash -c 'rm -rf dist' 66 | python -m build 67 | bash -c 'python -m twine check dist/*.whl' 68 | bash -c 'python -m twine check dist/*.gz' 69 | 70 | [testenv:py{27,35,36,37,38,39,310,311}-test_built_package] 71 | skip_install = true 72 | deps = 73 | pytest 74 | allowlist_externals = 75 | bash 76 | commands = 77 | bash -c 'python -m pip install {posargs:dist/*.whl}' 78 | pytest 79 | """ 80 | -------------------------------------------------------------------------------- /pytest.ini: -------------------------------------------------------------------------------- 1 | [pytest] 2 | markers = 3 | comparison: For running only ``comparison`` tests. 4 | creation: For running only ``creation`` tests. 5 | for_sort: For running only ``for_sort`` tests on units. 6 | valid: For running only ``valid`` tests on units. 7 | invalid: For running only ``invalid`` tests on units. 8 | valid_parse: For running only ``valid_parse`` tests on units. 9 | parts: For running only ``parts`` tests on units. 10 | attribute: For running only ``attribute`` tests on units. 11 | sort: For running only ``sort`` tests on units. 12 | sort_equivalence: For running only ``sort_equivalence`` tests on units. 13 | search: For running only ``search`` tests on units. 14 | display: For running only ``display`` tests on units. 15 | simple: For testing all Unit types in the units.simple module. 16 | Alphabetic: For testing the ``Alphabetic`` Unit type. 17 | Numeric: For testing the ``Numeric`` Unit type. 18 | Formatting: For testing the ``Formatting`` Unit type. 19 | compound: For testing all Unit types in the units.compound module. 20 | AlphaNumeric: For testing all ``AlphaNumeric`` Unit types. 21 | AlphaSymbol: For testing all ``AlphaSymbol`` Unit types. 22 | NumericSymbol: For testing all ``NumericSymbol`` Unit types. 23 | AlphaNumericSymbol: For testing all ``AlphaNumericSymbol`` Unit types. 24 | numbers: For testing all Unit types in the units.numbers module. 25 | Number: For testing the ``Number`` Unit type. 26 | OrdinalNumber: For testing the ``OrdinalNumber`` Unit type. 27 | dates: For testing all Unit types in the units.dates package. 28 | Year: For testing the ``Year`` Unit type. 29 | Month: For testing the ``Month`` Unit type. 30 | Day: For testing the ``Day`` Unit type. 31 | DateString: For testing the ``DateString`` Unit type. 32 | callnumbers: For testing all Unit types in the units.callnumbers package. 33 | Cutter: For testing the ``Cutter`` Unit type. 34 | Dewey: For testing the ``Dewey`` Unit type. 35 | Edition: For testing the ``Edition`` Unit type. 36 | Item: For testing the ``Item`` Unit type. 37 | LC: For testing the ``LC`` Unit type. 38 | Local: For testing the ``Local`` Unit type. 39 | SuDoc: For testing the ``SuDoc`` Unit type. 40 | range: For running tests on the Range type. 41 | rangeset: For running tests on the RangeSet type. 42 | two: For running Range and Set tests on two-arg operators. 43 | multiple: For running Range and Set tests on multi-arg operators. 44 | callnumber_factory: For testing the ``callnumber`` factory. 45 | cnrange_factory: For testing the ``cnrange`` factory. 46 | cnset_factory: For testing the ``cnset`` factory. 47 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [metadata] 2 | name=pycallnumber 3 | version=attr: pycallnumber.__version__ 4 | description=A Python library for parsing call numbers. 5 | long_description=file: README.md 6 | long_description_content_type=text/markdown 7 | author=Jason Thomale 8 | author_email=jason.thomale@unt.edu 9 | maintainer=University of North Texas Libraries 10 | keywords=python, callnumber, callnumbers, call number, call numbers 11 | license=BSD 12 | classifiers= 13 | Development Status :: 5 - Production/Stable 14 | Intended Audience :: Education 15 | Intended Audience :: Developers 16 | Natural Language :: English 17 | License :: OSI Approved :: BSD License 18 | Programming Language :: Python 19 | Programming Language :: Python :: 2 20 | Programming Language :: Python :: 2.7 21 | Programming Language :: Python :: 3 22 | Programming Language :: Python :: 3.5 23 | Programming Language :: Python :: 3.6 24 | Programming Language :: Python :: 3.7 25 | Programming Language :: Python :: 3.8 26 | Programming Language :: Python :: 3.9 27 | Programming Language :: Python :: 3.10 28 | Programming Language :: Python :: 3.11 29 | Topic :: Software Development 30 | project_urls= 31 | homepage=https://github.com/unt-libraries/pycallnumber 32 | 33 | [options] 34 | package_dir= 35 | =src 36 | packages=find: 37 | install_requires = 38 | future; python_version=="2.7" 39 | importlib_metadata>=2.0.0; python_version<="3.7" 40 | 41 | [options.packages.find] 42 | where=src 43 | 44 | [options.extras_require] 45 | dev = 46 | pytest>=6.2.4; python_version>="3.10" 47 | pytest>=3.5.0; python_version<"3.10" 48 | 49 | [bdist_wheel] 50 | universal = 1 51 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | from setuptools import setup 2 | 3 | setup() 4 | -------------------------------------------------------------------------------- /src/pycallnumber/__init__.py: -------------------------------------------------------------------------------- 1 | """The pycallnumber top-level package. 2 | 3 | This package allows you to work with call numbers (Library of Congress, 4 | Dewey Decimal, US SuDocs, and others)--parse them, normalize them, sort 5 | them. 6 | """ 7 | 8 | from __future__ import absolute_import 9 | 10 | try: 11 | from importlib import metadata 12 | except ImportError: 13 | import importlib_metadata as metadata 14 | 15 | from pycallnumber import settings 16 | from pycallnumber.exceptions import CallNumberError, CallNumberWarning,\ 17 | InvalidCallNumberStringError,\ 18 | SettingsError, MethodError, OptionsError,\ 19 | UtilsError, RangeSetError, BadRange 20 | from pycallnumber.options import Options, ObjectWithOptions 21 | from pycallnumber.template import Template, SimpleTemplate, CompoundTemplate,\ 22 | Grouping 23 | from pycallnumber.unit import Unit, SimpleUnit, CompoundUnit 24 | from pycallnumber.set import RangeSet 25 | from pycallnumber import units 26 | from pycallnumber import utils 27 | from pycallnumber.factories import callnumber, cnrange, cnset 28 | 29 | _md = metadata.metadata('pycallnumber') 30 | __version__ = metadata.version('pycallnumber') 31 | __name__ = 'pycallnumber' 32 | __url__ = _md['Home-page'] 33 | __description__ = _md['Summary'] 34 | __license__ = _md['License'] 35 | __author__ = _md['Author'] 36 | __author_email__ = _md['Author-email'] 37 | __maintainer__ = _md['Maintainer'] 38 | __keywords__ = _md['Keywords'] 39 | __all__ = ['settings', 'CallNumberError', 'CallNumberWarning', 40 | 'InvalidCallNumberStringError', 'SettingsError', 'MethodError', 41 | 'OptionsError', 'UtilsError', 'RangeSetError', 'BadRange', 42 | 'Options', 'ObjectWithOptions', 'Template', 'SimpleTemplate', 43 | 'CompoundTemplate', 'Grouping', 'Unit', 'SimpleUnit', 44 | 'CompoundUnit', 'RangeSet', 'units', 45 | 'utils', 'callnumber', 'cnrange', 'cnset'] 46 | -------------------------------------------------------------------------------- /src/pycallnumber/exceptions.py: -------------------------------------------------------------------------------- 1 | """Exceptions for pycallnumber package.""" 2 | 3 | 4 | from __future__ import unicode_literals 5 | 6 | 7 | class CallNumberError(Exception): 8 | """Base pycallnumber Exception.""" 9 | pass 10 | 11 | 12 | class CallNumberWarning(Warning): 13 | """Base CallNumber Warning.""" 14 | pass 15 | 16 | 17 | class InvalidCallNumberStringError(CallNumberError): 18 | """Call number string format does not conform to expectations.""" 19 | pass 20 | 21 | 22 | class SettingsError(CallNumberError): 23 | """General problem with settings passed to a Template or Unit.""" 24 | pass 25 | 26 | 27 | class MethodError(CallNumberError): 28 | """General problem with use of a pycallnumber method.""" 29 | pass 30 | 31 | 32 | class UtilsError(CallNumberError): 33 | """Problem with a Utility function.""" 34 | pass 35 | 36 | 37 | class OptionsError(SettingsError): 38 | """Problem with an Options or related object.""" 39 | pass 40 | 41 | 42 | class RangeSetError(CallNumberError): 43 | """General problem with a Set or Range object or method.""" 44 | pass 45 | 46 | 47 | class BadRange(RangeSetError): 48 | """A range is invalid.""" 49 | pass 50 | -------------------------------------------------------------------------------- /src/pycallnumber/factories.py: -------------------------------------------------------------------------------- 1 | """Use factories to generate call number units and ranges.""" 2 | from __future__ import absolute_import 3 | 4 | from pycallnumber import settings 5 | from pycallnumber.utils import create_unit, load_class 6 | from pycallnumber.exceptions import InvalidCallNumberStringError, SettingsError 7 | 8 | 9 | def callnumber(cnstr, name='', useropts=None, unittypes=None): 10 | """Create a Unit object from a callnumber string. 11 | 12 | This function generates a Unit object that best matches the 13 | provided call number string (``cnstr``), based on a list of Unit 14 | types. 15 | 16 | Use ``name`` to specify the name of the resulting Unit object. 17 | Defaults to an empty string. It generally isn't important that this 18 | is specified, unless your code makes heavy use of the Unit.name. 19 | 20 | Use ``useropts`` to pass sets of Unit-specific options to the 21 | resulting Unit object (as a dict). 22 | 23 | Use ``unittypes`` to specify the list of valid Unit types to use 24 | to generate Unit objects. The first Unit type found that matches 25 | the given call number string is returned, so order matters. 26 | Defaults are found in settings.DEFAULT_UNIT_TYPES. Pass your own 27 | list to override the default. 28 | """ 29 | useropts = useropts or {} 30 | utypes = unittypes or [load_class(t) for t in settings.DEFAULT_UNIT_TYPES] 31 | cn_unit = create_unit(cnstr, utypes, useropts, name) 32 | if cn_unit is None: 33 | types_str = ', '.join(['{}'.format(ut.__name__) for ut in utypes]) 34 | msg = ('The provided call number string \'{}\' did not match any ' 35 | 'of the following known call number types: {}' 36 | ''.format(cnstr, types_str)) 37 | raise InvalidCallNumberStringError(msg) 38 | return cn_unit 39 | 40 | 41 | def cnrange(start, end, startname='', endname='', useropts=None, 42 | unittypes=None, rangesettype=None): 43 | """Create a contiguous RangeSet-type object. 44 | 45 | This function generates a RangeSet (or subclass) object that 46 | represents a single contiguous call number range, using a ``start`` 47 | and an ``end`` value. These values may be call number strings or 48 | they may be Unit objects. If they are strings, they are first 49 | passed to the ``callnumber`` factory to generate Unit objects. They 50 | must both be or generate the same Unit type, and start must be less 51 | than end. 52 | 53 | Note that range start value is always considered inside the range, 54 | (inclusive) and the range end value is always considered outside 55 | the range (exclusive). This matches the behavior of the built-in 56 | ``range`` function. For example, if you want to model the LC class 57 | range that might commonly be written as 'MS 0000' to 58 | 'MS 9999.9999', your range should be 'MS 0' to 'MT 0'. 59 | 60 | The kwargs ``startname,`` ``endname,`` ``useropts,`` and 61 | ``unittypes`` are used to generate Unit objects if start and/or end 62 | are strings. (See ``callnumber`` for information about these args.) 63 | They are ignored if start and end are both Units. 64 | 65 | Use the ``rangesettype`` kwarg to specify what type of object you 66 | want to generate. The default is settings.DEFAULT_RANGESET_TYPE, 67 | which defaults to RangeSet. If you provide your own class, it must 68 | be a subclass of RangeSet. 69 | """ 70 | useropts = useropts or {} 71 | utypes = unittypes or [load_class(t) for t in settings.DEFAULT_UNIT_TYPES] 72 | rangesettype = rangesettype or load_class(settings.DEFAULT_RANGESET_TYPE) 73 | try: 74 | start = callnumber(start, startname, useropts, utypes) 75 | except TypeError: 76 | pass 77 | try: 78 | end = callnumber(end, endname, useropts, utypes) 79 | except TypeError: 80 | pass 81 | return rangesettype((start, end)) 82 | 83 | 84 | def cnset(ranges, names=None, useropts=None, unittypes=None, 85 | rangesettype=None): 86 | """Create a single RangeSet-type object from multiple ranges. 87 | 88 | This function generates a RangeSet (or subclass) object that 89 | represents a set of call numbers, not necessarily a contiguous 90 | range, comprising multiple ranges. Use this if, for example, you 91 | need to model a call number category that includes multiple 92 | ranges. 93 | 94 | As with ``cnrange``, ranges within the set have an inclusive start 95 | value and an exclusive end value. 96 | 97 | The ``ranges`` argument is a list or tuple, and it must contain 98 | either of the following: 99 | 100 | * RangeSet (or subclass) objects. 101 | * Lists or tuples, each of which has two items--the start and end 102 | value of a range. These values are passed to ``cn_range`` to 103 | generate RangeSet (or subclass) objects, so they may be Unit 104 | objects or call number strings. 105 | 106 | The other kwargs: ``names,`` ``useropts,`` ``unittypes,`` and 107 | ``rangesettype`` are as the kwargs used for ``callnumber`` and 108 | ``cnrange.`` 109 | 110 | Note about ``names``: if you use call number strings in ``ranges,`` 111 | and you want the resulting Units to have names, then the ``names`` 112 | kwarg should be a list or tuple of lists or tuples that matches 113 | up with ranges. E.g.: 114 | 115 | ranges = [('AB 100', 'AB 150'), ('CA 0', 'CA 1300')] 116 | names = [('R1 Start', 'R1 End'), ('R2 Start', 'R2 End')] 117 | 118 | It will raise a SettingsError if the ``names`` kwarg is provided 119 | but doesn't parse correctly--e.g., if an IndexError is raised and 120 | caught while trying to parse it. 121 | 122 | """ 123 | useropts = useropts or {} 124 | utypes = unittypes or [load_class(t) for t in settings.DEFAULT_UNIT_TYPES] 125 | rangesettype = rangesettype or load_class(settings.DEFAULT_RANGESET_TYPE) 126 | rangeset = rangesettype() 127 | for i, r in enumerate(ranges): 128 | try: 129 | rangeset |= r 130 | except TypeError: 131 | try: 132 | startname, endname = names[i][0], names[i][1] 133 | except IndexError: 134 | msg = ('The ``names`` kwarg should be a list or tuple of ' 135 | 'lists or tuples that matches up with the provided ' 136 | 'ranges so that each endpoint in the resulting ' 137 | 'rangeset has a name specified. E.g., if ranges is ' 138 | '[(\'AB 100\', \'AB 150\'), (\'CA 0\', \'CA 1300\')] ' 139 | 'then names might be [(\'R1 Start\', \'R1 End\'), ' 140 | '(\'R2 Start\', \'R2 End\')].') 141 | raise SettingsError(msg) 142 | except TypeError: 143 | startname, endname = '', '' 144 | rangeset |= cnrange(r[0], r[1], startname, endname, useropts, 145 | utypes, rangesettype) 146 | return rangeset 147 | -------------------------------------------------------------------------------- /src/pycallnumber/options.py: -------------------------------------------------------------------------------- 1 | """Implement objects with intelligently overridable options.""" 2 | 3 | 4 | from __future__ import unicode_literals 5 | from __future__ import absolute_import 6 | from builtins import object 7 | from pycallnumber.exceptions import OptionsError 8 | 9 | 10 | class Options(dict): 11 | 12 | def __init__(self, parent_class, useropts=None, override_class_opts=False, 13 | **argopts): 14 | useropts = useropts or argopts 15 | self.reset_options(parent_class, useropts, override_class_opts) 16 | 17 | def reset_options(self, parent_class, useropts=None, 18 | override_class_opts=False): 19 | useropts = useropts or {} 20 | self.parent_classname = parent_class.__name__ 21 | self.classopts = parent_class.get_classopts() 22 | self.defopts = parent_class.options_defaults 23 | self.sources = {} 24 | self.validate_options(useropts) 25 | for option, default in self.defopts.items(): 26 | if option in useropts: 27 | value = useropts[option] 28 | is_from_defaults = False 29 | else: 30 | value = default 31 | is_from_defaults = True 32 | self.set_option(option, value, is_from_defaults, 33 | override_class_opts) 34 | 35 | def validate_option(self, option): 36 | if option not in self.defopts: 37 | msg = ('``{}`` is not a valid option for class {}.' 38 | ''.format(option, self.parent_classname)) 39 | raise OptionsError(msg) 40 | 41 | def validate_options(self, useropts): 42 | for option in list(useropts.keys()): 43 | self.validate_option(option) 44 | 45 | def set_option(self, option, value, is_from_defaults=False, 46 | override_class_opts=False): 47 | self.validate_option(option) 48 | if not override_class_opts and option in self.classopts: 49 | self[option] = self.classopts[option] 50 | self.sources[option] = 'class' 51 | else: 52 | self[option] = value 53 | if is_from_defaults: 54 | self.sources[option] = 'defaults' 55 | else: 56 | self.sources[option] = 'argument' 57 | 58 | 59 | class ObjectWithOptions(object): 60 | 61 | options_defaults = {} 62 | 63 | def __init__(self, override_class_opts=False, **useropts): 64 | self.reset_options(useropts, override_class_opts=override_class_opts) 65 | 66 | @classmethod 67 | def get_classopts(cls): 68 | return {opt: getattr(cls, opt) for opt in cls.options_defaults 69 | if hasattr(cls, opt)} 70 | 71 | @classmethod 72 | def filter_valid_useropts(cls, useropts): 73 | """Filter an opts dict to opts only in cls.options_defaults. 74 | 75 | Pass a dictionary of user-supplied options (``useropts``) to 76 | this method, and get back a dictionary containing only the 77 | key/value pairs where a key is present in options_defaults. 78 | """ 79 | outopts = {} 80 | for opt in list(cls.options_defaults.keys()): 81 | if opt in useropts: 82 | outopts[opt] = useropts[opt] 83 | return outopts 84 | 85 | def copy_option_values_to_other(self, other, protect_opts_set_by=None): 86 | """Copy option values to another ObjectWithOptions object. 87 | 88 | Pass in another ObjectWithOptions object (``other``), and this 89 | method will copy option values from this object to the other, 90 | *only* for keys that exist in both objects' options dict. 91 | 92 | Use ``protect_opts_set_by`` to protect options on the other 93 | object from being overwritten based on where they were last set 94 | from--pass in a list containing 'defaults', 'class', or 95 | 'argument'. 96 | """ 97 | protect_opts_set_by = protect_opts_set_by or [] 98 | for option, value in other.options.items(): 99 | opt_src = other.get_option_source(option) 100 | if opt_src not in protect_opts_set_by: 101 | other.set_option(option, self.options.get(option, value)) 102 | 103 | def reset_options(self, useropts=None, override_class_opts=False): 104 | self.options = Options(type(self), useropts, override_class_opts) 105 | self.apply_options_to_self() 106 | 107 | def get_option_source(self, option): 108 | return self.options.sources[option] 109 | 110 | def set_option(self, option, value, override_class_opts=False): 111 | self.options.set_option(option, value, 112 | override_class_opts=override_class_opts) 113 | setattr(self, option, self.options[option]) 114 | 115 | def apply_options_to_self(self): 116 | for option, value in self.options.items(): 117 | setattr(self, option, value) 118 | -------------------------------------------------------------------------------- /src/pycallnumber/set.py: -------------------------------------------------------------------------------- 1 | """Model and work with call number ranges and sets.""" 2 | 3 | 4 | from __future__ import unicode_literals 5 | from __future__ import absolute_import 6 | from builtins import object 7 | import operator 8 | import copy 9 | from functools import reduce 10 | 11 | from pycallnumber.exceptions import RangeSetError, BadRange 12 | from pycallnumber.unit import Unit 13 | from pycallnumber import utils as u 14 | 15 | 16 | class NonDiscreteSet(object): 17 | 18 | @property 19 | def iscontiguous(self): 20 | raise NotImplementedError() 21 | 22 | @property 23 | def full_typename(self): 24 | try: 25 | unittype_name = '{} '.format(self.unittype.__name__) 26 | except AttributeError: 27 | unittype_name = '' 28 | return '{}{}'.format(unittype_name, type(self).__name__) 29 | 30 | @property 31 | def _range_str_repr(self): 32 | raise NotImplementedError() 33 | 34 | def raise_op_type_error(self, other, optext): 35 | try: 36 | other_name = other.full_typename 37 | except AttributeError: 38 | other_name = type(other).__name__ 39 | msg = ('Cannot use {} with {} and {} objects.' 40 | ''.format(optext, self.full_typename, other_name)) 41 | raise TypeError(msg) 42 | 43 | def _is_valid_arg_for_set_manipulation(self, other): 44 | same_type = isinstance(other, type(self)) 45 | try: 46 | my_ut_general = self.unittype in (u.Infinity, None) 47 | other_ut_general = other.unittype in (u.Infinity, None) 48 | same_ut = self.unittype == other.unittype 49 | except AttributeError: 50 | my_ut_general, other_ut_general, same_ut = False, False, False 51 | return same_type and (my_ut_general or other_ut_general or same_ut) 52 | 53 | def __repr__(self): 54 | return '<{} {}>'.format(self.full_typename, self._range_str_repr) 55 | 56 | def __eq__(self, other): 57 | raise NotImplementedError() 58 | 59 | def __ne__(self, other): 60 | return not self == other 61 | 62 | def __gt__(self, other): 63 | if not isinstance(other, NonDiscreteSet): 64 | self.raise_op_type_error(other, '< > <= or >=') 65 | return other in self and self != other 66 | 67 | def __ge__(self, other): 68 | if not isinstance(other, NonDiscreteSet): 69 | self.raise_op_type_error(other, '< > <= or >=') 70 | return other in self 71 | 72 | def __lt__(self, other): 73 | if not isinstance(other, NonDiscreteSet): 74 | self.raise_op_type_error(other, '< > <= or >=') 75 | return self in other and self != other 76 | 77 | def __le__(self, other): 78 | if not isinstance(other, NonDiscreteSet): 79 | self.raise_op_type_error(other, '< > <= or >=') 80 | return self in other 81 | 82 | def __contains__(self, other): 83 | raise NotImplementedError() 84 | 85 | def __or__(self, other): 86 | raise NotImplementedError() 87 | 88 | def __and__(self, other): 89 | raise NotImplementedError() 90 | 91 | def __sub__(self, other): 92 | raise NotImplementedError() 93 | 94 | def __xor__(self, other): 95 | raise NotImplementedError() 96 | 97 | def issubset(self, other): 98 | try: 99 | return self <= other 100 | except TypeError: 101 | self.raise_op_type_error(other, '``issubset``') 102 | 103 | def issuperset(self, other): 104 | try: 105 | return self >= other 106 | except TypeError: 107 | self.raise_op_type_error(other, '``issuperset``') 108 | 109 | def overlaps(self, other): 110 | raise NotImplementedError() 111 | 112 | def isdisjoint(self, other): 113 | try: 114 | return not self.overlaps(other) 115 | except TypeError: 116 | self.raise_op_type_error(other, '``isdisjoint``') 117 | 118 | def issequential(self, other): 119 | try: 120 | return self.end == other.start or self.start == other.end 121 | except AttributeError: 122 | self.raise_op_type_error(other, '``issequential``') 123 | 124 | def extendshigher(self, other): 125 | try: 126 | return self.end > other.end 127 | except AttributeError: 128 | self.raise_op_type_error(other, '``extendshigher``') 129 | 130 | def extendslower(self, other): 131 | try: 132 | return self.start < other.start 133 | except AttributeError: 134 | self.raise_op_type_error(other, '``extendslower``') 135 | 136 | def isafter(self, other): 137 | try: 138 | return self.start >= other.end 139 | except AttributeError: 140 | return self.start > other 141 | 142 | def isbefore(self, other): 143 | try: 144 | return self.end <= other.start 145 | except AttributeError: 146 | return self.end <= other 147 | 148 | def union(self, *others): 149 | raise NotImplementedError() 150 | 151 | def intersection(self, *others): 152 | raise NotImplementedError() 153 | 154 | def difference(self, *others): 155 | raise NotImplementedError() 156 | 157 | def copy(self): 158 | return copy.deepcopy(self) 159 | 160 | 161 | class Range(NonDiscreteSet): 162 | 163 | def __init__(self, start=None, end=None): 164 | start, end = start or -u.Infinity(), end or u.Infinity() 165 | try: 166 | self._validate(start, end) 167 | except BadRange as e: 168 | raise BadRange('The range {} to {} did not validate: {}' 169 | ''.format(start, end, e)) 170 | self.start, self.end = start, end 171 | utype = type(start) if type(start) != u.Infinity else type(end) 172 | self.unittype = utype 173 | 174 | def _validate(self, start, end): 175 | start_provided = not isinstance(start, u.Infinity) 176 | end_provided = not isinstance(end, u.Infinity) 177 | if start_provided and not isinstance(start, Unit): 178 | raise BadRange('The range\'s ``start`` argument, if provided, ' 179 | 'must be a Unit-derived object.') 180 | if end_provided and not isinstance(end, Unit): 181 | raise BadRange('The range\'s ``end`` argument, if provided, must ' 182 | 'be a Unit-derived object.') 183 | if start_provided and end_provided and type(start) != type(end): 184 | raise BadRange('The range\'s ``start`` and ``end`` arguments, ' 185 | 'if both are provided, must have the same type.') 186 | if start >= end: 187 | raise BadRange('The range\'s ``start`` argument must be less than ' 188 | 'its ``end`` argument.') 189 | 190 | @property 191 | def iscontiguous(self): 192 | return True 193 | 194 | @property 195 | def _range_str_repr(self): 196 | return '\'{}\' to \'{}\''.format(self.start, self.end) 197 | 198 | def __eq__(self, other): 199 | try: 200 | return self.start == other.start and self.end == other.end 201 | except AttributeError: 202 | return False 203 | 204 | def __contains__(self, other): 205 | try: 206 | return self.start <= other.start and self.end >= other.end 207 | except AttributeError: 208 | return self.start <= other and self.end > other 209 | 210 | def __or__(self, other): 211 | try: 212 | if not self._is_valid_arg_for_set_manipulation(other): 213 | raise TypeError 214 | disjoint = self.isdisjoint(other) and not self.issequential(other) 215 | except TypeError: 216 | self.raise_op_type_error(other, 'bitwise ``or`` (|)') 217 | mytype = type(self) 218 | outer_start = self.start if self.extendslower(other) else other.start 219 | outer_end = self.end if self.extendshigher(other) else other.end 220 | if disjoint: 221 | inner_start = self.start if self.isafter(other) else other.start 222 | inner_end = self.end if self.isbefore(other) else other.end 223 | return (mytype(outer_start, inner_end), 224 | mytype(inner_start, outer_end)) 225 | return (mytype(outer_start, outer_end),) 226 | 227 | def __and__(self, other): 228 | try: 229 | if not self._is_valid_arg_for_set_manipulation(other): 230 | raise TypeError 231 | overlaps = self.overlaps(other) 232 | except TypeError: 233 | self.raise_op_type_error(other, 'bitwise ``and`` (&)') 234 | mytype = type(self) 235 | if overlaps: 236 | start = self.start if other.extendslower(self) else other.start 237 | end = self.end if other.extendshigher(self) else other.end 238 | return mytype(start, end) 239 | return None 240 | 241 | def __sub__(self, other): 242 | try: 243 | if not self._is_valid_arg_for_set_manipulation(other): 244 | raise TypeError 245 | issubset = self.issubset(other) 246 | except TypeError: 247 | self.raise_op_type_error(other, 'subtraction (-)') 248 | mytype = type(self) 249 | if issubset: 250 | return None 251 | if self.extendslower(other) and self.extendshigher(other): 252 | return (mytype(self.start, other.start), 253 | mytype(other.end, self.end)) 254 | 255 | use_self_start = self.extendslower(other) or self.isdisjoint(other) 256 | use_self_end = self.extendshigher(other) or self.isdisjoint(other) 257 | start = self.start if use_self_start else other.end 258 | end = self.end if use_self_end else other.start 259 | return (mytype(start, end),) 260 | 261 | def __xor__(self, other): 262 | if self == other: 263 | return None 264 | try: 265 | if not self._is_valid_arg_for_set_manipulation(other): 266 | raise AttributeError 267 | points = [self.start, other.start, self.end, other.end] 268 | points.sort() 269 | except AttributeError: 270 | self.raise_op_type_error(other, 'xor (^)') 271 | mytype = type(self) 272 | if points[0] == points[1]: 273 | return (mytype(points[2], points[3]),) 274 | if points[2] == points[3]: 275 | return (mytype(points[0], points[1]),) 276 | return (mytype(points[0], points[1]), mytype(points[2], points[3])) 277 | 278 | def overlaps(self, other): 279 | try: 280 | issub_or_superset = self.issubset(other) or self.issuperset(other) 281 | except TypeError: 282 | self.raise_op_type_error(other, '``overlaps``') 283 | self_end_overlaps = self.end > other.start and self.end < other.end 284 | other_end_overlaps = other.end > self.start and other.end < self.end 285 | return issub_or_superset or self_end_overlaps or other_end_overlaps 286 | 287 | def union(self, *others): 288 | if any(not self._is_valid_arg_for_set_manipulation(other) 289 | for other in others): 290 | msg = ('All args passed to ``union`` must be the same type and ' 291 | 'use the same Unit type.') 292 | raise TypeError(msg) 293 | rsort_others = tuple(sort((self,) + others, reverse=True)) 294 | results, ranges = (rsort_others[0],), rsort_others[1:] 295 | for range_ in ranges: 296 | if not range_.issubset(results[0]): 297 | results = (results[0] | range_) + results[1:] 298 | return results 299 | 300 | def intersection(self, *others): 301 | if any(not self._is_valid_arg_for_set_manipulation(other) 302 | for other in others): 303 | msg = ('All args passed to ``intersection`` must be the same type ' 304 | 'and use the same Unit type.') 305 | raise TypeError(msg) 306 | results = self 307 | for range_ in others: 308 | results &= range_ 309 | if results is None: 310 | break 311 | return results 312 | 313 | def difference(self, *others): 314 | if any(not self._is_valid_arg_for_set_manipulation(other) 315 | for other in others): 316 | msg = ('All args passed to ``difference`` must be the same type ' 317 | 'and use the same Unit type.') 318 | raise TypeError(msg) 319 | results = (self,) 320 | for range_ in others: 321 | temp_results = [] 322 | for res in results: 323 | diff = res - range_ 324 | if diff is not None: 325 | temp_results.extend(diff) 326 | if not temp_results: 327 | results = None 328 | break 329 | results = tuple(temp_results) 330 | return results 331 | 332 | 333 | class RangeSet(NonDiscreteSet): 334 | 335 | def __init__(self, *user_ranges): 336 | ranges = [] 337 | for rg in user_ranges: 338 | try: 339 | ranges.append(Range(*rg)) 340 | except TypeError: 341 | if isinstance(rg, Range): 342 | ranges.append(rg) 343 | else: 344 | msg = '``__init__`` args must be tuples or Ranges.' 345 | raise RangeSetError(msg) 346 | try: 347 | self.ranges = join(ranges) or [] 348 | except TypeError: 349 | msg = '``__init__`` range args must all use the same Unit type.' 350 | raise RangeSetError(msg) 351 | try: 352 | self.unittype = self.ranges[0].unittype 353 | except IndexError: 354 | self.unittype = None 355 | 356 | @property 357 | def ranges(self): 358 | return self._ranges 359 | 360 | @ranges.setter 361 | def ranges(self, user_ranges): 362 | self._ranges = sort(user_ranges) 363 | if self._ranges: 364 | self.start, self.end = self._ranges[0].start, self._ranges[-1].end 365 | else: 366 | self.start, self.end = None, None 367 | 368 | @property 369 | def iscontiguous(self): 370 | return len(self.ranges) == 1 371 | 372 | @property 373 | def _range_str_repr(self): 374 | return ', '.join([r._range_str_repr for r in self.ranges]) 375 | 376 | def __eq__(self, other): 377 | try: 378 | o_rngs = other.ranges 379 | except (AttributeError, IndexError): 380 | return False 381 | o_rngs_in_self = (r == o_rngs[i] for i, r in enumerate(self.ranges)) 382 | return len(o_rngs) == len(self.ranges) and all(o_rngs_in_self) 383 | 384 | def __contains__(self, other): 385 | try: 386 | other_list = other.ranges 387 | except AttributeError: 388 | other_list = [other] 389 | # All ranges in other must be in at least one of self's ranges 390 | return all(any(o in r for r in self.ranges) for o in other_list) 391 | 392 | def __or__(self, other): 393 | try: 394 | if not self._is_valid_arg_for_set_manipulation(other): 395 | raise TypeError 396 | joined = other.ranges + self.ranges 397 | except (TypeError, AttributeError): 398 | self.raise_op_type_error(other, 'bitwise ``or`` (|)') 399 | mytype = type(self) 400 | return mytype(*joined) 401 | 402 | def __and__(self, other): 403 | try: 404 | if not self._is_valid_arg_for_set_manipulation(other): 405 | raise TypeError 406 | intersected = [r & o for r in self.ranges for o in other.ranges] 407 | except (TypeError, AttributeError): 408 | self.raise_op_type_error(other, 'bitwise ``and`` (&)') 409 | mytype = type(self) 410 | return mytype(*[r for r in intersected if r is not None]) 411 | 412 | def __sub__(self, other): 413 | try: 414 | if not self._is_valid_arg_for_set_manipulation(other): 415 | raise TypeError 416 | diff = [r.difference(*other.ranges) for r in self.ranges] 417 | except (TypeError, AttributeError): 418 | self.raise_op_type_error(other, 'subtraction (-)') 419 | mytype = type(self) 420 | return mytype(*[r for tup in diff if tup is not None for r in tup]) 421 | 422 | def __xor__(self, other): 423 | try: 424 | if not self._is_valid_arg_for_set_manipulation(other): 425 | raise TypeError 426 | sub = self - other 427 | except TypeError: 428 | self.raise_op_type_error(other, 'xor (^)') 429 | revsub = other - self 430 | return sub | revsub 431 | 432 | def overlaps(self, other): 433 | try: 434 | other_list = other.ranges 435 | except AttributeError: 436 | other_list = [other] 437 | try: 438 | return any(r.overlaps(o) for r in self.ranges for o in other_list) 439 | except TypeError: 440 | self.raise_op_type_error(other, '``overlaps``') 441 | 442 | def union(self, *others): 443 | try: 444 | return reduce(lambda rs, other: rs | other, others, self) 445 | except TypeError: 446 | msg = ('All args passed to ``union`` must be the same type and ' 447 | 'use the same Unit type.') 448 | raise TypeError(msg) 449 | 450 | def intersection(self, *others): 451 | try: 452 | return reduce(lambda rs, other: rs & other, others, self) 453 | except TypeError: 454 | msg = ('All args passed to ``intersection`` must be the same type ' 455 | 'and use the same Unit type.') 456 | raise TypeError(msg) 457 | 458 | def difference(self, *others): 459 | try: 460 | return reduce(lambda rs, other: rs - other, others, self) 461 | except TypeError: 462 | msg = ('All args passed to ``difference`` must be the same type ' 463 | 'and use the same Unit type.') 464 | raise TypeError(msg) 465 | 466 | 467 | # Range/RangeSet Utility functions 468 | 469 | def sort(sets, reverse=False): 470 | nr = not reverse 471 | try: 472 | start_desc = sorted(sets, key=operator.attrgetter('start'), reverse=nr) 473 | except AttributeError: 474 | msg = ('Items passed to ``sort`` via the ``sets`` argument must all ' 475 | 'be NonDiscreteSet type items.') 476 | raise TypeError(msg) 477 | return sorted(start_desc, key=operator.attrgetter('end'), reverse=reverse) 478 | 479 | 480 | def join(sets): 481 | try: 482 | return sets[0].union(*sets[1:]) 483 | except IndexError: 484 | return None 485 | except TypeError: 486 | msg = ('Objects passed to ``join`` must all have the same type and ' 487 | 'use the same Unit type.') 488 | raise TypeError(msg) 489 | 490 | 491 | def intersect(sets): 492 | try: 493 | return sets[0].intersection(*sets[1:]) 494 | except IndexError: 495 | return None 496 | except TypeError: 497 | msg = ('Objects passed to ``intersect`` must all have the same type ' 498 | 'and use the same Unit type.') 499 | raise TypeError(msg) 500 | 501 | 502 | def subtract(sets): 503 | try: 504 | return sets[0].difference(*sets[1:]) 505 | except IndexError: 506 | return None 507 | except TypeError: 508 | msg = ('Objects passed to ``subtract`` must all have the same type ' 509 | 'and use the same Unit type.') 510 | raise TypeError(msg) 511 | -------------------------------------------------------------------------------- /src/pycallnumber/settings.py: -------------------------------------------------------------------------------- 1 | """Default settings for pycallnumber package. 2 | 3 | This module provides packagewide defaults. Generally, you can override 4 | them on an individual basis by passing the appropriate parameters to 5 | various functions and object methods, as explained below. 6 | 7 | DO NOT override them by changing these values directly. 8 | 9 | """ 10 | 11 | # ************** OVERRIDABLE UNIT OPTIONS 12 | # These are default options controlling unit type normalization. 13 | # Generally, you shouldn't have to override the defaults. But, in case 14 | # you do, here's what the options are and how to override them. 15 | # 16 | # Alphabetic types provide options for controlling how to normalize 17 | # case. Formatting types provide options for controlling whether or not 18 | # formatting characters appear in sort and search normalizations. 19 | # 20 | # Compound Unit types that contain an Alphabetic and/or Formatting type 21 | # inherit their options. AlphaNumeric types therefore have only case 22 | # options; AlphaSymbol types have case and formatting options, for 23 | # example. 24 | # 25 | # HOW TO OVERRIDE THESE 26 | # 27 | # There are four ways to override options, listed here in order of 28 | # strength or precedence. 29 | # 30 | # 1. Set the relevant class attribute for a unit type to FORCE that 31 | # unit type to use the particular setting. Overrides everything. 32 | # Example: units.Cutter.sort_case = 'upper' 33 | # 34 | # 2. Set the option for an individual object by passing the option via 35 | # a kwarg when you initialize the object. This will override any 36 | # options defaults (see 4) but NOT forced class attributes (see 1). 37 | # Example: units.Cutter('c35', sort_case='upper') 38 | # 39 | # 3. If using one of the factories.py functions, such as `callnumber`, 40 | # you can pass options in using a dict in the `useropts` kwarg. 41 | # This passes your options on when the correct unit object is 42 | # initialized, as in option 2a. 43 | # 44 | # 4. Set or change the default value for an option by setting the 45 | # relevant option in the `options_defaults` class attribute (dict). 46 | # This changes the default for that unit type, which is used IF 47 | # nothing else overrides it. Caveat: be careful that you create a 48 | # copy of the `options_defaults` dict before making changes. 49 | # Otherwise you will end up changing defaults for other unit types. 50 | # Example: units.Cutter.options_defaults = units.Cutter\ 51 | # .options_defaults.copy() 52 | # units.Cutter.options_defaults['sort_case'] = 'upper' 53 | # 54 | # ALPHABETIC `CASE` Options 55 | # ------------------------- 56 | # Options are: `display_case` (controls case when using the `for_print` 57 | # unit method), `search_case` (controls case when using the 58 | # `for_search` unit method), and `sort_case` (controls case when using 59 | # the `for_sort` unit method). 60 | # 61 | # Use 'lower' for lowercase, 'upper' for uppercase, and anything 62 | # else for retaining the original case. 63 | DEFAULT_DISPLAY_CASE = '' 64 | DEFAULT_SEARCH_CASE = 'lower' 65 | DEFAULT_SORT_CASE = 'lower' 66 | 67 | # FORMATTING `USE IN` Options 68 | # --------------------------- 69 | # Options are: `use_formatting_in_search` (controls whether formatting 70 | # appears at all when using the `for_search` unit method) and 71 | # `use_formatting_in_sort` (controls whether formatting appears at all 72 | # when using the `for_sort` unit method). 73 | # 74 | # Use True to include formatting in a normalized string or False to 75 | # hide it. 76 | DEFAULT_USE_FORMATTING_IN_SEARCH = False 77 | DEFAULT_USE_FORMATTING_IN_SORT = False 78 | 79 | 80 | # ************** NON-OVERRIDABLE DEFAULTS 81 | # DEFAULT_MAX_NUMERIC_ZFILL is used by Numeric and derived classes. You 82 | # cannot change this directly, but if you specify that a Numeric unit 83 | # type has a maximum value that would require more than the default 84 | # number of digits, it will be adjusted accordingly for that type. 85 | DEFAULT_MAX_NUMERIC_ZFILL = 10 86 | 87 | 88 | # ************** `FACTORIES` SETTINGS 89 | # DEFAULT_UNIT_TYPES is used by the factories.py functions to specify 90 | # exactly what types of call numbers these functions should recognize. 91 | # You can override this setting by passing your own custom list via the 92 | # `unittypes` kwarg of these functions. 93 | # 94 | # Example: 95 | # my_unit_list = [local_module.MyDewey, local_module.MyLC] 96 | # pycallnumber.callnumber('my_string', unittypes=my_unit_list) 97 | # 98 | # Note that DEFAULT_UNIT_TYPES contains path strings. Your custom list 99 | # (passed to pycallnumber.callnumber via `unittypes`) should contain 100 | # the unit types themselves, not strings. 101 | DEFAULT_UNIT_TYPES = [ 102 | 'pycallnumber.units.Dewey', 103 | 'pycallnumber.units.DeweyClass', 104 | 'pycallnumber.units.LC', 105 | 'pycallnumber.units.LcClass', 106 | 'pycallnumber.units.SuDoc', 107 | 'pycallnumber.units.Local' 108 | ] 109 | 110 | # DEFAULT_RANGESET_TYPE is used by the factories.py `cnrange` and 111 | # `cnset` functions to determine the class that implements call number 112 | # ranges. If you create your own RangeSet class, you can pass the type 113 | # directly via the `rangesettype` kwarg. 114 | # 115 | # Note, like DEFAULT_UNIT_TYPES, DEFAULT_RANGESET_TYPE contains a path 116 | # string, while your `rangessettype` kwarg should be a type/class. 117 | DEFAULT_RANGESET_TYPE = 'pycallnumber.set.RangeSet' 118 | -------------------------------------------------------------------------------- /src/pycallnumber/unit.py: -------------------------------------------------------------------------------- 1 | """Represent a call number or part of a call number.""" 2 | 3 | 4 | from __future__ import unicode_literals 5 | from __future__ import absolute_import 6 | import inspect 7 | 8 | from pycallnumber.options import ObjectWithOptions 9 | from pycallnumber.exceptions import InvalidCallNumberStringError 10 | from pycallnumber.template import Template, SimpleTemplate, CompoundTemplate 11 | from pycallnumber import utils as u 12 | 13 | 14 | class Unit(u.ComparableObjectMixin, ObjectWithOptions): 15 | 16 | options_defaults = { 17 | 'definition': None, 18 | 'is_separator': False 19 | } 20 | template = Template() 21 | is_formatting = False 22 | is_simple = False 23 | is_alphabetic = False 24 | is_numeric = False 25 | 26 | def __init__(self, cnstr, name='', **useropts): 27 | super(Unit, self).__init__(**useropts) 28 | self._validate_result = type(self).validate(cnstr, self.options) 29 | self._string = str(cnstr) 30 | self.name = name 31 | 32 | @classmethod 33 | def _get_derive_calling_module_name(cls, stacklevel): 34 | caller_frm = inspect.stack()[stacklevel+1][0] 35 | try: 36 | name = inspect.getmodule(caller_frm).__name__ 37 | except Exception: 38 | name = None 39 | finally: 40 | del caller_frm 41 | return name 42 | 43 | @classmethod 44 | def derive(cls, stacklevel=1, **attributes): 45 | new_template_opts = {} 46 | template, template_class = cls.template, type(cls.template) 47 | exclude_template_opts = ['short_description'] 48 | for prefix in ['base', 'pre', 'post']: 49 | if '{}_pattern'.format(prefix) in attributes: 50 | exclude_template_opts.append('{}_description'.format(prefix)) 51 | exclude_template_opts.append('{}_description_plural' 52 | ''.format(prefix)) 53 | for opt, val in template.options.items(): 54 | val = None if opt in exclude_template_opts else val 55 | new_template_opts[opt] = attributes.pop(opt, val) 56 | attributes['template'] = template_class(**new_template_opts) 57 | cname = attributes.pop('classname', 'Derived{}'.format(cls.__name__)) 58 | newclass = type(str(cname), (cls,), attributes) 59 | mname = cls._get_derive_calling_module_name(stacklevel) 60 | if mname is not None: 61 | newclass.__module__ = mname 62 | return newclass 63 | 64 | @classmethod 65 | def validate(cls, cnstr, instance_options=None): 66 | instance_options = instance_options or cls.options_defaults.copy() 67 | try: 68 | validate_result = cls.template.validate(cnstr, instance_options) 69 | except InvalidCallNumberStringError as e: 70 | msg = ('\'{}\' is not a valid {} Unit. It should be {}.' 71 | '').format(cnstr, cls.__name__, cls.describe_short(False)) 72 | if str(e): 73 | msg = '{}\n\n{}'.format(msg, e) 74 | raise InvalidCallNumberStringError(msg) 75 | return validate_result 76 | 77 | @classmethod 78 | def describe_short(cls, include_pattern=False): 79 | t_desc = cls.template.describe_short(include_pattern) 80 | text = '' 81 | if getattr(cls, 'definition', None) is not None: 82 | text = '{}; it is structured as '.format(cls.definition) 83 | return '{}{}'.format(text, t_desc) 84 | 85 | @classmethod 86 | def describe_long(cls, include_pattern=False): 87 | t_desc = cls.template.describe_long(include_pattern) 88 | text = 'The ``{}`` Unit type '.format(cls.__name__) 89 | if getattr(cls, 'definition', None) is not None: 90 | text = '{}represents {}. It '.format(text, cls.definition) 91 | return '{}uses the following template.\n\n{}'.format(text, t_desc) 92 | 93 | @classmethod 94 | def get_template_pattern(cls, match_whole=False, use_re_groups=False): 95 | return cls.template.get_regex(match_whole, use_re_groups).pattern 96 | 97 | @classmethod 98 | def get_template_regex(cls, match_whole=False, use_re_groups=False): 99 | return cls.template.get_regex(match_whole, use_re_groups) 100 | 101 | def __str__(self): 102 | """Return the string value for this object.""" 103 | return self.for_print() 104 | 105 | def __repr__(self): 106 | """Return a string representation of this object.""" 107 | return '<{} \'{}\'>'.format(type(self).__name__, str(self)) 108 | 109 | def __contains__(self, other): 110 | return True if str(other) in str(self) else False 111 | 112 | def for_sort(self): 113 | return self._string 114 | 115 | def for_search(self): 116 | return self._string 117 | 118 | def for_print(self): 119 | return self._string 120 | 121 | def cmp_key(self, other, op): 122 | return self.for_sort() 123 | 124 | 125 | class SimpleUnit(Unit): 126 | 127 | template = SimpleTemplate() 128 | options_defaults = Unit.options_defaults.copy() 129 | is_simple = True 130 | 131 | 132 | class CompoundUnit(Unit): 133 | 134 | template = CompoundTemplate( 135 | groups=[ 136 | {'name': 'default', 'type': SimpleUnit} 137 | ] 138 | ) 139 | options_defaults = Unit.options_defaults.copy() 140 | sort_break = '!' 141 | 142 | def __init__(self, cnstr, name='default', **options): 143 | super(CompoundUnit, self).__init__(cnstr, name, **options) 144 | self._generate_parts_and_attributes() 145 | 146 | def _generate_parts_and_attributes(self): 147 | self._parts = [] 148 | self.part_names = [] 149 | self.has_part_names = True 150 | for name in self._validate_result._fields: 151 | value = getattr(self._validate_result, name) 152 | if isinstance(value, list): 153 | value = MultiUnitWrapper(value, value[0].name) 154 | if not getattr(value, 'is_separator', False): 155 | setattr(self, name, value) 156 | self.part_names.append(name) 157 | if value is not None: 158 | self._parts.append(value) 159 | 160 | def __contains__(self, other): 161 | if isinstance(other, type): 162 | return other == type(self) or self._contains_part_with_type(other) 163 | 164 | if isinstance(other, Unit): 165 | return ((type(other) == type(self) and other == self) or 166 | self._contains_part(other)) 167 | return super(CompoundUnit, self).__contains__(other) 168 | 169 | def _contains_part_with_type(self, other): 170 | return any([other == type(p) or other in p for p in self._parts]) 171 | 172 | def _contains_part(self, other): 173 | return any([(type(other) == type(p) and other == p or 174 | type(other) in p and other in p) for p in self._parts]) 175 | 176 | def for_sort(self): 177 | strings, join = ([], False) 178 | for p in self._parts: 179 | for_sort = p.for_sort() 180 | is_sep_or_formatting = p.is_separator or p.is_formatting 181 | if (is_sep_or_formatting and for_sort) or join: 182 | strings[-1] = '{}{}'.format(strings[-1], for_sort) 183 | elif for_sort: 184 | strings.append(for_sort) 185 | join = True if is_sep_or_formatting and for_sort else False 186 | return self.sort_break.join(strings) 187 | 188 | def for_search(self): 189 | return ''.join([p.for_search() for p in self._parts]) 190 | 191 | def for_print(self): 192 | return ''.join([p.for_print() for p in self._parts]) 193 | 194 | 195 | class MultiUnitWrapper(CompoundUnit): 196 | 197 | def __init__(self, parts, name='', is_separator=False): 198 | self.name = name 199 | self.is_separator = is_separator 200 | self._parts = parts 201 | self._parts_without_separators = self._remove_separators(self._parts) 202 | self.has_part_names = False 203 | 204 | def __len__(self): 205 | return len(self._parts_without_separators) 206 | 207 | def __getitem__(self, key): 208 | return self._parts_without_separators[key] 209 | 210 | def _remove_separators(self, parts): 211 | return [part for part in parts if not part.is_separator] 212 | -------------------------------------------------------------------------------- /src/pycallnumber/units/__init__.py: -------------------------------------------------------------------------------- 1 | """Work with predefined types of call numbers and call number parts.""" 2 | from __future__ import absolute_import 3 | 4 | from pycallnumber.units.simple import Alphabetic, Numeric, Formatting 5 | from pycallnumber.units.compound import AlphaNumeric, AlphaSymbol,\ 6 | NumericSymbol, AlphaNumericSymbol 7 | from pycallnumber.units.numbers import Number, OrdinalNumber 8 | from pycallnumber.units.dates import DateString 9 | from pycallnumber.units.callnumbers.parts import Cutter, Edition, Item 10 | from pycallnumber.units.callnumbers import LC, LcClass, Dewey, DeweyClass,\ 11 | SuDoc, Agency, AgencyDotSeries,\ 12 | Local 13 | 14 | __all__ = ['Alphabetic', 'Numeric', 'Formatting', 'AlphaNumeric', 15 | 'AlphaSymbol', 'NumericSymbol', 'AlphaNumericSymbol', 'Number', 16 | 'OrdinalNumber', 'DateString', 'Cutter', 'Edition', 'Item', 'LC', 17 | 'LcClass', 'Dewey', 'DeweyClass', 'SuDoc', 'Agency', 18 | 'AgencyDotSeries', 'Local'] 19 | -------------------------------------------------------------------------------- /src/pycallnumber/units/callnumbers/__init__.py: -------------------------------------------------------------------------------- 1 | """Work with standard call number types.""" 2 | from __future__ import absolute_import 3 | 4 | from pycallnumber.units.callnumbers.dewey import Dewey, DeweyClass 5 | from pycallnumber.units.callnumbers.lc import LC, LcClass 6 | from pycallnumber.units.callnumbers.sudoc import SuDoc, Agency, AgencyDotSeries 7 | from pycallnumber.units.callnumbers.local import Local 8 | 9 | __all__ = ['Dewey', 'DeweyClass', 'LC', 'LcClass', 'SuDoc', 'Agency', 10 | 'AgencyDotSeries', 'Local'] 11 | -------------------------------------------------------------------------------- /src/pycallnumber/units/callnumbers/dewey.py: -------------------------------------------------------------------------------- 1 | """Work with Dewey call numbers as Units.""" 2 | 3 | from __future__ import unicode_literals 4 | from __future__ import absolute_import 5 | 6 | from pycallnumber.template import CompoundTemplate 7 | from pycallnumber.units.simple import Alphabetic, DEFAULT_SEPARATOR_TYPE 8 | from pycallnumber.units.compound import AlphaNumericSymbol 9 | from pycallnumber.units.numbers import Number 10 | from pycallnumber.units.callnumbers.parts import Cutter, Edition, Item 11 | 12 | 13 | DeweyClass = Number.derive( 14 | classname='DeweyClass', 15 | short_description=('a number between 0 and 999 followed by an ' 16 | 'optional decimal, up to 8 decimal places'), 17 | max_val=999.99999999, 18 | max_decimal_places=8 19 | ) 20 | 21 | DeweyCutter = Cutter.derive( 22 | classname='DeweyCutter', 23 | short_description=('a string with 1 to 3 letters, followed by a ' 24 | 'number, followed by an optional alphabetic ' 25 | 'workmark; no whitespace between any of these ' 26 | 'components'), 27 | separator_type=None, 28 | groups=[ 29 | {'min': 1, 'max': 1, 'name': 'letters', 'type': Cutter.Letters}, 30 | {'min': 1, 'max': 1, 'name': 'number', 'type': Cutter.StringNumber}, 31 | {'min': 0, 'max': 1, 'name': 'workmark', 'type': Alphabetic} 32 | ] 33 | ) 34 | 35 | 36 | class Dewey(AlphaNumericSymbol): 37 | 38 | definition = ('a Dewey Decimal call number') 39 | 40 | template = CompoundTemplate( 41 | separator_type=DEFAULT_SEPARATOR_TYPE, 42 | groups=[ 43 | {'name': 'classification', 'min': 1, 'max': 1, 'type': DeweyClass}, 44 | {'name': 'cutters', 'min': 1, 'max': 2, 45 | 'inner_sep_type': DEFAULT_SEPARATOR_TYPE, 'type': DeweyCutter}, 46 | {'name': 'edition', 'min': 0, 'max': 1, 'type': Edition}, 47 | {'name': 'item', 'min': 0, 'max': 1, 'type': Item} 48 | ] 49 | ) 50 | -------------------------------------------------------------------------------- /src/pycallnumber/units/callnumbers/lc.py: -------------------------------------------------------------------------------- 1 | """Work with Library of Congress call numbers as Units.""" 2 | 3 | from __future__ import unicode_literals 4 | from __future__ import absolute_import 5 | 6 | from pycallnumber.template import CompoundTemplate 7 | from pycallnumber.units.simple import Alphabetic, Formatting,\ 8 | DEFAULT_SEPARATOR_TYPE 9 | from pycallnumber.units.compound import AlphaNumericSymbol 10 | from pycallnumber.units.numbers import Number 11 | from pycallnumber.units.callnumbers.parts import Cutter, Edition, Item 12 | 13 | 14 | class LcClass(AlphaNumericSymbol): 15 | 16 | short_description = ('a string with 1 to 3 letters followed by a number ' 17 | 'with up to 4 digits and optionally up to 4 ' 18 | 'decimal places; the alphabetic and numeric parts ' 19 | 'may optionally be separated by whitespace') 20 | 21 | ClassLetters = Alphabetic.derive( 22 | classname='LcClass.ClassLetters', 23 | min_length=1, 24 | max_length=3 25 | ) 26 | 27 | ClassNumber = Number.derive( 28 | classname='LcClass.ClassNumber', 29 | definition=('A number between 1 and 9999.9999, with 0 to 4 decimal ' 30 | 'places'), 31 | max_val=9999.9999, 32 | max_decimal_places=4, 33 | thousands=None 34 | ) 35 | 36 | template = CompoundTemplate( 37 | separator_type=DEFAULT_SEPARATOR_TYPE, 38 | groups=[ 39 | {'min': 1, 'max': 1, 'name': 'letters', 'type': ClassLetters}, 40 | {'min': 1, 'max': 1, 'name': 'number', 'type': ClassNumber} 41 | ] 42 | ) 43 | 44 | 45 | CutterPeriod = Formatting.derive( 46 | classname='CutterPeriod', 47 | short_description=('the whitespace and/or period before the Cutters'), 48 | min_length=1, 49 | max_length=1, 50 | base_pattern=r'(?:\s*\.?)', 51 | ) 52 | 53 | 54 | class LC(AlphaNumericSymbol): 55 | 56 | definition = 'a Library of Congress call number' 57 | 58 | Space = DEFAULT_SEPARATOR_TYPE.derive( 59 | min_length=1 60 | ) 61 | 62 | template = CompoundTemplate( 63 | separator_type=DEFAULT_SEPARATOR_TYPE, 64 | groups=[ 65 | {'name': 'classification', 'min': 1, 'max': 1, 'type': LcClass}, 66 | {'name': 'period', 'min': 0, 'max': 1, 'type': CutterPeriod, 67 | 'is_separator': True}, 68 | {'name': 'cutters', 'min': 1, 'max': None, 69 | 'inner_sep_type': DEFAULT_SEPARATOR_TYPE, 'type': Cutter}, 70 | {'name': 'edition', 'min': 0, 'max': 1, 'type': Edition}, 71 | {'name': 'space', 'min': 1, 'max': 1, 'type': Space, 72 | 'is_separator': True}, 73 | {'name': 'item', 'min': 0, 'max': 1, 'type': Item} 74 | ] 75 | ) 76 | -------------------------------------------------------------------------------- /src/pycallnumber/units/callnumbers/local.py: -------------------------------------------------------------------------------- 1 | """Work with any string as a local call number Unit type.""" 2 | 3 | from __future__ import unicode_literals 4 | 5 | from pycallnumber.template import CompoundTemplate 6 | from pycallnumber.units.simple import Alphabetic, Formatting,\ 7 | DEFAULT_SEPARATOR_TYPE 8 | from pycallnumber.units.compound import AlphaNumericSymbol 9 | from pycallnumber.units.numbers import Number 10 | 11 | 12 | class Local(AlphaNumericSymbol): 13 | 14 | definition = 'a local call number with a non-specific structure' 15 | 16 | template = CompoundTemplate( 17 | short_description=AlphaNumericSymbol.template.short_description, 18 | groups=[ 19 | {'name': 'parts', 'min': 1, 'max': None, 20 | 'inner_sep_type': DEFAULT_SEPARATOR_TYPE, 21 | 'possible_types': [Alphabetic, Number, Formatting]} 22 | ] 23 | ) 24 | -------------------------------------------------------------------------------- /src/pycallnumber/units/callnumbers/parts.py: -------------------------------------------------------------------------------- 1 | """Work with structures commonly found in many call number types.""" 2 | 3 | from __future__ import unicode_literals 4 | 5 | from pycallnumber.template import CompoundTemplate 6 | from pycallnumber.unit import CompoundUnit 7 | from pycallnumber.units.simple import Alphabetic, Numeric, Formatting,\ 8 | DEFAULT_SEPARATOR_TYPE 9 | from pycallnumber.units.compound import AlphaNumeric, AlphaNumericSymbol 10 | from pycallnumber.units.numbers import Number, OrdinalNumber 11 | from pycallnumber.units.dates.datestring import DateString 12 | 13 | 14 | class Cutter(AlphaNumericSymbol): 15 | 16 | definition = ('a compact alphanumeric code used to arrange things ' 17 | 'alphabetically') 18 | 19 | Letters = Alphabetic.derive( 20 | classname='Cutter.Letters', 21 | short_description='1 to 3 letters', 22 | min_length=1, 23 | max_length=3 24 | ) 25 | 26 | StringNumber = Numeric.derive( 27 | classname='Cutter.StringNumber', 28 | short_description='a number that sorts as a decimal', 29 | max_val=.99999999 30 | ) 31 | 32 | template = CompoundTemplate( 33 | short_description=('a string with 1 to 3 letters followed by a ' 34 | 'number; the alphabetic and numeric portions ' 35 | 'can be separated by optional whitespace'), 36 | separator_type=DEFAULT_SEPARATOR_TYPE, 37 | groups=[ 38 | {'name': 'letters', 'min': 1, 'max': 1, 'type': Letters}, 39 | {'name': 'number', 'min': 1, 'max': 1, 'type': StringNumber} 40 | ] 41 | ) 42 | 43 | 44 | class Edition(AlphaNumeric): 45 | 46 | definition = 'information identifying the edition of an item' 47 | 48 | Year = Numeric.derive( 49 | classname='Edition.Year', 50 | min_length=4, 51 | max_length=4 52 | ) 53 | 54 | template = CompoundTemplate( 55 | short_description=('a 4-digit year, optionally followed by one or ' 56 | 'more letters (no whitespace between them)'), 57 | separator_type=None, 58 | groups=[ 59 | {'min': 1, 'max': 1, 'name': 'year', 'type': Year}, 60 | {'min': 0, 'max': 1, 'name': 'letters', 'type': Alphabetic} 61 | ] 62 | ) 63 | 64 | 65 | class Item(AlphaNumericSymbol): 66 | 67 | definition = ('information such as volume number, copy number, opus ' 68 | 'number, etc., that may be included at the end of a call ' 69 | 'number to help further differentiate an item from others ' 70 | 'with the same call number') 71 | 72 | Separator = Formatting.derive( 73 | min_length=1, 74 | max_length=None 75 | ) 76 | 77 | FormattingNoSpace = Formatting.derive( 78 | classname='Item.FormattingNoSpace', 79 | short_description='any non-alphanumeric, non-whitespace character', 80 | min_length=1, 81 | max_length=None, 82 | base_pattern=r'[^A-Za-z0-9\s]', 83 | use_formatting_in_sort=False, 84 | use_formatting_in_search=False 85 | ) 86 | 87 | SpaceOrPeriod = Formatting.derive( 88 | classname='Item.SpaceOrPeriod', 89 | short_description='a period followed by optional whitespace', 90 | min_length=1, 91 | max_length=1, 92 | base_pattern=r'(?:(?:\.|\s)\s*)' 93 | ) 94 | 95 | Space = Formatting.derive( 96 | classname='Item.Space', 97 | short_description='whitespace', 98 | min_length=1, 99 | max_length=1, 100 | base_pattern=r'\s' 101 | ) 102 | 103 | AnythingButSpace = AlphaNumericSymbol.derive( 104 | classname='Item.AnythingButSpace', 105 | short_description=('any combination of letters, symbols, and numbers ' 106 | 'with no whitespace'), 107 | groups=[ 108 | {'min': 1, 'max': None, 'name': 'parts', 'inner_sep_type': None, 109 | 'possible_types': [Alphabetic, Numeric, FormattingNoSpace]} 110 | ] 111 | ) 112 | 113 | Label = Alphabetic.derive( 114 | classname='Item.Label', 115 | for_sort=lambda x: '', 116 | for_search=lambda x: '' 117 | ) 118 | 119 | IdString = AlphaNumericSymbol.derive( 120 | classname='Item.IdString', 121 | short_description=('a string with at least one number; can have any ' 122 | 'characters except whitespace'), 123 | separator_type=None, 124 | groups=[ 125 | {'min': 0, 'max': None, 'name': 'pre_number', 126 | 'inner_sep_type': None, 127 | 'possible_types': [Alphabetic, FormattingNoSpace]}, 128 | {'min': 1, 'max': 1, 'name': 'first_number', 'type': Number}, 129 | {'min': 0, 'max': None, 'name': 'everything_else', 130 | 'inner_sep_type': None, 131 | 'possible_types': [Alphabetic, Number, FormattingNoSpace]} 132 | ] 133 | ) 134 | 135 | LabelThenNumber = AlphaNumericSymbol.derive( 136 | classname='Item.LabelThenNumber', 137 | short_description=('a string with a one-word label (which can contain ' 138 | 'formatting), followed by a period and/or ' 139 | 'whitespace, followed by one or more numbers (and ' 140 | 'possibly letters and formatting), such as ' 141 | '\'Op. 1\', \'volume 1a\', or \'no. A-1\'; when ' 142 | 'sorting, the label is ignored so that, e.g., ' 143 | '\'Volume 1\' sorts before \'vol 2\''), 144 | separator_type=SpaceOrPeriod, 145 | groups=[ 146 | {'min': 0, 'max': None, 'name': 'label', 'inner_sep_type': None, 147 | 'possible_types': [Label, FormattingNoSpace]}, 148 | {'min': 1, 'max': 1, 'name': 'number', 'type': IdString}, 149 | ], 150 | for_sort=lambda x: '{}{}'.format(CompoundUnit.sort_break, 151 | AlphaNumericSymbol.for_sort(x)) 152 | ) 153 | 154 | NumberThenLabel = AlphaNumericSymbol.derive( 155 | classname='Item.NumberThenLabel', 156 | short_description=('a string with an ordinal number and then a one-' 157 | 'word label, like \'101st Congress\' or \'2nd ' 158 | 'vol.\'; when sorting, the label is ignored so ' 159 | 'that, e.g., \'1st Congress\' sorts before \'2nd ' 160 | 'CONG.\''), 161 | separator_type=Space, 162 | groups=[ 163 | {'min': 1, 'max': 1, 'name': 'number', 'type': OrdinalNumber}, 164 | {'min': 1, 'max': 1, 'name': 'label', 'type': Label} 165 | ], 166 | for_sort=lambda x: '{}{}'.format(CompoundUnit.sort_break, 167 | AlphaNumericSymbol.for_sort(x)) 168 | ) 169 | 170 | template = CompoundTemplate( 171 | short_description=('a string (any string) that gets parsed into ' 172 | 'groups of labeled numbers and other groups of ' 173 | 'words, symbols, and dates, where labels are ' 174 | 'ignored for sorting; \'Volume 1 Copy 1\' sorts ' 175 | 'before \'v. 1 c. 2\', which sorts before ' 176 | '\'VOL 1 CP 2 SUPP\'; dates are normalized to ' 177 | 'YYYYMMDD format for sorting'), 178 | groups=[ 179 | {'min': 1, 'max': None, 'name': 'parts', 180 | 'inner_sep_type': Separator, 181 | 'possible_types': [DateString, NumberThenLabel, 182 | LabelThenNumber, AnythingButSpace]} 183 | ] 184 | ) 185 | -------------------------------------------------------------------------------- /src/pycallnumber/units/callnumbers/sudoc.py: -------------------------------------------------------------------------------- 1 | """Work with US SuDocs numbers as Units.""" 2 | 3 | from __future__ import unicode_literals 4 | from __future__ import absolute_import 5 | 6 | from pycallnumber.template import CompoundTemplate 7 | from pycallnumber.unit import CompoundUnit 8 | from pycallnumber.units.simple import Alphabetic, Numeric, Formatting,\ 9 | DEFAULT_SEPARATOR_TYPE 10 | from pycallnumber.units.compound import AlphaNumericSymbol 11 | from pycallnumber.units.callnumbers.parts import Cutter 12 | 13 | 14 | # Various base SimpleUnits for SuDoc component classes 15 | 16 | LettersFirst = Alphabetic.derive( 17 | classname='LettersFirst', 18 | for_sort=lambda x: '{}{}'.format(CompoundUnit.sort_break, 19 | Alphabetic.for_sort(x)) 20 | ) 21 | 22 | Period = Formatting.derive( 23 | classname='Period', 24 | short_description='a period', 25 | min_length=1, 26 | max_length=1, 27 | base_pattern=r'\.', 28 | use_formatting_in_sort=True 29 | ) 30 | 31 | Slash = Formatting.derive( 32 | classname='Slash', 33 | short_description='a forward slash', 34 | min_length=1, 35 | max_length=1, 36 | base_pattern=r'\s*/\s*', 37 | use_formatting_in_sort=True 38 | ) 39 | 40 | FormattingNoSlash = Formatting.derive( 41 | classname='FormattingNoSlash', 42 | short_description=('any non-alphanumeric character except a forward ' 43 | 'slash'), 44 | min_length=1, 45 | max_length=1, 46 | base_pattern=r'[^A-Za-z0-9/]' 47 | ) 48 | 49 | Dash = Formatting.derive( 50 | classname='Dash', 51 | short_description='a dash (hyphen)', 52 | min_length=1, 53 | max_length=1, 54 | base_pattern=r'\s*-\s*', 55 | use_formatting_in_sort=True 56 | ) 57 | 58 | Colon = Formatting.derive( 59 | classname='Colon', 60 | short_description='a colon', 61 | min_length=1, 62 | max_length=1, 63 | base_pattern=r'\s*:\s*' 64 | ) 65 | 66 | 67 | class Agency(AlphaNumericSymbol): 68 | 69 | short_description = ('either \'X/A\' (for the Congressional Record) or ' 70 | 'a 1- to 4-letter alphabetic department code and ' 71 | 'optional numeric code for a subordinate office') 72 | 73 | Department = Alphabetic.derive( 74 | classname='Agency.Department', 75 | min_length=1, 76 | max_length=4, 77 | ) 78 | 79 | XaDepartment = AlphaNumericSymbol.derive( 80 | classname='Agency.XaDepartment', 81 | short_description='\'X/A\'', 82 | separator_type=DEFAULT_SEPARATOR_TYPE, 83 | groups=[ 84 | {'name': 'x', 'min': 1, 'max': 1, 85 | 'type': Alphabetic.derive(min_length=1, max_length=1, 86 | base_pattern=r'[Xx]')}, 87 | {'name': 'slash', 'min': 1, 'max': 1, 'type': Slash}, 88 | {'name': 'a', 'min': 1, 'max': 1, 89 | 'type': LettersFirst.derive(min_length=1, max_length=1, 90 | base_pattern=r'[Aa]')} 91 | ] 92 | ) 93 | 94 | Office = Numeric.derive( 95 | classname='Agency.Office' 96 | ) 97 | 98 | template = CompoundTemplate( 99 | separator_type=DEFAULT_SEPARATOR_TYPE, 100 | groups=[ 101 | {'name': 'department', 'min': 1, 'max': 1, 102 | 'possible_types': [XaDepartment, Department]}, 103 | {'name': 'office', 'min': 0, 'max': 1, 'type': Office} 104 | ] 105 | ) 106 | 107 | 108 | XjhsAgency = CompoundUnit.derive( 109 | options_defaults=Alphabetic.options_defaults.copy(), 110 | classname='XjhsAgency', 111 | definition='the House or Senate Journal designation', 112 | short_description='the string \'XJH\' or \'XJS\'', 113 | groups=[ 114 | {'name': 'agency', 'min': 1, 'max': 1, 115 | 'type': Alphabetic.derive(min_length=1, max_length=1, 116 | base_pattern=r'[Xx][Jj](?:[Hh]|[Ss])')} 117 | ] 118 | ) 119 | 120 | 121 | class Series(AlphaNumericSymbol): 122 | 123 | short_description = ('a Cutter number or a number denoting the category/' 124 | 'series of the publication, optionally followed ' 125 | 'by a forward slash and one or two alphabetic or ' 126 | 'numeric codes separated by a hyphen, which denote ' 127 | 'a related series'), 128 | 129 | NumericSeries = Numeric.derive( 130 | classname='Series.NumericSeries' 131 | ) 132 | 133 | RelatedSeries = AlphaNumericSymbol.derive( 134 | classname='Series.RelatedSeries', 135 | groups=[ 136 | {'name': 'parts', 'min': 1, 'max': None, 137 | 'possible_types': [LettersFirst, Numeric], 'inner_sep_type': Dash} 138 | ] 139 | ) 140 | 141 | template = CompoundTemplate( 142 | separator_type=DEFAULT_SEPARATOR_TYPE, 143 | groups=[ 144 | {'name': 'main_series', 'min': 1, 'max': 1, 145 | 'possible_types': [Cutter, NumericSeries]}, 146 | {'name': 'slash', 'min': 0, 'max': 1, 'type': Slash, 147 | 'is_separator': True}, 148 | {'name': 'related_series', 'min': 0, 'max': 1, 149 | 'type': RelatedSeries} 150 | ] 151 | ) 152 | 153 | 154 | AgencyDotSeries = AlphaNumericSymbol.derive( 155 | classname='AgencyDotSeries', 156 | definition=('information about the department, agency, or office that ' 157 | 'published the item along with the format or publication ' 158 | 'series it falls under'), 159 | short_description=('a string with the following parts: ``agency``, ' 160 | '{}; dot; ``series``, {}' 161 | ''.format(Agency.describe_short(False), 162 | Series.describe_short(False))), 163 | groups=[ 164 | {'name': 'agency', 'min': 1, 'max': 1, 'type': Agency}, 165 | {'name': 'period', 'min': 1, 'max': 1, 'type': Period, 166 | 'is_separator': True}, 167 | {'name': 'series', 'min': 1, 'max': 1, 'type': Series}, 168 | ] 169 | ) 170 | 171 | 172 | class BookNumber(AlphaNumericSymbol): 173 | 174 | definition = ('information that helps differentiate items with the same ' 175 | 'class stem from each other, such as volume numbers or ' 176 | 'agency-specific designators') 177 | short_description = ('a string containing any characters; letters sort ' 178 | 'before numbers') 179 | 180 | Component = AlphaNumericSymbol.derive( 181 | classname='BookNumber.Component', 182 | groups=[ 183 | {'name': 'parts', 'min': 1, 'max': None, 184 | 'possible_types': [LettersFirst, Numeric, Dash, 185 | FormattingNoSlash]} 186 | ] 187 | ) 188 | 189 | template = CompoundTemplate( 190 | groups=[ 191 | {'name': 'parts', 'min': 0, 'max': None, 'inner_sep_type': Slash, 192 | 'type': Component}, 193 | ] 194 | ) 195 | 196 | 197 | class SuDoc(AlphaNumericSymbol): 198 | 199 | definition = ('a call number that uses the US Superintendent of Documents ' 200 | 'Classification scheme') 201 | 202 | template = CompoundTemplate( 203 | separator_type=DEFAULT_SEPARATOR_TYPE, 204 | groups=[ 205 | {'name': 'stem', 'min': 1, 'max': 1, 206 | 'possible_types': [XjhsAgency, AgencyDotSeries]}, 207 | {'name': 'colon', 'min': 1, 'max': 1, 'type': Colon}, 208 | {'name': 'book_number', 'min': 1, 'max': 1, 'type': BookNumber} 209 | ] 210 | ) 211 | 212 | def __init__(self, cnstr, name='default', **options): 213 | super(SuDoc, self).__init__(cnstr, name, **options) 214 | if hasattr(self.stem, 'series'): 215 | if not getattr(self.stem.series, 'related_series'): 216 | blank_related_series = Formatting.derive( 217 | min_length=0, 218 | for_sort=lambda x: CompoundUnit.sort_break 219 | )('') 220 | self.stem.series._parts.append(blank_related_series) 221 | -------------------------------------------------------------------------------- /src/pycallnumber/units/compound.py: -------------------------------------------------------------------------------- 1 | """Represent general types of compound call numbers and parts.""" 2 | 3 | 4 | from __future__ import unicode_literals 5 | from __future__ import absolute_import 6 | 7 | from pycallnumber.template import CompoundTemplate 8 | from pycallnumber.unit import CompoundUnit 9 | from pycallnumber.units.simple import Alphabetic, Numeric, Formatting,\ 10 | DEFAULT_SEPARATOR_TYPE 11 | 12 | 13 | class AlphaNumeric(CompoundUnit): 14 | 15 | options_defaults = Alphabetic.options_defaults.copy() 16 | options_defaults.update(Numeric.options_defaults) 17 | 18 | template = CompoundTemplate( 19 | separator_type=None, 20 | groups=[ 21 | {'name': 'parts', 'min': 1, 'max': None, 22 | 'possible_types': [Alphabetic, Numeric]} 23 | ] 24 | ) 25 | 26 | 27 | class AlphaSymbol(CompoundUnit): 28 | 29 | options_defaults = Alphabetic.options_defaults.copy() 30 | options_defaults.update(Formatting.options_defaults) 31 | 32 | template = CompoundTemplate( 33 | separator_type=DEFAULT_SEPARATOR_TYPE, 34 | groups=[ 35 | {'name': 'parts', 'min': 1, 'max': None, 36 | 'possible_types': [Alphabetic, Formatting]} 37 | ] 38 | ) 39 | 40 | 41 | class NumericSymbol(CompoundUnit): 42 | 43 | options_defaults = Numeric.options_defaults.copy() 44 | options_defaults.update(Formatting.options_defaults) 45 | 46 | template = CompoundTemplate( 47 | separator_type=DEFAULT_SEPARATOR_TYPE, 48 | groups=[ 49 | {'name': 'parts', 'min': 1, 'max': None, 50 | 'possible_types': [Numeric, Formatting]} 51 | ] 52 | ) 53 | 54 | 55 | class AlphaNumericSymbol(CompoundUnit): 56 | 57 | options_defaults = AlphaNumeric.options_defaults.copy() 58 | options_defaults.update(Formatting.options_defaults) 59 | 60 | template = CompoundTemplate( 61 | separator_type=DEFAULT_SEPARATOR_TYPE, 62 | groups=[ 63 | {'name': 'parts', 'min': 1, 'max': None, 64 | 'possible_types': [Alphabetic, Numeric, Formatting]} 65 | ] 66 | ) 67 | -------------------------------------------------------------------------------- /src/pycallnumber/units/dates/__init__.py: -------------------------------------------------------------------------------- 1 | """Parse date strings as call number parts.""" 2 | from __future__ import absolute_import 3 | 4 | from pycallnumber.units.dates.datestring import DateString 5 | 6 | __all__ = ['DateString'] 7 | -------------------------------------------------------------------------------- /src/pycallnumber/units/dates/base.py: -------------------------------------------------------------------------------- 1 | """Model various types of date strings as call number parts. 2 | 3 | This contains some base classes and a mixin to allow dates to be 4 | represented as Units more easily. 5 | """ 6 | 7 | 8 | from __future__ import unicode_literals 9 | 10 | from builtins import str 11 | from builtins import object 12 | from pycallnumber.template import CompoundTemplate 13 | from pycallnumber.unit import SimpleUnit 14 | from pycallnumber.units.simple import Alphabetic, Numeric 15 | from pycallnumber.units.compound import AlphaNumeric, AlphaNumericSymbol 16 | 17 | 18 | class DatePartMixin(object): 19 | 20 | category = None # 'year' or 'month' or 'day' 21 | 22 | @property 23 | def value(self): 24 | try: 25 | return super(DatePartMixin, self).value 26 | except AttributeError: 27 | raise NotImplementedError() 28 | 29 | def for_sort(self): 30 | numeric_opts = Numeric.filter_valid_useropts(self.options) 31 | return Numeric(str(self.value), **numeric_opts).for_sort() 32 | 33 | 34 | class AlphaDatePart(DatePartMixin, Alphabetic): 35 | 36 | options_defaults = AlphaNumeric.options_defaults.copy() 37 | 38 | 39 | class NumericDatePart(DatePartMixin, Numeric): 40 | pass 41 | 42 | 43 | class CompoundDatePart(DatePartMixin, AlphaNumericSymbol): 44 | pass 45 | 46 | 47 | class BaseDate(AlphaNumericSymbol): 48 | 49 | template = CompoundTemplate( 50 | separator_type=None, 51 | groups=[ 52 | {'min': 1, 'max': 1, 'name': 'prop_month', 'type': SimpleUnit}, 53 | {'min': 0, 'max': 1, 'name': 'prop_day', 'type': SimpleUnit}, 54 | {'min': 1, 'max': 1, 'name': 'prop_year', 'type': SimpleUnit} 55 | ] 56 | ) 57 | 58 | def _get_date_prop(self, prop): 59 | prop = 'prop_{}'.format(prop) 60 | if hasattr(self, prop): 61 | return getattr(self, prop) 62 | else: 63 | return None 64 | 65 | @property 66 | def year(self): 67 | return self._get_date_prop('year') 68 | 69 | @property 70 | def month(self): 71 | return self._get_date_prop('month') 72 | 73 | @property 74 | def day(self): 75 | return self._get_date_prop('day') 76 | 77 | @property 78 | def normalized_datestring(self): 79 | if not hasattr(self, '_normalized_datestring'): 80 | parts = [] 81 | for category in ['year', 'month', 'day']: 82 | part = getattr(self, category) or 0 83 | if part: 84 | part = part.value 85 | parts.append(part) 86 | self._normalized_datestring = '{:04d}{:02d}{:02d}'.format(*parts) 87 | return self._normalized_datestring 88 | 89 | def for_sort(self): 90 | numeric_opts = Numeric.filter_valid_useropts(self.options) 91 | return Numeric(self.normalized_datestring, **numeric_opts).for_sort() 92 | -------------------------------------------------------------------------------- /src/pycallnumber/units/dates/datestring.py: -------------------------------------------------------------------------------- 1 | """Represent (almost) any kind of date string as a call number part.""" 2 | 3 | 4 | from __future__ import unicode_literals 5 | from __future__ import absolute_import 6 | 7 | from pycallnumber.template import CompoundTemplate 8 | from pycallnumber.units.simple import Formatting, DEFAULT_SEPARATOR_TYPE 9 | from pycallnumber.units.dates.base import BaseDate 10 | from pycallnumber.units.dates.parts import Year, Month, Day 11 | 12 | 13 | Separator = Formatting.derive( 14 | classname='Separator', 15 | short_description=('a space, forward slash, period, hyphen, or comma ' 16 | 'plus space'), 17 | min_length=1, 18 | max_length=1, 19 | base_pattern=r'(?:[\s.\-/]|,\s?)' 20 | ) 21 | 22 | 23 | DateMDY = BaseDate.derive( 24 | classname='DateMDY', 25 | separator_type=Separator, 26 | short_description='a date with the month first, day, and then year', 27 | groups=[ 28 | {'min': 1, 'max': 1, 'name': 'prop_month', 'type': Month}, 29 | {'min': 1, 'max': 1, 'name': 'prop_day', 'type': Day}, 30 | {'min': 1, 'max': 1, 'name': 'prop_year', 'type': Year} 31 | ], 32 | ) 33 | 34 | DateDMY = BaseDate.derive( 35 | classname='DateDMY', 36 | separator_type=Separator, 37 | short_description='a date with the day first, month, and then year', 38 | groups=[ 39 | {'min': 1, 'max': 1, 'name': 'prop_day', 'type': Day}, 40 | {'min': 1, 'max': 1, 'name': 'prop_month', 'type': Month}, 41 | {'min': 1, 'max': 1, 'name': 'prop_year', 'type': Year} 42 | ] 43 | ) 44 | 45 | DateYMD = BaseDate.derive( 46 | classname='DateYMD', 47 | separator_type=Separator, 48 | short_description='a date with the year first, month, and then day', 49 | groups=[ 50 | {'min': 1, 'max': 1, 'name': 'prop_year', 'type': Year}, 51 | {'min': 1, 'max': 1, 'name': 'prop_month', 'type': Month}, 52 | {'min': 1, 'max': 1, 'name': 'prop_day', 'type': Day}, 53 | ] 54 | ) 55 | 56 | DateYDM = BaseDate.derive( 57 | classname='DateYMD', 58 | separator_type=Separator, 59 | short_description='a date with the year first, day, and then month', 60 | groups=[ 61 | {'min': 1, 'max': 1, 'name': 'prop_year', 'type': Year}, 62 | {'min': 1, 'max': 1, 'name': 'prop_day', 'type': Day}, 63 | {'min': 1, 'max': 1, 'name': 'prop_month', 'type': Month}, 64 | ] 65 | ) 66 | 67 | DateMY = BaseDate.derive( 68 | classname='DateMY', 69 | separator_type=Separator, 70 | short_description='a date with the month then the year; no day', 71 | groups=[ 72 | {'min': 1, 'max': 1, 'name': 'prop_month', 'type': Month}, 73 | {'min': 1, 'max': 1, 'name': 'prop_year', 'type': Year}, 74 | ] 75 | ) 76 | 77 | DateYM = BaseDate.derive( 78 | classname='DateYM', 79 | separator_type=Separator, 80 | short_description='a date with the year then the month; no day', 81 | groups=[ 82 | {'min': 1, 'max': 1, 'name': 'prop_year', 'type': Year}, 83 | {'min': 1, 'max': 1, 'name': 'prop_month', 'type': Month}, 84 | ] 85 | ) 86 | 87 | 88 | class DateString(BaseDate): 89 | 90 | definition = 'any string that represents a date, in a non-specific format' 91 | 92 | template = CompoundTemplate( 93 | separator_type=DEFAULT_SEPARATOR_TYPE, 94 | groups=[ 95 | {'min': 1, 'max': 1, 'name': 'date', 96 | 'possible_types': [DateMDY, DateDMY, DateYMD, DateYDM, 97 | DateMY, DateYM, Month.AlphaMonthLong, 98 | Month.AbbreviatedMonth]} 99 | ] 100 | ) 101 | 102 | def _get_date_prop(self, prop): 103 | if hasattr(self.date, prop): 104 | return getattr(self.date, prop) 105 | elif self.date.category == prop: 106 | return self.date 107 | else: 108 | return None 109 | -------------------------------------------------------------------------------- /src/pycallnumber/units/dates/parts.py: -------------------------------------------------------------------------------- 1 | """Model various types of date strings as call number parts. 2 | 3 | This contains Unit implementations for various pieces of dates. 4 | """ 5 | 6 | 7 | from __future__ import unicode_literals 8 | from __future__ import absolute_import 9 | from builtins import str 10 | from builtins import range 11 | from datetime import datetime 12 | 13 | from pycallnumber.template import SimpleTemplate, CompoundTemplate 14 | from pycallnumber.units.simple import Formatting 15 | from pycallnumber.units.numbers import OrdinalNumber 16 | from pycallnumber.units.dates.base import AlphaDatePart, NumericDatePart,\ 17 | CompoundDatePart 18 | 19 | 20 | class Year(NumericDatePart): 21 | 22 | prop_year = datetime.now().year + 6 23 | _year_pattern = r'|'.join(str(i) for i in range(2000, prop_year)) 24 | category = 'year' 25 | 26 | template = SimpleTemplate( 27 | min_length=1, 28 | max_length=1, 29 | base_description=('2-digit year or 4-digit year, where a 2-digit ' 30 | 'year between 00 and this year + 1 is assumed to be ' 31 | 'a 21st century year, and 4-digit years between ' 32 | '1000 and this year + 5 are valid'), 33 | base_pattern=r'(?:1[0-9]{{3}}|{}|[0-9]{{2}})'.format(_year_pattern), 34 | post_pattern=r'(?![0-9])' 35 | ) 36 | 37 | @classmethod 38 | def string_to_value(self, cnstr): 39 | if len(cnstr) == 2: 40 | next_year = str(datetime.now().year + 1)[-2:] 41 | if int(cnstr) > int(next_year): 42 | value = int('19{}'.format(cnstr)) 43 | else: 44 | value = int('20{}'.format(cnstr)) 45 | else: 46 | value = int(cnstr) 47 | return value 48 | 49 | 50 | class AlphaMonth(AlphaDatePart): 51 | 52 | definition = 'an alphabetic representation of a month of the year' 53 | numeric_zfill = 2 54 | months = {'january': 1} 55 | category = 'month' 56 | 57 | template = SimpleTemplate( 58 | min_length=1, 59 | max_length=1, 60 | post_pattern=r'(?![A-Za-z])', 61 | post_description='anything but a letter', 62 | ) 63 | 64 | @classmethod 65 | def derive(cls, **attr): 66 | months = list(attr['months'].keys()) 67 | months.sort(key=lambda x: int(attr['months'][x])) 68 | base_pattern = '' 69 | for month in months: 70 | first_upper_lower = r'[{}{}]'.format(month[0].lower(), 71 | month[0].upper()) 72 | month_lower = r'{}{}'.format(first_upper_lower, 73 | month[1:].lower()) 74 | base_pattern = r'{}{}|{}|'.format(base_pattern, month_lower, 75 | month.upper()) 76 | base_pattern = r'(?:{})'.format(base_pattern.strip('|')) 77 | attr['base_pattern'] = base_pattern 78 | attr['short_description'] = ('one of the following strings -- {} -- ' 79 | 'which is all lower case, all upper ' 80 | 'case, or has just the first letter ' 81 | 'capitalized').format(', '.join(months)) 82 | return super(AlphaMonth, cls).derive(**attr) 83 | 84 | @property 85 | def value(self): 86 | return type(self).months[self._string.lower()] 87 | 88 | 89 | class Month(CompoundDatePart): 90 | 91 | short_description = 'a numeric or alphabetic month' 92 | category = 'month' 93 | 94 | Period = Formatting.derive( 95 | classname='Period', 96 | short_description='a period', 97 | min_length=1, 98 | max_length=1, 99 | base_pattern=r'\.' 100 | ) 101 | 102 | NumericMonth = NumericDatePart.derive( 103 | classname='NumericMonth', 104 | short_description='a numeric month, 1 to 12', 105 | min_length=1, 106 | max_length=1, 107 | base_pattern=r'(?:1[0-2]|0?[1-9])', 108 | post_pattern=r'(?![0-9])', 109 | category='month' 110 | ) 111 | 112 | AlphaMonthLong = AlphaMonth.derive( 113 | classname='AlphaMonthLong', 114 | months={ 115 | 'january': 1, 'february': 2, 'march': 3, 'april': 4, 'may': 5, 116 | 'june': 6, 'july': 7, 'august': 8, 'september': 9, 'october': 10, 117 | 'november': 11, 'december': 12, 'winter': 1, 'spring': 3, 118 | 'summer': 6, 'fall': 9 119 | }, 120 | category='month' 121 | ) 122 | 123 | AlphaMonthShort = AlphaMonth.derive( 124 | classname='AlphaMonthShort', 125 | months={ 126 | 'jan': 1, 'feb': 2, 'febr': 2, 'mar': 3, 'apr': 4, 'jun': 6, 127 | 'jul': 7, 'aug': 8, 'sep': 9, 'sept': 9, 'oct': 10, 'nov': 11, 128 | 'dec': 12, 'win': 1, 'wint': 1, 'spr': 3, 'sum': 6, 'summ': 6 129 | }, 130 | category='month' 131 | ) 132 | 133 | AbbreviatedMonth = CompoundDatePart.derive( 134 | classname='AbbreviatedMonth', 135 | short_description='a month abbreviation and optional period', 136 | separator_type=None, 137 | groups=[ 138 | {'min': 1, 'max': 1, 'name': 'alphamonth', 139 | 'type': AlphaMonthShort}, 140 | {'min': 0, 'max': 1, 'name': 'period', 'type': Period} 141 | ], 142 | category='month', 143 | value=property(lambda x: x.alphamonth.value) 144 | ) 145 | 146 | template = CompoundTemplate( 147 | groups=[ 148 | {'min': 1, 'max': 1, 'name': 'fullmonth', 149 | 'possible_types': [NumericMonth, AlphaMonthLong, 150 | AbbreviatedMonth]} 151 | ] 152 | ) 153 | 154 | @property 155 | def value(self): 156 | return self.fullmonth.value 157 | 158 | 159 | class Day(CompoundDatePart): 160 | 161 | short_description = ('cardinal or ordinal number, from 1 to 31, ' 162 | 'representing the day of the month') 163 | category = 'day' 164 | 165 | NumericDay = NumericDatePart.derive( 166 | classname='NumericDay', 167 | short_description='numeric day of the month, 1 to 31', 168 | min_length=1, 169 | max_length=1, 170 | base_pattern=r'(?:[1-2][0-9]|3[0-1]|0?[1-9])', 171 | category='day' 172 | ) 173 | 174 | template = CompoundTemplate( 175 | groups=[ 176 | {'min': 1, 'max': 1, 'name': 'wholenumber', 'type': NumericDay}, 177 | {'min': 0, 'max': 1, 'name': 'suffix', 178 | 'type': OrdinalNumber.OrdinalSuffix} 179 | ] 180 | ) 181 | 182 | @property 183 | def value(self): 184 | return self.wholenumber.value 185 | -------------------------------------------------------------------------------- /src/pycallnumber/units/numbers.py: -------------------------------------------------------------------------------- 1 | """Represent numeric parts of call numbers.""" 2 | 3 | 4 | from __future__ import unicode_literals 5 | from __future__ import absolute_import 6 | from builtins import str 7 | import decimal 8 | import math 9 | import copy 10 | 11 | from pycallnumber import settings 12 | from pycallnumber.exceptions import InvalidCallNumberStringError, SettingsError 13 | from pycallnumber.template import CompoundTemplate 14 | import pycallnumber.utils as u 15 | from pycallnumber.units.simple import Alphabetic, Numeric, Formatting 16 | from pycallnumber.units.compound import AlphaNumericSymbol 17 | 18 | 19 | ThreeDigits = Numeric.derive( 20 | classname='ThreeDigits', 21 | min_val=0, 22 | max_val=999, 23 | min_length=3, 24 | max_length=3 25 | ) 26 | 27 | UpToThreeDigits = Numeric.derive( 28 | classname='UpToThreeDigits', 29 | min_val=0, 30 | max_val=999, 31 | min_length=1, 32 | max_length=3 33 | ) 34 | 35 | UpToThreeDigits__1To999 = UpToThreeDigits.derive( 36 | min_val=1, 37 | max_val=999 38 | ) 39 | 40 | USGBThousandsSeparator = Formatting.derive( 41 | classname='USGBThousandsSeparator', 42 | short_description='a comma', 43 | min_length=1, 44 | max_length=1, 45 | base_pattern=r',', 46 | use_formatting_in_search=False, 47 | use_formatting_in_sort=False 48 | ) 49 | 50 | USGBDecimalSeparator = Formatting.derive( 51 | classname='USGBDecimalSeparator', 52 | short_description='a decimal point', 53 | min_length=1, 54 | max_length=1, 55 | base_pattern=r'\.', 56 | use_formatting_in_search=True, 57 | use_formatting_in_sort=True, 58 | ) 59 | 60 | Decimal = Numeric.derive( 61 | classname='Decimal', 62 | short_description=('a numeric string representing 1 or more decimal ' 63 | 'places'), 64 | min_val=0, 65 | max_val=.999999999, 66 | min_length=1, 67 | max_length=9 68 | ) 69 | 70 | 71 | class BaseCompoundNumber(AlphaNumericSymbol): 72 | 73 | max_numeric_zfill = settings.DEFAULT_MAX_NUMERIC_ZFILL 74 | min_val = 0 75 | max_val = int(math.pow(10, max_numeric_zfill) - 1) 76 | numeric_zfill = max_numeric_zfill 77 | min_interval = 1 78 | is_whole_number = True 79 | min_decimal_places = 0 80 | max_decimal_places = 0 81 | 82 | @classmethod 83 | def validate(cls, cnstr, instance_options=None): 84 | too_low, too_high = False, False 85 | try: 86 | cnval = cls.string_to_value(cnstr) 87 | too_low = cls.min_val is not None and cnval < cls.min_val 88 | too_high = cls.max_val is not None and cnval > cls.max_val 89 | except Exception: 90 | pass 91 | if too_low or too_high: 92 | min_max_text = u.min_max_to_text(cls.min_val, cls.max_val, 'less') 93 | msg = 'Value for {} must be {}'.format(cls.__name__, min_max_text) 94 | raise InvalidCallNumberStringError(msg) 95 | return super(BaseCompoundNumber, cls).validate(cnstr, instance_options) 96 | 97 | @classmethod 98 | def string_to_value(cls, cnstr): 99 | raise NotImplementedError() 100 | 101 | @classmethod 102 | def create_decimal(cls, value): 103 | min_interval = decimal.Decimal(str(cls.min_interval)) 104 | return decimal.Decimal(value).quantize(min_interval) 105 | 106 | @classmethod 107 | def describe_short(cls, include_pattern=False): 108 | min_max_text = u.min_max_to_text(cls.min_val, cls.max_val, 'less') 109 | text = super(BaseCompoundNumber, cls).describe_short(include_pattern) 110 | return '{}; has an overall value of {}'.format(text, min_max_text) 111 | 112 | @classmethod 113 | def describe_long(cls, include_pattern=False): 114 | min_max_text = u.min_max_to_text(cls.min_val, cls.max_val, 'less') 115 | text = super(BaseCompoundNumber, cls).describe_long(include_pattern) 116 | return '{}. It has an overall value of {}'.format(text, min_max_text) 117 | 118 | @classmethod 119 | def derive(cls, **attr): 120 | max_zfill = attr.get('max_numeric_zfill', cls.max_numeric_zfill) 121 | min_val = attr.get('min_val', cls.min_val) 122 | max_val = attr.get('max_val', cls.max_val) 123 | min_str, max_str = str(min_val), str(max_val) 124 | min_parts = min_str.split('.') if '.' in min_str else [min_str, '0'] 125 | max_parts = max_str.split('.') if '.' in max_str else [max_str, '0'] 126 | user_whole_number = attr.get('is_whole_number', False) 127 | user_min_decimal_places = attr.get('min_decimal_places', 0) 128 | user_max_decimal_places = attr.get('max_decimal_places', 0) 129 | 130 | if max_val is not None and min_val > max_val: 131 | raise SettingsError('``min_val`` cannot be > ``max_val``') 132 | if max_val is not None and max_val <= 0: 133 | raise SettingsError('``max_val`` cannot be <= 0') 134 | if min_val < 0: 135 | raise SettingsError('``min_val`` cannot be < 0') 136 | if user_min_decimal_places > user_max_decimal_places: 137 | msg = '``min_decimal_places`` cannot be > ``max_decimal_places``' 138 | raise SettingsError(msg) 139 | 140 | if '.' in '{}{}'.format(min_val, max_val): 141 | if user_whole_number: 142 | msg = ('``min_val`` and ``max_val`` must be integers if ' 143 | '``is_whole_number`` is True') 144 | raise SettingsError(msg) 145 | if len(min_parts[1]) > len(max_parts[1]): 146 | max_decimal_places = len(min_parts[1]) 147 | else: 148 | max_decimal_places = len(max_parts[1]) 149 | if user_max_decimal_places > max_decimal_places: 150 | max_decimal_places = user_max_decimal_places 151 | min_interval = math.pow(10, -max_decimal_places) 152 | attr['is_whole_number'] = False 153 | attr['min_interval'] = min_interval 154 | attr['max_decimal_places'] = max_decimal_places 155 | else: 156 | attr['is_whole_number'] = attr.get('is_whole_number', True) 157 | attr['min_interval'] = attr.get('min_interval', 1) 158 | attr['min_decimal_places'] = attr.get('min_decimal_places', 0) 159 | attr['max_decimal_places'] = attr.get('max_decimal_places', 0) 160 | 161 | if max_val is None or max_val >= 1: 162 | real_max_val = int(math.pow(10, max_zfill) - 1) 163 | if max_val is None or max_val > real_max_val: 164 | attr['max_val'] = real_max_val 165 | attr['numeric_zfill'] = len(str(real_max_val)) 166 | else: 167 | attr['numeric_zfill'] = len(max_parts[0]) 168 | elif max_val > 0: 169 | attr['numeric_zfill'] = 0 170 | 171 | if 'classname' not in attr: 172 | min_val = attr.get('min_val', cls.min_val) 173 | max_val = attr.get('max_val', cls.max_val) 174 | classname = '{}__{}To{}'.format(cls.__name__, min_val, max_val) 175 | attr['classname'] = classname 176 | return super(BaseCompoundNumber, cls).derive(**attr) 177 | 178 | @property 179 | def value(self): 180 | return type(self).string_to_value(self._string) 181 | 182 | 183 | class WholeNumUSGB1000sSep(BaseCompoundNumber): 184 | 185 | min_val = 1000 186 | template = CompoundTemplate( 187 | short_description=('a string representing a whole number that is ' 188 | '>=1000 and uses a comma as the thousands ' 189 | 'separator'), 190 | groups=[ 191 | {'name': 'thousand1', 'min': 1, 'max': 1, 192 | 'type': UpToThreeDigits__1To999}, 193 | {'name': 'thousand_sep1', 'min': 1, 'max': 1, 194 | 'type': USGBThousandsSeparator, 'is_separator': True}, 195 | {'name': 'last_groups', 'min': 1, 'max': None, 'type': ThreeDigits, 196 | 'inner_sep_type': USGBThousandsSeparator} 197 | ] 198 | ) 199 | 200 | @classmethod 201 | def string_to_value(cls, cnstr): 202 | return int(''.join(cnstr.split(','))) 203 | 204 | def for_sort(self): 205 | return str(int(self.value)).zfill(self.numeric_zfill) 206 | 207 | 208 | class Number(BaseCompoundNumber): 209 | 210 | definition = ('a non-negative integer or floating point number, formatted ' 211 | 'based on US or British conventions: a period is used as a ' 212 | 'decimal point, if needed, and commas may be used as ' 213 | 'thousands separators (but are optional)') 214 | 215 | max_numeric_zfill = settings.DEFAULT_MAX_NUMERIC_ZFILL 216 | min_val = 0 217 | max_val = float(math.pow(10, max_numeric_zfill) - 1) + .999999999 218 | min_interval = .000000001 219 | is_whole_number = False 220 | min_decimal_places = 0 221 | max_decimal_places = 9 222 | 223 | template = CompoundTemplate( 224 | separator_type=USGBDecimalSeparator, 225 | groups=[ 226 | {'name': 'wholenumber', 'min': 1, 'max': 1, 227 | 'possible_types': [WholeNumUSGB1000sSep, Numeric]}, 228 | {'name': 'decimal', 'min': 0, 'max': 1, 'type': Decimal} 229 | ] 230 | ) 231 | 232 | @classmethod 233 | def string_to_value(cls, cnstr): 234 | val = float(''.join(cnstr.split(','))) 235 | if val.is_integer(): 236 | return int(val) 237 | return cls.create_decimal(val) 238 | 239 | @classmethod 240 | def derive(cls, **attr): 241 | thousands = attr.pop('thousands', 'optional') 242 | min_dec_places = attr.pop('min_decimal_places', 0) 243 | max_dec_places = attr.pop('max_decimal_places', 9) 244 | mn, mx = str(attr.get('min_val', 0)), str(attr.get('max_val', None)) 245 | min_val, min_dec = mn.split('.') if '.' in mn else (mn, '0') 246 | max_val, max_dec = mx.split('.') if '.' in mx else (mx, 'None') 247 | min_val, min_dec = int(min_val), int(min_dec) 248 | max_val = None if max_val == 'None' else int(max_val) 249 | max_dec = None if max_dec == 'None' else int(max_dec) 250 | separator_type = USGBDecimalSeparator 251 | groups = copy.deepcopy(cls.template.groups) 252 | 253 | types = [] 254 | if thousands in ('required', 'optional'): 255 | if max_val is None and min_val < 1000: 256 | types.append(WholeNumUSGB1000sSep) 257 | elif max_val > 999 and min_val < 1000: 258 | types.append(WholeNumUSGB1000sSep.derive(max_val=max_val)) 259 | elif max_val > 999 and min_val > 999: 260 | types.append(WholeNumUSGB1000sSep.derive(max_val=max_val, 261 | min_val=min_val)) 262 | if min_val == 0: 263 | types.append(UpToThreeDigits) 264 | elif min_val < 1000 and (max_val is None or max_val > 999): 265 | types.append(UpToThreeDigits.derive(min_val=min_val)) 266 | elif min_val < 1000 and max_val < 1000: 267 | types.append(UpToThreeDigits.derive(min_val=min_val, 268 | max_val=max_val)) 269 | if thousands in (None, 'optional'): 270 | types.append(Numeric.derive(min_val=min_val, max_val=max_val)) 271 | 272 | groups[0] = groups[0] = {'name': 'wholenumber', 'min': 1, 'max': 1, 273 | 'possible_types': types} 274 | 275 | if max_dec_places == 0: 276 | separator_type = None 277 | del groups[1] 278 | else: 279 | attr['max_decimal_places'] = max_dec_places 280 | min_max_text = u.min_max_to_text(min_dec_places or 1, 281 | max_dec_places) 282 | min_val = 0 if min_val == 0 else float('.{}'.format(min_dec)) 283 | max_val = None if max_dec is None else float('.{}'.format(max_dec)) 284 | NewDecimal = Decimal.derive( 285 | short_description=('a numeric string representing {} decimal ' 286 | 'places'.format(min_max_text)), 287 | min_length=min_dec_places or 1, 288 | max_length=max_dec_places, 289 | min_val=min_val, 290 | max_val=max_val 291 | ) 292 | group_min = 0 if min_dec_places == 0 else 1 293 | groups[1] = {'name': 'decimal', 'min': group_min, 'max': 1, 294 | 'type': NewDecimal} 295 | 296 | attr['separator_type'] = separator_type 297 | attr['groups'] = groups 298 | return super(Number, cls).derive(**attr) 299 | 300 | def for_sort(self): 301 | sortval = super(Number, self).for_sort() 302 | if '.' in sortval: 303 | (whole, dec) = sortval.split('.') 304 | if int(dec) == 0: 305 | return whole 306 | return sortval 307 | 308 | 309 | class OrdinalNumber(BaseCompoundNumber): 310 | 311 | definition = 'an ordinal number (1st, 2nd, 3rd, 4th ... 1,000th, etc.)' 312 | min_val = 1 313 | max_decimal_places = 0 314 | 315 | WholeNumber = Number.derive( 316 | classname='WholeNumber', 317 | definition=('a non-negative whole number, formatted based on US or ' 318 | 'British conventions, with a comma as an optional ' 319 | 'thousands separator'), 320 | max_decimal_places=0, 321 | min_val=1, 322 | ) 323 | 324 | OrdinalSuffix = Alphabetic.derive( 325 | classname='OrdinalSuffix', 326 | short_description=('a 2-character string: \'st\', \'nd\', \'rd\', or ' 327 | '\'th\''), 328 | min_length=1, 329 | max_length=1, 330 | base_pattern=r'(?:[sS][tT]|[nNrR][dD]|[tT][hH])', 331 | for_sort=lambda x: '', 332 | ) 333 | 334 | template = CompoundTemplate( 335 | separator_type=None, 336 | groups=[ 337 | {'min': 1, 'max': 1, 'name': 'wholenumber', 'type': WholeNumber}, 338 | {'min': 1, 'max': 1, 'name': 'suffix', 'type': OrdinalSuffix} 339 | ] 340 | ) 341 | -------------------------------------------------------------------------------- /src/pycallnumber/units/simple.py: -------------------------------------------------------------------------------- 1 | """Represent the most basic types of call number parts.""" 2 | 3 | from __future__ import unicode_literals 4 | from builtins import str 5 | import decimal 6 | import math 7 | 8 | from pycallnumber import settings 9 | from pycallnumber.template import SimpleTemplate 10 | from pycallnumber.unit import SimpleUnit 11 | from pycallnumber.exceptions import InvalidCallNumberStringError, SettingsError 12 | from pycallnumber import utils as u 13 | 14 | 15 | class Alphabetic(SimpleUnit): 16 | 17 | options_defaults = SimpleUnit.options_defaults.copy() 18 | options_defaults.update({ 19 | 'display_case': settings.DEFAULT_DISPLAY_CASE, 20 | 'search_case': settings.DEFAULT_SEARCH_CASE, 21 | 'sort_case': settings.DEFAULT_SORT_CASE 22 | }) 23 | is_alphabetic = True 24 | template = SimpleTemplate( 25 | min_length=1, 26 | max_length=None, 27 | base_pattern=r'[A-Za-z]', 28 | base_description='letter' 29 | ) 30 | 31 | def _get_cased_string(self, case): 32 | if case == 'lower': 33 | return self._string.lower() 34 | elif case == 'upper': 35 | return self._string.upper() 36 | else: 37 | return self._string 38 | 39 | def for_sort(self): 40 | if self._string == '': 41 | return ' ' 42 | else: 43 | return self._get_cased_string(self.sort_case) 44 | 45 | def for_search(self): 46 | return self._get_cased_string(self.search_case) 47 | 48 | def for_print(self): 49 | return self._get_cased_string(self.display_case) 50 | 51 | 52 | class Numeric(SimpleUnit): 53 | 54 | options_defaults = SimpleUnit.options_defaults.copy() 55 | max_numeric_zfill = settings.DEFAULT_MAX_NUMERIC_ZFILL 56 | numeric_zfill = max_numeric_zfill 57 | min_val = 0 58 | max_val = int(math.pow(10, max_numeric_zfill) - 1) 59 | is_numeric = True 60 | min_interval = 1 61 | template = SimpleTemplate( 62 | min_length=1, 63 | max_length=None, 64 | base_pattern=r'\d', 65 | base_description='digit' 66 | ) 67 | 68 | @classmethod 69 | def validate(cls, cnstr, instance_options=None): 70 | validate_result = super(Numeric, cls).validate(cnstr, instance_options) 71 | cnval = cls.string_to_value(cnstr) 72 | too_low = cls.min_val is not None and cnval < cls.min_val 73 | too_high = cls.max_val is not None and cnval > cls.max_val 74 | if too_low or too_high: 75 | min_max_text = u.min_max_to_text(cls.min_val, cls.max_val, 'less') 76 | msg = 'Value for {} must be {}'.format(cls.__name__, min_max_text) 77 | raise InvalidCallNumberStringError(msg) 78 | return validate_result 79 | 80 | @classmethod 81 | def string_to_value(cls, cnstr): 82 | if cls.max_val < 1: 83 | cnstr = '.{}'.format(cnstr) 84 | return cls.create_decimal(cnstr) 85 | return int(cnstr) 86 | 87 | @classmethod 88 | def create_decimal(cls, value): 89 | min_interval = decimal.Decimal(str(cls.min_interval)) 90 | return decimal.Decimal(value).quantize(min_interval) 91 | 92 | @classmethod 93 | def describe_short(cls, include_pattern=False): 94 | min_max_text = u.min_max_to_text(cls.min_val, cls.max_val, 'less') 95 | text = super(Numeric, cls).describe_short(include_pattern) 96 | return '{} that has a value of {}'.format(text, min_max_text) 97 | 98 | @classmethod 99 | def describe_long(cls, include_pattern=False): 100 | min_max_text = u.min_max_to_text(cls.min_val, cls.max_val, 'less') 101 | text = super(Numeric, cls).describe_long(include_pattern) 102 | return '{} that has a value of {}'.format(text, min_max_text) 103 | 104 | @classmethod 105 | def derive(cls, **attr): 106 | max_zfill = attr.get('max_numeric_zfill', cls.max_numeric_zfill) 107 | min_val = attr.get('min_val', cls.min_val) 108 | max_val = attr.get('max_val', cls.max_val) 109 | min_length = attr.get('min_length', cls.template.min_length) 110 | if max_val is not None and min_val > max_val: 111 | raise SettingsError('``min_val`` cannot be > ``max_val``') 112 | if max_val is not None and max_val <= 0: 113 | raise SettingsError('``max_val`` cannot be <= 0') 114 | if min_val < 0: 115 | raise SettingsError('``min_val`` cannot be < 0') 116 | if max_val is None or max_val >= 1: 117 | attr['min_interval'] = 1 118 | real_max_val = int(math.pow(10, max_zfill) - 1) 119 | if max_val is None or max_val > real_max_val: 120 | attr['numeric_zfill'] = max_zfill 121 | attr['max_val'] = real_max_val 122 | else: 123 | attr['numeric_zfill'] = len(str(max_val)) 124 | elif max_val > 0: 125 | attr['numeric_zfill'] = 0 126 | attr['min_decimal_places'] = min_length 127 | attr['max_decimal_places'] = len(str(max_val)) - 2 128 | attr['min_interval'] = math.pow(10, -attr['max_decimal_places']) 129 | if 'classname' not in attr: 130 | classname = '{}__{}To{}'.format(cls.__name__, min_val, max_val) 131 | attr['classname'] = classname 132 | return super(Numeric, cls).derive(**attr) 133 | 134 | @property 135 | def value(self): 136 | return type(self).string_to_value(self._string) 137 | 138 | def for_sort(self): 139 | return self._string.zfill(self.numeric_zfill) 140 | 141 | 142 | class Formatting(SimpleUnit): 143 | 144 | options_defaults = SimpleUnit.options_defaults.copy() 145 | options_defaults.update({ 146 | 'use_formatting_in_search': settings.DEFAULT_USE_FORMATTING_IN_SEARCH, 147 | 'use_formatting_in_sort': settings.DEFAULT_USE_FORMATTING_IN_SORT 148 | }) 149 | is_formatting = True 150 | template = SimpleTemplate( 151 | min_length=1, 152 | max_length=None, 153 | base_pattern=r'[^A-Za-z0-9]', 154 | base_description='non-alphanumeric symbol' 155 | ) 156 | 157 | def for_sort(self): 158 | return self._string if self.use_formatting_in_sort else '' 159 | 160 | def for_search(self): 161 | return self._string if self.use_formatting_in_search else '' 162 | 163 | 164 | DEFAULT_SEPARATOR_TYPE = Formatting.derive( 165 | classname='DEFAULT_SEPARATOR_TYPE', 166 | min_length=0, 167 | max_length=None, 168 | base_pattern=r'\s', 169 | short_description='optional whitespace' 170 | ) 171 | -------------------------------------------------------------------------------- /src/pycallnumber/utils.py: -------------------------------------------------------------------------------- 1 | """Miscellaneous utility functions and classes.""" 2 | from __future__ import unicode_literals 3 | from __future__ import absolute_import 4 | 5 | from builtins import str 6 | from builtins import range 7 | from builtins import object 8 | import functools 9 | import inspect 10 | import re 11 | import struct 12 | import importlib 13 | import types 14 | 15 | from pycallnumber.exceptions import InvalidCallNumberStringError 16 | 17 | 18 | def memoize(function): 19 | """Decorate a function/method so it caches its return value. 20 | 21 | If the function is called as a method, then the cache is attached 22 | to the object used as the first arg passed to the method (e.g., 23 | ``self`` or ``cls``) and can be accessed directly via a ``_cache`` 24 | attribute on the object. 25 | 26 | If the function passed does not have a first argument named 27 | ``self`` or ``cls``, then it's assumed that it's not a method, and 28 | the cache is attached to the function itself via a ``_cache`` 29 | attribute. 30 | 31 | Cache keys are calculated based on the argument values--both 32 | positional and keyword. In cases where a function uses default 33 | values for kwargs, the key will be the same no matter whether the 34 | call to the method includes the kwargs or relies on the default 35 | values. Order of args in the key will follow the order in the 36 | function's signature, even if kwargs are called out of order. 37 | """ 38 | try: 39 | sig = inspect.signature(function) 40 | except AttributeError: 41 | argnames, _, _, _ = inspect.getargspec(function) 42 | else: 43 | argnames = [arg for arg in sig.parameters.keys()] 44 | 45 | if len(argnames) > 0 and argnames[0] in ('self', 'cls'): 46 | function_is_method = True 47 | argnames = argnames[1:] 48 | else: 49 | function_is_method = False 50 | function._cache = {} 51 | 52 | def generate_key(base, args, kwargs): 53 | argsmap = inspect.getcallargs(function, *args, **kwargs) 54 | argvals_as_strings = [str(argsmap[argname]) for argname in argnames] 55 | argstr = '_{}'.format('_'.join(argvals_as_strings)) if argnames else '' 56 | return '{}{}'.format(base, argstr) 57 | 58 | @functools.wraps(function) 59 | def wrapper(*args, **kwargs): 60 | if function_is_method: 61 | obj = args[0] 62 | else: 63 | obj = function 64 | cache = getattr(obj, '_cache', {}) 65 | key = generate_key(function.__name__, args, kwargs) 66 | if key not in cache: 67 | cache[key] = function(*args, **kwargs) 68 | obj._cache = cache 69 | return obj._cache[key] 70 | 71 | return wrapper 72 | 73 | 74 | def min_max_to_pattern(min_, max_): 75 | if min_ == 0 and max_ is None: 76 | return '*' 77 | if min_ == 0 and max_ == 1: 78 | return '?' 79 | if min_ == 1 and max_ is None: 80 | return '+' 81 | if min_ == 1 and max_ == 1: 82 | return '' 83 | if min_ > 1 and max_ is None: 84 | return '{{{},}}'.format(min_) 85 | if min_ == max_: 86 | return '{{{}}}'.format(min_) 87 | return '{{{},{}}}'.format(min_, max_) 88 | 89 | 90 | def min_max_to_text(min_, max_, lower_word='fewer'): 91 | if min_ is None and max_ is None: 92 | return 'any number' 93 | if max_ is None: 94 | return '{} or more'.format(min_) 95 | if min_ is None: 96 | return '{} or {}'.format(max_, lower_word) 97 | if min_ == max_: 98 | return '{}'.format(min_) 99 | return '{} to {}'.format(min_, max_) 100 | 101 | 102 | def list_to_text(list_, conjunction='or'): 103 | if len(list_) == 1: 104 | return list_[0] 105 | if len(list_) == 2: 106 | return '{} {} {}'.format(list_[0], conjunction, list_[1]) 107 | return '{}, {} {}'.format(', '.join(list_[0:-1]), conjunction, list_[-1]) 108 | 109 | 110 | def convert_re_groups_to_noncapturing(re_str): 111 | """Convert groups in a re string to noncapturing. 112 | 113 | Adds ?: to capturing groups after the opening parentheses to 114 | convert them to noncapturing groups and returns the resulting re 115 | string. 116 | 117 | """ 118 | return re.sub(r'(?{})'.format(label, pattern) 123 | 124 | 125 | def load_class(class_string): 126 | split = class_string.split('.') 127 | module, class_ = '.'.join(split[0:-1]), split[-1] 128 | return getattr(importlib.import_module(module), class_) 129 | 130 | 131 | def create_unit(cnstr, possible_types, useropts, name='', is_separator=False): 132 | useropts = useropts or {} 133 | for t in possible_types: 134 | opts = t.filter_valid_useropts(useropts) 135 | opts['is_separator'] = is_separator 136 | try: 137 | unit = t(cnstr, name=name, **opts) 138 | except InvalidCallNumberStringError: 139 | pass 140 | else: 141 | return unit 142 | return None 143 | 144 | 145 | def get_terminal_size(default_width=100, default_height=50): 146 | try: 147 | terminal_size = _get_terminal_size_unixlike() 148 | except (IOError, ImportError): 149 | try: 150 | terminal_size = _get_terminal_size_windows() 151 | except (IOError, ImportError): 152 | terminal_size = default_width, default_height 153 | return terminal_size 154 | 155 | 156 | def _get_terminal_size_unixlike(): 157 | import fcntl 158 | import termios 159 | # this try/except block detects and works around a Py2.7.5 bug with 160 | # passing a unicode value as the first arg to struct methods 161 | try: 162 | struct.pack('HH', 0, 0) 163 | except TypeError: 164 | fmt = types.StringType('HHHH') 165 | else: 166 | fmt = 'HHHH' 167 | 168 | winsize_struct = struct.pack(fmt, 0, 0, 0, 0) 169 | packed_winsize = fcntl.ioctl(0, termios.TIOCGWINSZ, winsize_struct) 170 | height, width, _, _ = struct.unpack(fmt, packed_winsize) 171 | return width, height 172 | 173 | 174 | def _get_terminal_size_windows(): 175 | from ctypes import windll, create_string_buffer 176 | # stdin handle is -10 177 | # stdout handle is -11 178 | # stderr handle is -12 179 | handle = windll.kernel32.GetStdHandle(-12) 180 | csbi = create_string_buffer(22) 181 | res = windll.kernel32.GetConsoleScreenBufferInfo(handle, csbi) 182 | # Windows and Unix-like systems have different ways of going about finding 183 | # terminal size. Throw an IOError on failure to stay similar to the 184 | # Unix-like API (ioctl throws IOError on failure). 185 | if res: 186 | (_, _, _, _, _, 187 | left, top, right, bottom, 188 | _, _) = struct.unpack("hhhhHhhhhhh", csbi.raw) 189 | width = right - left + 1 190 | height = bottom - top + 1 191 | return width, height 192 | raise IOError 193 | 194 | 195 | def _pretty_paragraph(in_str, adjusted_line_width, indent): 196 | out_paragraph, i = '', 0 197 | while i < len(in_str): 198 | next_i = i + adjusted_line_width 199 | words = in_str[i:next_i].split(' ') 200 | next_char = in_str[next_i] if next_i < len(in_str) else '' 201 | if next_char == ' ' or next_char == '': 202 | next_i += 1 203 | elif words[-1] and len(words) > 1: 204 | next_i -= len(words[-1]) 205 | line = in_str[i:next_i].rstrip() 206 | lbreak = '\n' if i > 0 else '' 207 | out_paragraph = '{}{}{}{}'.format(out_paragraph, lbreak, indent, line) 208 | i = next_i 209 | return out_paragraph 210 | 211 | 212 | def pretty(in_data, max_line_width=None, indent_level=0, 213 | tab_width=4): 214 | if max_line_width is None: 215 | max_line_width = get_terminal_size()[0] 216 | in_str = str(in_data) 217 | indent_length = tab_width * indent_level 218 | indent = ''.join(' ' for _ in range(0, indent_length)) 219 | adjusted_line_width = max_line_width - indent_length 220 | if adjusted_line_width <= 0: 221 | adjusted_line_width = 20 222 | blocks = in_str.splitlines() 223 | out = [_pretty_paragraph(b, adjusted_line_width, indent) for b in blocks] 224 | return '\n'.join(out) 225 | 226 | 227 | class ComparableObjectMixin(object): 228 | 229 | def _compare(self, other, op, compare): 230 | try: 231 | return compare(self.cmp_key(other, op), self._get_other(other, op)) 232 | except (TypeError, AttributeError): 233 | return NotImplemented 234 | 235 | def _get_other(self, other, op): 236 | return other.cmp_key(self, op) 237 | 238 | def __eq__(self, other): 239 | return self._compare(other, 'eq', lambda s, o: s == o) 240 | 241 | def __ne__(self, other): 242 | return self._compare(other, 'ne', lambda s, o: s != o) 243 | 244 | def __gt__(self, other): 245 | return self._compare(other, 'gt', lambda s, o: s > o) 246 | 247 | def __ge__(self, other): 248 | return self._compare(other, 'ge', lambda s, o: s >= o) 249 | 250 | def __lt__(self, other): 251 | return self._compare(other, 'lt', lambda s, o: s < o) 252 | 253 | def __le__(self, other): 254 | return self._compare(other, 'le', lambda s, o: s <= o) 255 | 256 | def __hash__(self): 257 | return super(ComparableObjectMixin, self).__hash__() 258 | 259 | def cmp_key(self, other, op): 260 | return str(self) 261 | 262 | 263 | class Infinity(ComparableObjectMixin, object): 264 | 265 | def __init__(self): 266 | self.sign = 'pos' 267 | 268 | def __repr__(self): 269 | return '<{} infinity>'.format(self.sign) 270 | 271 | def __neg__(self): 272 | ret = Infinity() 273 | ret.sign = 'neg' if self.sign == 'pos' else 'pos' 274 | return ret 275 | 276 | def _get_other(self, other, op): 277 | str_other = '{}'.format(other) 278 | return getattr(other, 'cmp_key', lambda ot, op: str_other)(self, op) 279 | 280 | def cmp_key(self, other, op): 281 | """ 282 | Return a string key to use for comparisons. 283 | 284 | Unless you're comparing two Infinity objects to each other, 285 | positive Infinity should be greater than any other string, and 286 | negative Infinity should be less than any other string. If pos, 287 | it takes the other key and adds a space to the end, ensuring 288 | that this cmp_key will always be larger; if neg, it returns an 289 | empty string. 290 | """ 291 | if isinstance(other, Infinity): 292 | return str(self) 293 | if self.sign == 'pos': 294 | okey = self._get_other(other, op) 295 | selfkey = '{} '.format(okey) 296 | return selfkey 297 | return '' 298 | -------------------------------------------------------------------------------- /tests/helpers.py: -------------------------------------------------------------------------------- 1 | """Contains utility functions used for test code.""" 2 | 3 | from __future__ import unicode_literals 4 | 5 | from builtins import range 6 | import pytest 7 | 8 | 9 | DEFAULT = object() 10 | 11 | 12 | def make_obj_factory(object_type, num_pos_args=0, kwarg_list=None): 13 | def factory(*args): 14 | obj_args, obj_kwargs = [], {} 15 | for i in range(0, num_pos_args): 16 | obj_args.append(args[i]) 17 | for i, kwarg_name in enumerate(kwarg_list): 18 | if args[i+num_pos_args] != DEFAULT: 19 | obj_kwargs[kwarg_name] = args[i+num_pos_args] 20 | return object_type(*obj_args, **obj_kwargs) 21 | return factory 22 | 23 | 24 | def flatten_parameters(params_dict): 25 | """ 26 | Assuming a dictionary containing test parameters is structured like 27 | this: 28 | 29 | { 30 | 'lc': [ 31 | ('A', 'AA'), 32 | ('AA', 'AB'), 33 | ], 34 | 'sudoc': [ 35 | ('A:', 'AA:'), 36 | ('AA:', 'AB:'), 37 | ] 38 | } 39 | 40 | or this: 41 | 42 | { 43 | 'lc': ['A', 'B', 'C'], 44 | 'sudoc': ['A', 'B', 'C'] 45 | } 46 | 47 | This flattens the dictionary into a list of tuples, suitable for 48 | passing into a test function parametrized with 49 | @pytest.mark.parametrize, like this: 50 | 51 | [ 52 | ('lc', 'A', 'AA'), 53 | ('lc', 'AA', 'AB'), 54 | ('sudoc', 'A:', 'AA:'), 55 | ('sudoc', 'AA:', 'AB:') 56 | ] 57 | 58 | or this: 59 | 60 | [ 61 | ('lc', 'A'), 62 | ('lc', 'B'), 63 | ('lc', 'C'), 64 | ('sudoc', 'A'), 65 | ('sudoc', 'B'), 66 | ('sudoc', 'C') 67 | ] 68 | 69 | Dictionary keys always become the first member of each tuple. 70 | """ 71 | flattened = [] 72 | for kind, params in params_dict.items(): 73 | for values in params: 74 | if not isinstance(values, (tuple)): 75 | values = (values,) 76 | flattened.extend([(kind,) + values]) 77 | return flattened 78 | 79 | 80 | def generate_params(data, param_type): 81 | flattened = [] 82 | for kind, param_sets in data.items(): 83 | markers = [getattr(pytest.mark, m) 84 | for m in param_sets.get('markers', [])] 85 | markers.append(getattr(pytest.mark, param_type)) 86 | param_set = param_sets.get(param_type, []) 87 | for values in param_set: 88 | if not isinstance(values, tuple): 89 | values = (values,) 90 | inner_params_list = (kind,) + values 91 | flattened.append(pytest.param(*inner_params_list, marks=markers)) 92 | return flattened 93 | 94 | 95 | def mark_params(param_sets, markers): 96 | try: 97 | return [pytest.param(*p.values, marks=markers) for p in param_sets] 98 | except AttributeError: 99 | return [pytest.param(*p, marks=markers) for p in param_sets] 100 | -------------------------------------------------------------------------------- /tests/test_factories.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from pycallnumber import unit as un 4 | from pycallnumber import units as uns 5 | from pycallnumber import template as t 6 | from pycallnumber import exceptions as e 7 | from pycallnumber import set as s 8 | from pycallnumber import factories as f 9 | 10 | 11 | # Fixtures, factories, and test data 12 | 13 | class FactoryTestType(un.CompoundUnit): 14 | 15 | options_defaults = uns.AlphaNumeric.options_defaults.copy() 16 | options_defaults.update({ 17 | 'test_option': True, 18 | }) 19 | 20 | template = t.CompoundTemplate( 21 | separator_type=uns.simple.DEFAULT_SEPARATOR_TYPE, 22 | groups=[ 23 | {'name': 'letters', 'type': uns.Alphabetic, 'min': 1, 'max': 1}, 24 | {'name': 'numbers', 'type': uns.Numeric, 'min': 1, 'max': 1}, 25 | ] 26 | ) 27 | 28 | 29 | class AnotherFactoryTestType(un.CompoundUnit): 30 | 31 | template = t.CompoundTemplate( 32 | separator_type=uns.simple.DEFAULT_SEPARATOR_TYPE, 33 | groups=[ 34 | {'name': 'numbers', 'type': uns.Numeric, 'min': 1, 'max': 1}, 35 | {'name': 'letters', 'type': uns.Alphabetic, 'min': 1, 'max': 1}, 36 | ] 37 | ) 38 | 39 | 40 | class FactoryTestRangeSetType(s.RangeSet): 41 | pass 42 | 43 | 44 | aa0 = FactoryTestType('AA 0') 45 | aa50 = FactoryTestType('AA 50') 46 | aa100 = FactoryTestType('AA 100') 47 | aa1000 = FactoryTestType('AA 1000') 48 | ab0 = FactoryTestType('AB 0') 49 | _10a = AnotherFactoryTestType('10 A') 50 | _100a = AnotherFactoryTestType('100 A') 51 | 52 | 53 | # Tests 54 | 55 | def test_star_imports(): 56 | """Star imports should work without raising errors.""" 57 | import pycallnumber 58 | all_imports = __import__('pycallnumber', globals(), locals(), ['*']) 59 | assert all_imports.callnumber 60 | assert len(all_imports.__all__) == len(pycallnumber.__all__) 61 | 62 | 63 | @pytest.mark.callnumber_factory 64 | def test_callnumber_selects_correct_type_using_unittype_kwarg(): 65 | """Calls to the ``callnumber`` factory should return the correct 66 | Unit type when a specific type list is provided via the 67 | ``unittypes`` kwarg. 68 | """ 69 | types = [FactoryTestType, AnotherFactoryTestType] 70 | assert f.callnumber('AA 0', unittypes=types) == FactoryTestType('AA 0') 71 | 72 | 73 | @pytest.mark.callnumber_factory 74 | def test_callnumber_raises_error_using_unittype_kwarg(): 75 | """Calls to the ``callnumber`` factory should raise an 76 | InvalidCallNumberStringError when the given call number string does 77 | not match any of the Unit types given, when a specific type list is 78 | provided via the ``unittypes`` kwarg. 79 | """ 80 | types = [FactoryTestType, AnotherFactoryTestType] 81 | with pytest.raises(e.InvalidCallNumberStringError): 82 | f.callnumber('AA 0 AA 0', unittypes=types) 83 | 84 | 85 | @pytest.mark.callnumber_factory 86 | def test_callnumber_sets_correct_options_using_useropts_kwarg(): 87 | """Calls to the ``callnumber`` factory should utilize a set of 88 | custom options when one is provided via the ``useropts`` kwarg. 89 | """ 90 | opts = {'test_option': False} 91 | types = [FactoryTestType] 92 | default = f.callnumber('AA 0', unittypes=types) 93 | custom = f.callnumber('AA 0', useropts=opts, unittypes=types) 94 | assert default.test_option is True and custom.test_option is False 95 | 96 | 97 | @pytest.mark.callnumber_factory 98 | def test_callnumber_sets_unitname_using_name_kwarg(): 99 | """Calls to the ``callnumber`` factory should generate a Unit 100 | object with a ``name`` attribute matching the value passed to the 101 | ``name`` kwarg. 102 | """ 103 | types = [FactoryTestType] 104 | custom = f.callnumber('AA 0', name='test_unit', unittypes=types) 105 | assert custom.name == 'test_unit' 106 | 107 | 108 | @pytest.mark.cnrange_factory 109 | @pytest.mark.parametrize('start, end, expected', [ 110 | ('AA 0', 'AA 100', s.RangeSet((aa0, aa100))), 111 | ('AA 0', aa100, s.RangeSet((aa0, aa100))), 112 | (aa0, 'AA 100', s.RangeSet((aa0, aa100))), 113 | (aa0, aa100, s.RangeSet((aa0, aa100))), 114 | ('10 A', 'AA 0', e.BadRange), 115 | ]) 116 | def test_cnrange_returns_object_or_raises_error(start, end, expected): 117 | """Calls to the ``cnrange`` factory should successfully create the 118 | expected RangeSet object or raise the expected error, based on the 119 | provided start and end values. 120 | """ 121 | types = [FactoryTestType, AnotherFactoryTestType] 122 | if isinstance(expected, type) and issubclass(expected, Exception): 123 | with pytest.raises(expected): 124 | f.cnrange(start, end, unittypes=types) 125 | else: 126 | assert f.cnrange(start, end, unittypes=types) == expected 127 | 128 | 129 | @pytest.mark.cnrange_factory 130 | def test_cnrange_returns_correct_type_using_rangesettype_kwarg(): 131 | """Calls to the ``cnrange`` factory that provide a custom RangeSet 132 | type via the ``rangesettype`` kwarg should return the correct type 133 | of object. 134 | """ 135 | types = [FactoryTestType] 136 | rng = f.cnrange('AA 0', 'AA 100', unittypes=types, 137 | rangesettype=FactoryTestRangeSetType) 138 | assert isinstance(rng, FactoryTestRangeSetType) 139 | 140 | 141 | @pytest.mark.cnset_factory 142 | @pytest.mark.parametrize('ranges, expected', [ 143 | ((('AA 0', 'AA 50'), ('AA 100', 'AB 0')), 144 | s.RangeSet((aa0, aa50), (aa100, ab0))), 145 | ((('AA 0', 'AA 100'), ('AA 100', 'AB 0')), 146 | s.RangeSet((aa0, ab0))), 147 | ((s.RangeSet((aa0, aa50)), ('AA 100', 'AB 0')), 148 | s.RangeSet((aa0, aa50), (aa100, ab0))), 149 | ((('AA 0', 'AA 50'), s.RangeSet((aa100, ab0))), 150 | s.RangeSet((aa0, aa50), (aa100, ab0))), 151 | ((('AA 0', 'AA 50'), ('10 A', '100 A')), TypeError), 152 | ]) 153 | def test_cnset_returns_object_or_raises_error(ranges, expected): 154 | """Calls to the ``cnset`` factory should successfully create the 155 | expected RangeSet object or raise the expected error, based on the 156 | provided values for ranges. 157 | """ 158 | types = [FactoryTestType, AnotherFactoryTestType] 159 | if isinstance(expected, type) and issubclass(expected, Exception): 160 | with pytest.raises(expected): 161 | f.cnset(ranges, unittypes=types) 162 | else: 163 | assert f.cnset(ranges, unittypes=types) == expected 164 | 165 | 166 | @pytest.mark.cnset_factory 167 | def test_cnset_assigns_unit_names_correctly_with_names_kwarg(): 168 | """Calls to the ``cnset`` factory should produce a RangeSet object 169 | with internal Unit objects representing RangeSet endpoints that 170 | have correct names based on the provided ``names`` kwarg. 171 | """ 172 | types = [FactoryTestType] 173 | ranges = [('AB 100', 'AB 150'), ('CA 0', 'CA 1300')] 174 | names = [('R1 Start', 'R1 End'), ('R2 Start', 'R2 End')] 175 | myset = f.cnset(ranges, names=names, unittypes=types) 176 | assert (myset.ranges[0].start.name == 'R1 Start' and 177 | myset.ranges[0].end.name == 'R1 End' and 178 | myset.ranges[1].start.name == 'R2 Start' and 179 | myset.ranges[1].end.name == 'R2 End') 180 | 181 | 182 | @pytest.mark.cnset_factory 183 | def test_cnset_raises_SettingsError_if_names_kwarg_causes_errors(): 184 | """Calls to the ``cnset`` factory should produce a RangeSet object 185 | with internal Unit objects representing RangeSet endpoints that 186 | have blank names if the provided ``names`` kwarg . 187 | """ 188 | types = [FactoryTestType] 189 | ranges = [('AB 100', 'AB 150'), ('CA 0', 'CA 1300')] 190 | names = [('R1 Start', 'R1 End')] 191 | with pytest.raises(e.SettingsError): 192 | f.cnset(ranges, names=names, unittypes=types) 193 | -------------------------------------------------------------------------------- /tests/test_options.py: -------------------------------------------------------------------------------- 1 | from __future__ import unicode_literals 2 | 3 | 4 | from pycallnumber import options 5 | 6 | 7 | # Fixtures, factories, and test data 8 | 9 | class TObjectWithOptions(options.ObjectWithOptions): 10 | 11 | options_defaults = { 12 | 'opt1': 'A', 13 | 'opt2': 'A', 14 | } 15 | opt2 = 'B' 16 | 17 | 18 | # Tests 19 | 20 | def test_OWO_init_normal_option_via_default(): 21 | """Initializing an ObjectWithOptions class (or subclass) with no 22 | options arguments provided should generate appropriate values 23 | for options based on the ``options_defaults`` class attribute 24 | and any options overridden individually as class attributes 25 | themselves. The object's options.sources dictionary should also 26 | correctly identify the source of each option value (whether it came 27 | from a default, a class definition, or an argument). 28 | """ 29 | t = TObjectWithOptions() 30 | assert (t.opt1 == 'A' and t.options.sources['opt1'] == 'defaults' and 31 | t.opt2 == 'B' and t.options.sources['opt2'] == 'class') 32 | 33 | 34 | def test_OWO_init_normal_option_via_argument(): 35 | """Initializing an ObjectWithOptions class (or subclass) with 36 | options arguments provided should generate appropriate values 37 | for options based on the ``options_defaults`` class attribute 38 | and any options overridden individually as class attributes 39 | themselves. The object's options.sources dictionary should also 40 | correctly identify the source of each option value (whether it came 41 | from a default, a class definition, or an argument). 42 | """ 43 | t = TObjectWithOptions(opt1='C') 44 | assert (t.opt1 == 'C' and t.options.sources['opt1'] == 'argument' and 45 | t.opt2 == 'B' and t.options.sources['opt2'] == 'class') 46 | 47 | 48 | def test_OWO_init_class_option_via_argument_without_override(): 49 | """If the ``override_class_opts`` kwarg provided upon initializing 50 | an ObjectWithOptions object is False, then attempts to override 51 | an option value specified as a class attribute using an argument 52 | passed to __init__ should fail. The class attribute value should 53 | override the argument value for that option/attribute on the 54 | object. 55 | """ 56 | t = TObjectWithOptions(opt2='C', override_class_opts=False) 57 | assert (t.opt1 == 'A' and t.options.sources['opt1'] == 'defaults' and 58 | t.opt2 == 'B' and t.options.sources['opt2'] == 'class') 59 | 60 | 61 | def test_OWO_init_class_option_via_argument_with_override(): 62 | """If the ``override_class_opts`` kwarg provided upon initializing 63 | an ObjectWithOptions object is True, then attempts to override 64 | an option value specified as a class attribute using an argument 65 | passed to __init__ should succeed. The argument value should 66 | override the class attribute value for that option/attribute on the 67 | object. (It should still not change the class attribute value.) 68 | """ 69 | t = TObjectWithOptions(opt2='C', override_class_opts=True) 70 | assert (t.opt1 == 'A' and t.options.sources['opt1'] == 'defaults' and 71 | t.opt2 == 'C' and t.options.sources['opt2'] == 'argument' and 72 | t.options.classopts['opt2'] == 'B') 73 | 74 | 75 | def test_OWO_set_normal_option_via_argument(): 76 | """Using the ``set_option`` method of an ObjectWithOptions object 77 | to set the value of a particular option on an object should work 78 | by default--it should set the option/value based on the args passed 79 | to the method and set the ``sources`` dictionary for that option to 80 | reflect that the source of the value is an argument. 81 | """ 82 | t = TObjectWithOptions() 83 | t.set_option('opt1', 'C') 84 | assert (t.opt1 == 'C' and t.options.sources['opt1'] == 'argument' and 85 | t.opt2 == 'B' and t.options.sources['opt2'] == 'class') 86 | 87 | 88 | def test_OWO_set_class_option_via_argument_without_override(): 89 | """Trying to use the ``set_option`` method of an ObjectWithOptions 90 | object to set the value of a particular option on an object while 91 | passing an ``override_class_opts`` value of False should fail to 92 | set the option on the object if that option has a value specified 93 | as a class attribute. The value contained in the class attribute 94 | should override the value provided via the ``set_option`` method. 95 | """ 96 | t = TObjectWithOptions() 97 | t.set_option('opt2', 'C', override_class_opts=False) 98 | assert (t.opt1 == 'A' and t.options.sources['opt1'] == 'defaults' and 99 | t.opt2 == 'B' and t.options.sources['opt2'] == 'class') 100 | 101 | 102 | def test_OWO_set_class_option_via_argument_with_override(): 103 | """Trying to use the ``set_option`` method of an ObjectWithOptions 104 | object to set the value of a particular option on an object while 105 | passing an ``override_class_opts`` value of True should succeed in 106 | setting the option on the object, even if that option has a value 107 | specified as a class attribute. The value contained in the class 108 | attribute should not change, however. 109 | """ 110 | t = TObjectWithOptions() 111 | t.set_option('opt2', 'C', override_class_opts=True) 112 | assert (t.opt1 == 'A' and t.options.sources['opt1'] == 'defaults' and 113 | t.opt2 == 'C' and t.options.sources['opt2'] == 'argument' and 114 | t.options.classopts['opt2'] == 'B') 115 | -------------------------------------------------------------------------------- /tests/test_unit.py: -------------------------------------------------------------------------------- 1 | from __future__ import unicode_literals 2 | from builtins import str 3 | import operator 4 | 5 | import pytest 6 | 7 | from pycallnumber import unit as u 8 | from pycallnumber import template as t 9 | from pycallnumber import exceptions as e 10 | from helpers import generate_params 11 | 12 | 13 | # SimpleUnit ********************************************************** 14 | 15 | # Fixtures, factories, and test data 16 | 17 | class SUTest_Simple(u.SimpleUnit): 18 | 19 | options_defaults = u.SimpleUnit.options_defaults.copy() 20 | options_defaults.update({ 21 | 'print_value': '[PR]', 22 | }) 23 | definition = 'SUTest_Simple definition' 24 | template = t.SimpleTemplate( 25 | min_length=1, 26 | max_length=None, 27 | base_pattern=r'[A-Za-z]', 28 | base_description='SUTest_Simple description', 29 | base_description_plural='SUTest_Simple description plural', 30 | pre_pattern=r'0?', 31 | pre_description='SUTest_Simple pre_description', 32 | post_pattern=r'0?', 33 | post_description='SUTest_Simple post_description', 34 | ) 35 | 36 | def for_print(self): 37 | return '{}{}'.format(self._string, self.print_value) 38 | 39 | 40 | test_obj_attributes = ['definition', 'is_separator', 'is_formatting', 41 | 'print_value'] 42 | 43 | test_template_attributes = ['min_length', 'max_length', 'base_pattern', 44 | 'base_description', 'base_description_plural', 45 | 'pre_pattern', 'pre_description', 'post_pattern', 46 | 'post_description'] 47 | 48 | 49 | class SUTest_CompSmall(SUTest_Simple): 50 | 51 | """Will always be smaller than an SUTest_Simple Unit""" 52 | 53 | def for_sort(self): 54 | return '!{}'.format(self._string) 55 | 56 | 57 | class SUTest_CompLarge(SUTest_Simple): 58 | 59 | """Will always be larger than an SUTest_Simple Unit""" 60 | 61 | def for_sort(self): 62 | return '~{}'.format(self._string) 63 | 64 | 65 | SIMPUNIT_CMP_PARAMS = [ 66 | (SUTest_Simple('a'), SUTest_Simple('a'), operator.lt, False), 67 | (SUTest_Simple('a'), SUTest_Simple('a'), operator.le, True), 68 | (SUTest_Simple('a'), SUTest_Simple('a'), operator.eq, True), 69 | (SUTest_Simple('a'), SUTest_Simple('a'), operator.ne, False), 70 | (SUTest_Simple('a'), SUTest_Simple('a'), operator.ge, True), 71 | (SUTest_Simple('a'), SUTest_Simple('a'), operator.gt, False), 72 | (SUTest_Simple('a'), SUTest_Simple('a'), operator.contains, True), 73 | (SUTest_Simple('a'), SUTest_Simple('b'), operator.lt, True), 74 | (SUTest_Simple('a'), SUTest_Simple('b'), operator.le, True), 75 | (SUTest_Simple('a'), SUTest_Simple('b'), operator.eq, False), 76 | (SUTest_Simple('a'), SUTest_Simple('b'), operator.ne, True), 77 | (SUTest_Simple('a'), SUTest_Simple('b'), operator.ge, False), 78 | (SUTest_Simple('a'), SUTest_Simple('b'), operator.gt, False), 79 | (SUTest_Simple('a'), SUTest_Simple('b'), operator.contains, False), 80 | (SUTest_Simple('a'), SUTest_CompSmall('b'), operator.lt, False), 81 | (SUTest_Simple('a'), SUTest_CompSmall('b'), operator.le, False), 82 | (SUTest_Simple('a'), SUTest_CompSmall('b'), operator.eq, False), 83 | (SUTest_Simple('a'), SUTest_CompSmall('b'), operator.ne, True), 84 | (SUTest_Simple('a'), SUTest_CompSmall('b'), operator.ge, True), 85 | (SUTest_Simple('a'), SUTest_CompSmall('b'), operator.gt, True), 86 | (SUTest_Simple('b'), SUTest_CompLarge('a'), operator.lt, True), 87 | (SUTest_Simple('b'), SUTest_CompLarge('a'), operator.le, True), 88 | (SUTest_Simple('b'), SUTest_CompLarge('a'), operator.eq, False), 89 | (SUTest_Simple('b'), SUTest_CompLarge('a'), operator.ne, True), 90 | (SUTest_Simple('b'), SUTest_CompLarge('a'), operator.ge, False), 91 | (SUTest_Simple('b'), SUTest_CompLarge('a'), operator.gt, False), 92 | (SUTest_Simple('a'), '', operator.contains, True), 93 | (SUTest_Simple('abc'), 'a', operator.contains, True), 94 | (SUTest_Simple('abc'), 'b', operator.contains, True), 95 | (SUTest_Simple('abc'), 'c', operator.contains, True), 96 | (SUTest_Simple('abc'), 'ab', operator.contains, True), 97 | (SUTest_Simple('abc'), 'ac', operator.contains, False), 98 | ] 99 | 100 | 101 | # Tests 102 | 103 | @pytest.mark.simple 104 | @pytest.mark.parametrize('tstr', ['a', 'ab', 'A', 'AB']) 105 | def test_simpleunit_validate_true(tstr): 106 | """When passed to a SimpleUnit's ``validate`` method, the given 107 | test string (tstr) should result in a True value. 108 | """ 109 | assert bool(SUTest_Simple.validate(tstr)) is True 110 | 111 | 112 | @pytest.mark.simple 113 | @pytest.mark.parametrize('tstr', ['', 'a ', 'a1']) 114 | def test_simpleunit_validate_error(tstr): 115 | """When passed to a SimpleUnit's ``validate`` method, the given 116 | test string (tstr) should raise an InvalidCallNumberStringError. 117 | """ 118 | with pytest.raises(e.InvalidCallNumberStringError): 119 | SUTest_Simple.validate(tstr) 120 | 121 | 122 | @pytest.mark.simple 123 | def test_simpleunit_as_str(): 124 | """Casting a SimpleUnit as a string should return the ``for_print`` 125 | value. 126 | """ 127 | unit = SUTest_Simple('a') 128 | assert str(unit) == 'a[PR]' 129 | 130 | 131 | @pytest.mark.simple 132 | @pytest.mark.comparison 133 | @pytest.mark.parametrize('val1, val2, op, expected', SIMPUNIT_CMP_PARAMS) 134 | def test_simpleunit_comparisons(val1, val2, op, expected): 135 | """The given values, val1 and val2, should produce the expected 136 | truth value when compared via the given operator, op.""" 137 | assert op(val1, val2) == expected 138 | 139 | 140 | @pytest.mark.simple 141 | @pytest.mark.parametrize('attribute, testvalue', [ 142 | ('definition', 'derived definition'), 143 | ('is_separator', True), 144 | ('is_formatting', True), 145 | ('print_value', 'derived print value'), 146 | ('for_sort', lambda x: True), 147 | ('custom_attribute', 'custom derived attribute') 148 | ]) 149 | def test_simpleunit_derive_unit_attributes(attribute, testvalue): 150 | """Creating a new SimpleUnit using the ``derive`` method and 151 | specifying the given attribute (found on the Unit class) should 152 | result in an object attribute with the given testvalue. All 153 | other object attributes should be the same as the parent unit. 154 | """ 155 | derived = SUTest_Simple.derive(**{attribute: testvalue}) 156 | derived_unit = derived('a') 157 | original_unit = SUTest_Simple('a') 158 | for attr in test_obj_attributes: 159 | if attr == attribute: 160 | expected = testvalue 161 | else: 162 | expected = getattr(original_unit, attr) 163 | assert getattr(derived_unit, attr) == expected 164 | 165 | 166 | @pytest.mark.simple 167 | @pytest.mark.parametrize('attribute, testvalue', [ 168 | ('min_length', 0), 169 | ('max_length', 5), 170 | ('base_description', 'derived description'), 171 | ('base_description_plural', 'derived description plural'), 172 | ('pre_description', 'derived pre_description'), 173 | ('post_description', 'derived post_description'), 174 | ]) 175 | def test_simpleunit_derive_template_attributes(attribute, testvalue): 176 | """Creating a new SimpleUnit using the ``derive`` method and 177 | specifying the given attribute (found on the SimpleTemplate object 178 | that is in the Unit's ``template`` attribute) should result in an 179 | attribute with the given testvalue on the object's ``template``. 180 | All other template attributes should be the same as the parent unit 181 | class. 182 | """ 183 | derived = SUTest_Simple.derive(**{attribute: testvalue}) 184 | derived_unit = derived('a') 185 | original_unit = SUTest_Simple('a') 186 | for attr in test_template_attributes: 187 | if attr == attribute: 188 | expected = testvalue 189 | else: 190 | expected = getattr(original_unit.template, attr) 191 | assert getattr(derived_unit.template, attr) == expected 192 | 193 | 194 | @pytest.mark.simple 195 | @pytest.mark.parametrize('attr_prefix, testvalue', [ 196 | ('base', r'aa'), 197 | ('pre', r'a'), 198 | ('post', r'a'), 199 | ]) 200 | def test_simpleunit_derive_template_patterns(attr_prefix, testvalue): 201 | """Creating a new SimpleUnit using the ``derive`` method and 202 | specifying one of the *_pattern attributes (found on the object's 203 | ``template`` object) should result in an attribute with the given 204 | testvalue on the object's template. The corresponding *_description 205 | attribute, in addition, should become None. All other template 206 | attributes should be the same as the parent unit class. 207 | """ 208 | attribute = '{}_pattern'.format(attr_prefix) 209 | attr_description = '{}_description'.format(attr_prefix) 210 | attr_desc_plural = '{}_description_plural'.format(attr_prefix) 211 | derived = SUTest_Simple.derive(**{attribute: testvalue}) 212 | derived_unit = derived('aa') 213 | original_unit = SUTest_Simple('aa') 214 | for attr in test_template_attributes: 215 | if attr == attribute: 216 | expected = testvalue 217 | elif attr == attr_description or attr == attr_desc_plural: 218 | expected = None 219 | else: 220 | expected = getattr(original_unit.template, attr) 221 | assert getattr(derived_unit.template, attr) == expected 222 | 223 | 224 | # CompoundUnit ******************************************************** 225 | 226 | # Fixtures, factories, and test data 227 | 228 | alpha_utype = u.SimpleUnit.derive(base_pattern=r'[A-Za-z]', max_length=None) 229 | digit_utype = u.SimpleUnit.derive(base_pattern=r'[0-9]', max_length=None) 230 | pipe_utype = u.SimpleUnit.derive(base_pattern=r'\|', min_length=0, 231 | is_formatting=True) 232 | dot_utype = u.SimpleUnit.derive(base_pattern=r'\.', min_length=0, 233 | is_formatting=True) 234 | dash_utype = u.SimpleUnit.derive(base_pattern=r'\-', min_length=0, 235 | is_formatting=True) 236 | space_utype = u.SimpleUnit.derive(base_pattern=r' ', min_length=0, 237 | is_formatting=True) 238 | 239 | 240 | class AlphaCustomForMethods(alpha_utype): 241 | 242 | options_defaults = u.SimpleUnit.options_defaults.copy() 243 | options_defaults.update({ 244 | 'print_value': '[PR]', 245 | 'search_value': '[SE]', 246 | 'sort_value': '[SO]' 247 | }) 248 | 249 | def for_print(self): 250 | return '{}{}'.format(self._string, self.print_value) 251 | 252 | def for_search(self): 253 | return '{}{}'.format(self._string, self.search_value) 254 | 255 | def for_sort(self): 256 | return '{}{}'.format(self._string, self.sort_value) 257 | 258 | 259 | class HideFormattingForSort(u.SimpleUnit): 260 | 261 | is_formatting = True 262 | 263 | def for_sort(self): 264 | return '' 265 | 266 | 267 | pipehide_utype = HideFormattingForSort.derive(base_pattern=r'\|', min_length=0) 268 | dothide_utype = HideFormattingForSort.derive(base_pattern=r'\.', min_length=0) 269 | dashhide_utype = HideFormattingForSort.derive(base_pattern=r'\-', min_length=0) 270 | spacehide_utype = HideFormattingForSort.derive(base_pattern=r' ', min_length=0) 271 | 272 | 273 | class CUTest_Simple(u.CompoundUnit): 274 | 275 | template = t.CompoundTemplate( 276 | separator_type=pipe_utype, 277 | groups=[ 278 | {'name': 'letter1', 'min': 0, 'max': None, 'type': alpha_utype, 279 | 'inner_sep_type': dot_utype}, 280 | {'name': 'digit', 'min': 0, 'max': None, 'type': digit_utype, 281 | 'inner_sep_type': dot_utype}, 282 | {'name': 'letter2', 'min': 0, 'max': None, 'type': alpha_utype, 283 | 'inner_sep_type': dot_utype}, 284 | 285 | ] 286 | ) 287 | 288 | 289 | class CUTest_Simple_SortHideFormatting(u.CompoundUnit): 290 | 291 | template = t.CompoundTemplate( 292 | separator_type=pipehide_utype, 293 | groups=[ 294 | {'name': 'letter1', 'min': 0, 'max': None, 'type': alpha_utype, 295 | 'inner_sep_type': dothide_utype}, 296 | {'name': 'digit', 'min': 0, 'max': None, 'type': digit_utype, 297 | 'inner_sep_type': dothide_utype}, 298 | {'name': 'letter2', 'min': 0, 'max': None, 'type': alpha_utype, 299 | 'inner_sep_type': dothide_utype}, 300 | 301 | ] 302 | ) 303 | 304 | 305 | class CUTest_Compound_1GroupInnerSep(u.CompoundUnit): 306 | 307 | template = t.CompoundTemplate( 308 | separator_type=None, 309 | groups=[ 310 | {'name': 'parts', 'min': 0, 'max': None, 311 | 'type': CUTest_Simple, 'inner_sep_type': dash_utype} 312 | ] 313 | ) 314 | 315 | 316 | class CUTest_CustomForMethods(u.CompoundUnit): 317 | 318 | template = t.CompoundTemplate( 319 | separator_type=None, 320 | groups=[ 321 | {'name': 'letter', 'min': 0, 'max': None, 322 | 'type': AlphaCustomForMethods}, 323 | {'name': 'digit', 'min': 0, 'max': None, 'type': digit_utype} 324 | ] 325 | ) 326 | 327 | 328 | class CUTest_ForCmpP1(u.CompoundUnit): 329 | 330 | template = t.CompoundTemplate( 331 | separator_type=dashhide_utype, 332 | groups=[ 333 | {'name': 'letter1', 'min': 1, 'max': 1, 'type': alpha_utype}, 334 | {'name': 'letter2', 'min': 1, 'max': 1, 'type': alpha_utype}, 335 | ] 336 | ) 337 | 338 | 339 | class CUTest_ForCmpP2(u.CompoundUnit): 340 | 341 | template = t.CompoundTemplate( 342 | separator_type=dothide_utype, 343 | groups=[ 344 | {'name': 'digit1', 'min': 1, 'max': 1, 'type': digit_utype}, 345 | {'name': 'digit2', 'min': 1, 'max': 1, 'type': digit_utype}, 346 | ] 347 | ) 348 | 349 | 350 | class CUTest_ForCmp(u.CompoundUnit): 351 | 352 | template = t.CompoundTemplate( 353 | separator_type=spacehide_utype, 354 | groups=[ 355 | {'name': 'part1', 'min': 1, 'max': 1, 'type': CUTest_ForCmpP1}, 356 | {'name': 'part2', 'min': 0, 'max': 1, 'type': CUTest_ForCmpP2}, 357 | {'name': 'part3', 'min': 0, 'max': None, 358 | 'possible_types': [dot_utype, pipe_utype, dash_utype]}, 359 | ] 360 | ) 361 | 362 | 363 | COMPOUND_UNIT_DATA = { 364 | CUTest_Simple: { 365 | 'valid': ['', 'a', 'a1', 'a1a', 'aaa', 'aa11aa', 'a|1|a', 'aa|11|aa', 366 | 'a|a', 'a.a|1.1|a.a'], 367 | 'invalid': ['a1a1', 'a.1', 'a.|1', 'a.', 'a|', '|1', '.1', '|a', '.a'], 368 | 'attribute': [ 369 | ('a', [('letter1', 'a'), 370 | ('digit', None), 371 | ('letter2', None)]), 372 | ('aa', [('letter1', 'aa'), 373 | ('digit', None), 374 | ('letter2', None)]), 375 | ('a.a', [('letter1', 'a.a'), 376 | ('letter1', ['a', 'a']), 377 | ('digit', None), 378 | ('letter2', None)]), 379 | ('a|1', [('letter1', 'a'), 380 | ('digit', '1'), 381 | ('letter2', None)]), 382 | ('a|a', [('letter1', 'a'), 383 | ('digit', None), 384 | ('letter2', 'a')]), 385 | ('a|1|a', [('letter1', 'a'), 386 | ('digit', '1'), 387 | ('letter2', 'a')]), 388 | ('a1a', [('letter1', 'a'), 389 | ('digit', '1'), 390 | ('letter2', 'a')]), 391 | ], 392 | 'for_sort': [('a', 'a'), ('a.a', 'a.a'), ('a|a', 'a|a'), 393 | ('a|1', 'a|1'), ('a|1|a', 'a|1|a'), ('aa', 'aa'), 394 | ('a1a', 'a!1!a'), ('aa11aa', 'aa!11!aa'), 395 | ('a.a|1.1|a.a', 'a.a|1.1|a.a')] 396 | }, 397 | CUTest_Simple_SortHideFormatting: { 398 | 'for_sort': [('a', 'a'), ('a.a', 'a!a'), ('a|a', 'a!a'), 399 | ('a|1', 'a!1'), ('a|1|a', 'a!1!a'), ('aa', 'aa'), 400 | ('a1a', 'a!1!a'), ('aa11aa', 'aa!11!aa'), 401 | ('a.a|1.1|a.a', 'a!a!1!1!a!a')] 402 | }, 403 | CUTest_Compound_1GroupInnerSep: { 404 | 'valid': ['', 'a', 'a-a', 'aa-a', 'aa11|a-aa', 'a.a11|a-a1a-a'], 405 | 'invalid': ['a-', 'a-|', '-a', '|-a'], 406 | 'attribute': [ 407 | ('a', [('parts', 'a')]), 408 | ('a-a', [ 409 | ('parts', 'a-a'), 410 | ('parts', ['a', 'a']), 411 | ('parts', [ 412 | (('letter1', 'a'), 413 | ('digit', None), 414 | ('letter2', None)), 415 | (('letter1', 'a'), 416 | ('digit', None), 417 | ('letter2', None)) 418 | ]) 419 | ]), 420 | ('a.a|1.1|a.a-b.b|2.2|b.b', [ 421 | ('parts', 'a.a|1.1|a.a-b.b|2.2|b.b'), 422 | ('parts', ['a.a|1.1|a.a', 'b.b|2.2|b.b']), 423 | ('parts', [ 424 | (('letter1', 'a.a'), 425 | ('letter1', ['a', 'a']), 426 | ('digit', '1.1'), 427 | ('digit', ['1', '1']), 428 | ('letter2', 'a.a'), 429 | ('letter2', ['a', 'a'])), 430 | (('letter1', 'b.b'), 431 | ('letter1', ['b', 'b']), 432 | ('digit', '2.2'), 433 | ('digit', ['2', '2']), 434 | ('letter2', 'b.b'), 435 | ('letter2', ['b', 'b'])) 436 | ]) 437 | ]) 438 | ], 439 | } 440 | } 441 | 442 | COMPUNIT_VALID_PARAMS = generate_params(COMPOUND_UNIT_DATA, 'valid') 443 | COMPUNIT_INVALID_PARAMS = generate_params(COMPOUND_UNIT_DATA, 'invalid') 444 | COMPUNIT_ATTR_PARAMS = generate_params(COMPOUND_UNIT_DATA, 'attribute') 445 | COMPUNIT_FOR_SORT_PARAMS = generate_params(COMPOUND_UNIT_DATA, 'for_sort') 446 | 447 | COMPUNIT_CMP_PARAMS = [ 448 | (CUTest_ForCmpP1('a-a'), CUTest_ForCmpP1('a-a'), operator.lt, False), 449 | (CUTest_ForCmpP1('a-a'), CUTest_ForCmpP1('a-a'), operator.le, True), 450 | (CUTest_ForCmpP1('a-a'), CUTest_ForCmpP1('a-a'), operator.eq, True), 451 | (CUTest_ForCmpP1('a-a'), CUTest_ForCmpP1('a-a'), operator.ne, False), 452 | (CUTest_ForCmpP1('a-a'), CUTest_ForCmpP1('a-a'), operator.ge, True), 453 | (CUTest_ForCmpP1('a-a'), CUTest_ForCmpP1('a-a'), operator.gt, False), 454 | (CUTest_ForCmpP1('a-a'), CUTest_ForCmpP1('a-a'), operator.contains, True), 455 | (CUTest_ForCmpP1('a-a'), CUTest_ForCmpP1('aa'), operator.lt, False), 456 | (CUTest_ForCmpP1('a-a'), CUTest_ForCmpP1('aa'), operator.le, True), 457 | (CUTest_ForCmpP1('a-a'), CUTest_ForCmpP1('aa'), operator.eq, True), 458 | (CUTest_ForCmpP1('a-a'), CUTest_ForCmpP1('aa'), operator.ne, False), 459 | (CUTest_ForCmpP1('a-a'), CUTest_ForCmpP1('aa'), operator.ge, True), 460 | (CUTest_ForCmpP1('a-a'), CUTest_ForCmpP1('aa'), operator.gt, False), 461 | (CUTest_ForCmpP1('a-a'), CUTest_ForCmpP1('aa'), operator.contains, True), 462 | (CUTest_ForCmpP1('a-a'), CUTest_ForCmpP1('a-b'), operator.lt, True), 463 | (CUTest_ForCmpP1('a-a'), CUTest_ForCmpP1('a-b'), operator.le, True), 464 | (CUTest_ForCmpP1('a-a'), CUTest_ForCmpP1('a-b'), operator.eq, False), 465 | (CUTest_ForCmpP1('a-a'), CUTest_ForCmpP1('a-b'), operator.ne, True), 466 | (CUTest_ForCmpP1('a-a'), CUTest_ForCmpP1('a-b'), operator.ge, False), 467 | (CUTest_ForCmpP1('a-a'), CUTest_ForCmpP1('a-b'), operator.gt, False), 468 | (CUTest_ForCmpP1('a-a'), CUTest_ForCmpP1('a-b'), operator.contains, False), 469 | (CUTest_ForCmp('a-a 1.1'), CUTest_ForCmp('a-a 1.2'), operator.lt, True), 470 | (CUTest_ForCmp('a-a 1.1'), CUTest_ForCmp('a-a 1.2'), operator.le, True), 471 | (CUTest_ForCmp('a-a 1.1'), CUTest_ForCmp('a-a 1.2'), operator.eq, False), 472 | (CUTest_ForCmp('a-a 1.1'), CUTest_ForCmp('a-a 1.2'), operator.ne, True), 473 | (CUTest_ForCmp('a-a 1.1'), CUTest_ForCmp('a-a 1.2'), operator.ge, False), 474 | (CUTest_ForCmp('a-a 1.1'), CUTest_ForCmp('a-a 1.2'), operator.gt, False), 475 | (CUTest_ForCmp('a-a 1.1'), CUTest_ForCmpP1('a-b'), operator.lt, True), 476 | (CUTest_ForCmp('a-a 1.1'), CUTest_ForCmpP1('a-b'), operator.le, True), 477 | (CUTest_ForCmp('a-a 1.1'), CUTest_ForCmpP1('a-b'), operator.eq, False), 478 | (CUTest_ForCmp('a-a 1.1'), CUTest_ForCmpP1('a-b'), operator.ne, True), 479 | (CUTest_ForCmp('a-a 1.1'), CUTest_ForCmpP1('a-b'), operator.ge, False), 480 | (CUTest_ForCmp('a-a 1.1'), CUTest_ForCmpP1('a-b'), operator.gt, False), 481 | (CUTest_ForCmp('a-a 1.1'), CUTest_ForCmpP1('a-a'), operator.contains, 482 | True), 483 | (CUTest_ForCmp('a-a 1.1'), alpha_utype, operator.contains, True), 484 | (CUTest_ForCmp('a-a 1.1'), digit_utype, operator.contains, True), 485 | (CUTest_ForCmp('a-a 1.1'), CUTest_ForCmp, operator.contains, True), 486 | (CUTest_ForCmp('a-a 1.1'), CUTest_ForCmpP1, operator.contains, True), 487 | (CUTest_ForCmp('a-a 1.1'), CUTest_ForCmpP2, operator.contains, True), 488 | (CUTest_ForCmp('a-a 1.1'), dashhide_utype, operator.contains, True), 489 | (CUTest_ForCmp('a-a 1.1'), dothide_utype, operator.contains, True), 490 | (CUTest_ForCmp('a-a 1.1'), spacehide_utype, operator.contains, True), 491 | (CUTest_ForCmp('a-a 1.1'), dash_utype, operator.contains, False), 492 | (CUTest_ForCmp('a-a 1.1'), dot_utype, operator.contains, False), 493 | (CUTest_ForCmp('a-a 1.1'), pipe_utype, operator.contains, False), 494 | (CUTest_ForCmp('a-a 1.1|'), pipe_utype, operator.contains, True), 495 | (CUTest_ForCmp('a-a 1.1|'), CUTest_ForCmpP1('a-a'), operator.contains, 496 | True), 497 | (CUTest_ForCmp('a-a 1.1|'), CUTest_ForCmpP1('a-b'), operator.contains, 498 | False), 499 | (CUTest_ForCmp('a-a 1.1|'), CUTest_ForCmpP2('1.1'), operator.contains, 500 | True), 501 | (CUTest_ForCmp('a-a 1.1|'), CUTest_ForCmpP2('1.2'), operator.contains, 502 | False), 503 | (CUTest_ForCmp('a-a 1.1|'), alpha_utype('a'), operator.contains, True), 504 | (CUTest_ForCmp('a-a 1.1|'), alpha_utype('b'), operator.contains, False), 505 | (CUTest_ForCmp('a-a 1.1|'), digit_utype('1'), operator.contains, True), 506 | (CUTest_ForCmp('a-a 1.1|'), digit_utype('2'), operator.contains, False), 507 | (CUTest_ForCmp('a-a 1.1|'), dothide_utype('.'), operator.contains, True), 508 | ] 509 | 510 | 511 | # Tests 512 | 513 | @pytest.mark.compound 514 | @pytest.mark.parametrize('unit_type, tstr', COMPUNIT_VALID_PARAMS) 515 | def test_compoundunit_validate_true(unit_type, tstr): 516 | """When passed to the given unit_type's ``validate`` method, the 517 | given test string (tstr) should result in a True value. 518 | """ 519 | assert bool(unit_type.validate(tstr)) is True 520 | 521 | 522 | @pytest.mark.compound 523 | @pytest.mark.parametrize('unit_type, tstr', COMPUNIT_INVALID_PARAMS) 524 | def test_compoundunit_validate_error(unit_type, tstr): 525 | """When passed to the given unit_type's ``validate`` method, the 526 | given test string (tstr) should raise an 527 | InvalidCallNumberStringError. 528 | """ 529 | with pytest.raises(e.InvalidCallNumberStringError): 530 | unit_type.validate(tstr) 531 | 532 | 533 | @pytest.mark.compound 534 | @pytest.mark.parametrize('unit_type, tstr, expected', COMPUNIT_ATTR_PARAMS) 535 | def test_compoundunit_attributes(unit_type, tstr, expected): 536 | """When the given unit_type is instantiated using the given test 537 | string (tstr), the resulting object should have attributes that 538 | follow from what's present in ``expected.`` 539 | """ 540 | def check_attributes(unit, attrlist): 541 | for attr, val in attrlist: 542 | if isinstance(val, tuple): 543 | subunit = getattr(unit, attr) 544 | check_attributes(subunit, val) 545 | elif isinstance(val, list): 546 | if isinstance(val[0], str): 547 | valstrings = [str(v) for v in val] 548 | assert [str(v) for v in getattr(unit, attr)] == valstrings 549 | elif isinstance(val[0], tuple): 550 | multiunit = getattr(unit, attr) 551 | for i, subattrlist in enumerate(val): 552 | subunit = multiunit[i] 553 | check_attributes(subunit, subattrlist) 554 | else: 555 | assert str(getattr(unit, attr)) == str(val) 556 | 557 | unit = unit_type(tstr) 558 | check_attributes(unit, expected) 559 | 560 | 561 | @pytest.mark.compound 562 | @pytest.mark.comparison 563 | @pytest.mark.parametrize('val1, val2, op, expected', COMPUNIT_CMP_PARAMS) 564 | def test_compoundunit_comparisons(val1, val2, op, expected): 565 | """The given values, val1 and val2, should produce the expected 566 | truth value when compared via the given operator, op.""" 567 | assert op(val1, val2) == expected 568 | 569 | 570 | @pytest.mark.compound 571 | @pytest.mark.parametrize('unit_type, tstr, expected', COMPUNIT_FOR_SORT_PARAMS) 572 | def test_compoundunit_for_sort(unit_type, tstr, expected): 573 | """When the given unit_type is instantiated using the given test 574 | string (tstr), calling the ``for_sort`` method on the resulting 575 | object should produce the ``expected`` value. 576 | """ 577 | unit = unit_type(tstr) 578 | assert unit.for_sort() == expected 579 | 580 | 581 | @pytest.mark.compound 582 | def test_compoundunit_custom_for_print(): 583 | """When the ``for_print`` method is called on a 584 | CUTest_CustomForMethods object, the ``letter`` grouping's custom 585 | ``for_print`` method should be used to formulate the return value. 586 | """ 587 | unit = CUTest_CustomForMethods('aa11') 588 | assert unit.for_print() == 'aa[PR]11' 589 | 590 | 591 | @pytest.mark.compound 592 | def test_compoundunit_custom_for_search(): 593 | """When the ``for_search`` method is called on a 594 | CUTest_CustomForMethods object, the ``letter`` grouping's custom 595 | ``for_search`` method should be used to formulate the return value. 596 | """ 597 | unit = CUTest_CustomForMethods('aa11') 598 | assert unit.for_search() == 'aa[SE]11' 599 | 600 | 601 | @pytest.mark.compound 602 | def test_compoundunit_custom_for_sort(): 603 | """When the ``for_sort`` method is called on a 604 | CUTest_CustomForMethods object, the ``letter`` grouping's custom 605 | ``for_sort`` method should be used to formulate the return value. 606 | """ 607 | unit = CUTest_CustomForMethods('aa11') 608 | assert unit.for_sort() == 'aa[SO]!11' 609 | -------------------------------------------------------------------------------- /tests/test_utils.py: -------------------------------------------------------------------------------- 1 | from __future__ import unicode_literals 2 | from builtins import object 3 | import operator 4 | 5 | import pytest 6 | 7 | from pycallnumber import utils as u 8 | from pycallnumber import units as uns 9 | 10 | 11 | # Fixtures, factories, and test data 12 | 13 | # noargs, args, kwargs, and args_kwargs are factory functions that 14 | # produce memoized functions, where the memo key is generated based on 15 | # the function signature when the function is called. 16 | 17 | def noargs(): 18 | @u.memoize 19 | def f(): 20 | return 'val' 21 | return f 22 | 23 | 24 | def args(): 25 | @u.memoize 26 | def f(arg1, arg2): 27 | return 'val_{}_{}'.format(arg1, arg2) 28 | return f 29 | 30 | 31 | def kwargs(): 32 | @u.memoize 33 | def f(kwarg1='one', kwarg2='two'): 34 | return 'val_{}_{}'.format(kwarg1, kwarg2) 35 | return f 36 | 37 | 38 | def args_kwargs(): 39 | @u.memoize 40 | def f(arg1, arg2, kwarg1='one', kwarg2='two'): 41 | return 'val_{}_{}_{}_{}'.format(arg1, arg2, kwarg1, kwarg2) 42 | return f 43 | 44 | 45 | class MemoizeBoundMethodTester(object): 46 | 47 | @u.memoize 48 | def args_kwargs(self, arg1, arg2, kwarg1='one', kwarg2='two'): 49 | return 'val_{}_{}_{}_{}'.format(arg1, arg2, kwarg1, kwarg2) 50 | 51 | 52 | test_unit_low = uns.Alphabetic('A') 53 | test_unit_high = uns.Alphabetic('ZZZZZZZZZZ') 54 | 55 | 56 | MEMOIZE_PARAMETERS = [ 57 | (noargs, [], {}, ''), 58 | (args, ['a', 'b'], {}, '_a_b'), 59 | (kwargs, ['a', 'b'], {}, '_a_b'), 60 | (kwargs, [], {}, '_one_two'), 61 | (kwargs, ['a'], {}, '_a_two'), 62 | (kwargs, [], {'kwarg1': 'a'}, '_a_two'), 63 | (kwargs, [], {'kwarg2': 'b'}, '_one_b'), 64 | (kwargs, ['a'], {'kwarg2': 'b'}, '_a_b'), 65 | (kwargs, [], {'kwarg1': 'a', 'kwarg2': 'b'}, '_a_b'), 66 | (kwargs, [], {'kwarg2': 'b', 'kwarg1': 'a'}, '_a_b'), 67 | (args_kwargs, ['a', 'b'], {}, '_a_b_one_two'), 68 | (args_kwargs, ['a', 'b', 'c'], {}, '_a_b_c_two'), 69 | (args_kwargs, ['a', 'b', 'c', 'd'], {}, '_a_b_c_d'), 70 | (args_kwargs, ['a', 'b', 'c'], {'kwarg2': 'd'}, '_a_b_c_d'), 71 | (args_kwargs, ['a', 'b'], {'kwarg2': 'd'}, '_a_b_one_d'), 72 | (args_kwargs, ['a', 'b'], {'kwarg1': 'c'}, '_a_b_c_two'), 73 | (args_kwargs, ['a', 'b'], {'kwarg1': 'c', 'kwarg2': 'd'}, '_a_b_c_d'), 74 | (args_kwargs, ['a', 'b'], {'kwarg2': 'd', 'kwarg1': 'c'}, '_a_b_c_d'), 75 | ] 76 | 77 | 78 | PRETTY_PARAMETERS = [ 79 | ('1234 12345', 10, 0, 1, '1234 12345'), 80 | ('1234 12345', 9, 0, 1, '1234\n12345'), 81 | ('1234 12345', 5, 0, 1, '1234\n12345'), 82 | ('1234 12345', 4, 0, 1, '1234\n1234\n5'), 83 | ('1234 12345', 3, 0, 1, '123\n4\n123\n45'), 84 | ('1234 12345', 2, 0, 1, '12\n34\n12\n34\n5'), 85 | ('1234 12345', 1, 0, 1, '1\n2\n3\n4\n1\n2\n3\n4\n5'), 86 | ('1234 12345', 0, 0, 1, '1234 12345'), 87 | ('1234 12345', 11, 1, 1, ' 1234 12345'), 88 | ('1234 12345', 11, 2, 1, ' 1234\n 12345'), 89 | ('1234 12345', 11, 1, 2, ' 1234\n 12345'), 90 | ('1234 12345', 11, 6, 1, ' 1234\n 12345'), 91 | ('1234 12345', 11, 3, 2, ' 1234\n 12345'), 92 | ('1234 12345', 11, 2, 3, ' 1234\n 12345'), 93 | ('1234 12345', 11, 7, 1, ' 1234\n 1234\n 5'), 94 | ('1234 12345', 11, 8, 1, 95 | ' 123\n 4\n 123\n 45'), 96 | ('1234 12345', 11, 9, 1, 97 | ' 12\n 34\n 12\n 34\n 5'), 98 | ('1234 12345', 11, 10, 1, 99 | (' 1\n 2\n 3\n 4\n 1\n' 100 | ' 2\n 3\n 4\n 5')), 101 | ('1234 12345', 11, 11, 1, ' 1234 12345'), 102 | ('1234 12345', 11, 12, 1, ' 1234 12345'), 103 | ('1234 12345\n\n1234 12345', 10, 0, 1, '1234 12345\n\n1234 12345'), 104 | ('1234 12345\n\n1234 12345', 11, 2, 1, 105 | ' 1234\n 12345\n\n 1234\n 12345') 106 | ] 107 | 108 | INFINITY_COMP_PARAMS = [ 109 | ((u.Infinity(), u.Infinity()), operator.lt, False), 110 | ((u.Infinity(), u.Infinity()), operator.le, True), 111 | ((u.Infinity(), u.Infinity()), operator.eq, True), 112 | ((u.Infinity(), u.Infinity()), operator.ne, False), 113 | ((u.Infinity(), u.Infinity()), operator.gt, False), 114 | ((u.Infinity(), u.Infinity()), operator.ge, True), 115 | ((-u.Infinity(), u.Infinity()), operator.lt, True), 116 | ((-u.Infinity(), u.Infinity()), operator.le, True), 117 | ((-u.Infinity(), u.Infinity()), operator.eq, False), 118 | ((-u.Infinity(), u.Infinity()), operator.ne, True), 119 | ((-u.Infinity(), u.Infinity()), operator.gt, False), 120 | ((-u.Infinity(), u.Infinity()), operator.ge, False), 121 | ((-u.Infinity(), -u.Infinity()), operator.lt, False), 122 | ((-u.Infinity(), -u.Infinity()), operator.le, True), 123 | ((-u.Infinity(), -u.Infinity()), operator.eq, True), 124 | ((-u.Infinity(), -u.Infinity()), operator.ne, False), 125 | ((-u.Infinity(), -u.Infinity()), operator.gt, False), 126 | ((-u.Infinity(), -u.Infinity()), operator.ge, True), 127 | ((u.Infinity(), -u.Infinity()), operator.lt, False), 128 | ((u.Infinity(), -u.Infinity()), operator.le, False), 129 | ((u.Infinity(), -u.Infinity()), operator.eq, False), 130 | ((u.Infinity(), -u.Infinity()), operator.ne, True), 131 | ((u.Infinity(), -u.Infinity()), operator.gt, True), 132 | ((u.Infinity(), -u.Infinity()), operator.ge, True), 133 | ((u.Infinity(), 'ZZZZZZZZZZ9999999999'), operator.lt, False), 134 | ((u.Infinity(), 'ZZZZZZZZZZ9999999999'), operator.le, False), 135 | ((u.Infinity(), 'ZZZZZZZZZZ9999999999'), operator.eq, False), 136 | ((u.Infinity(), 'ZZZZZZZZZZ9999999999'), operator.ne, True), 137 | ((u.Infinity(), 'ZZZZZZZZZZ9999999999'), operator.gt, True), 138 | ((u.Infinity(), 'ZZZZZZZZZZ9999999999'), operator.ge, True), 139 | ((u.Infinity(), test_unit_high), operator.lt, False), 140 | ((u.Infinity(), test_unit_high), operator.le, False), 141 | ((u.Infinity(), test_unit_high), operator.eq, False), 142 | ((u.Infinity(), test_unit_high), operator.ne, True), 143 | ((u.Infinity(), test_unit_high), operator.gt, True), 144 | ((u.Infinity(), test_unit_high), operator.ge, True), 145 | ((-u.Infinity(), ' '), operator.lt, True), 146 | ((-u.Infinity(), ' '), operator.le, True), 147 | ((-u.Infinity(), ' '), operator.eq, False), 148 | ((-u.Infinity(), ' '), operator.ne, True), 149 | ((-u.Infinity(), ' '), operator.gt, False), 150 | ((-u.Infinity(), ' '), operator.ge, False), 151 | ((-u.Infinity(), test_unit_low), operator.lt, True), 152 | ((-u.Infinity(), test_unit_low), operator.le, True), 153 | ((-u.Infinity(), test_unit_low), operator.eq, False), 154 | ((-u.Infinity(), test_unit_low), operator.ne, True), 155 | ((-u.Infinity(), test_unit_low), operator.gt, False), 156 | ((-u.Infinity(), test_unit_low), operator.ge, False), 157 | ] 158 | 159 | 160 | # Tests 161 | 162 | @pytest.mark.parametrize('factory, args, kwargs, suffix', MEMOIZE_PARAMETERS) 163 | def test_memoize_vals(factory, args, kwargs, suffix): 164 | """The memoized function produced by ``factory`` should produce a 165 | value with the given ``suffix`` when passed the given ``args`` and 166 | ``kwargs`` values, and it should return the same value when called 167 | multiple times with the same arguments. 168 | """ 169 | function = factory() 170 | test_val = 'val{}'.format(suffix) 171 | return_val1 = function(*args, **kwargs) 172 | return_val2 = function(*args, **kwargs) 173 | assert return_val1 == test_val and return_val2 == test_val 174 | 175 | 176 | @pytest.mark.parametrize('factory, args, kwargs, suffix', MEMOIZE_PARAMETERS) 177 | def test_memoize_keys_key_generation(factory, args, kwargs, suffix): 178 | """The memoized function produced by ``factory`` should have a 179 | ``_cache`` dictionary attribute that has a key with the given 180 | ``suffix`` when passed the given ``args`` and ``kwargs`` values. 181 | """ 182 | function = factory() 183 | test_key = 'f{}'.format(suffix) 184 | function(*args, **kwargs) 185 | assert test_key in function._cache 186 | 187 | 188 | def test_memoize_bound_method_value(): 189 | """Memoizing a bound method should produce the correct value when 190 | called multiple times with the same arguments. 191 | """ 192 | test_object = MemoizeBoundMethodTester() 193 | return_val1 = test_object.args_kwargs('1', '2', kwarg2='3') 194 | return_val2 = test_object.args_kwargs('1', '2', kwarg2='3') 195 | test_val = 'val_1_2_one_3' 196 | assert return_val1 == test_val and return_val2 == test_val 197 | 198 | 199 | def test_memoize_bound_method_key(): 200 | """Memoizing a bound method should attach the ``_cache`` dict to 201 | the object the method is bound to rather than the function, with 202 | the correct key. 203 | """ 204 | test_object = MemoizeBoundMethodTester() 205 | test_object.args_kwargs('1', '2', kwarg2='3') 206 | test_key = 'args_kwargs_1_2_one_3' 207 | assert test_key in test_object._cache 208 | assert not hasattr(MemoizeBoundMethodTester.args_kwargs, '_cache') 209 | 210 | 211 | def test_memoize_multiple_calls(): 212 | """The memoized function produced by, e.g., the ``args_kwargs`` 213 | factory function should have a ``_cache`` dictionary attribute that 214 | retains key/value pairs whenever the function is called multiple 215 | times with different arguments. Calling the function multiple times 216 | with the same arguments should return the same result. 217 | """ 218 | function = args_kwargs() 219 | return_val1 = function('a', 'b') 220 | length1 = len(function._cache) 221 | return_val2 = function('1', '2', '3', '4') 222 | length2 = len(function._cache) 223 | return_val3 = function('a', 'b') 224 | length3 = len(function._cache) 225 | return_val4 = function('1', '2', '3', '4') 226 | length4 = len(function._cache) 227 | assert (return_val1 == return_val3 and return_val1 != return_val2 and 228 | return_val2 == return_val4 and length1 == 1 and length2 == 2 and 229 | length3 == 2 and length4 == 2) 230 | 231 | 232 | @pytest.mark.parametrize('x, max_line_width, indent_level, tab_width, y', 233 | PRETTY_PARAMETERS) 234 | def test_pretty_output(x, max_line_width, indent_level, tab_width, y): 235 | """The u.pretty function should give the output ``y`` when 236 | passed input text ``x`` and the given parameters: 237 | ``max_line_width``, ``indent_level``, and ``tab_width``. 238 | 239 | """ 240 | teststr = u.pretty(x, max_line_width, indent_level, tab_width) 241 | assert teststr == y 242 | 243 | 244 | @pytest.mark.parametrize('values, op, expected', INFINITY_COMP_PARAMS) 245 | def test_infinity_comparisons(values, op, expected): 246 | """The given values tuple should produce the expected truth value 247 | when compared via the given operator, op.""" 248 | assert op(*values) == expected 249 | --------------------------------------------------------------------------------