pax_global_header00006660000000000000000000000064145172054560014523gustar00rootroot0000000000000052 comment=b17984f5b4c6db0ef596b4d31456c8a39ae278ee ciso8601-2.3.1/000077500000000000000000000000001451720545600130025ustar00rootroot00000000000000ciso8601-2.3.1/.circleci/000077500000000000000000000000001451720545600146355ustar00rootroot00000000000000ciso8601-2.3.1/.circleci/config.yml000066400000000000000000000045411451720545600166310ustar00rootroot00000000000000version: 2.1 workflows: workflow: jobs: - test_python_34 - test: matrix: parameters: python_version: ["2.7", "3.5", "3.6", "3.7", "3.8", "3.9", "3.10", "3.11", "3.12"] - test_pypy: matrix: parameters: python_version: ["2.7", "3.7", "3.8", "3.9", "3.10"] - lint-rst - clang-format jobs: # `cimg/python` doesn't support Python 3.4, # but old `circleci/python` is still around! test_python_34: steps: - checkout - run: name: Test command: python setup.py test docker: - image: circleci/python:3.4 test: parameters: python_version: type: string steps: - checkout - run: name: Test command: python setup.py test docker: - image: cimg/python:<> test_pypy: parameters: python_version: type: string steps: - checkout - run: name: Test command: pypy setup.py test docker: - image: pypy:<> lint-rst: working_directory: ~/code steps: - checkout - run: name: Install lint tools command: | python3 -m venv venv . venv/bin/activate pip install Pygments restructuredtext-lint - run: name: Lint command: | . venv/bin/activate rst-lint --encoding=utf-8 README.rst docker: - image: cimg/python:3.12 clang-format: working_directory: ~/code steps: - checkout - run: name: Install lint tools command: | sudo apt-get update -y sudo apt-get install -y clang-format - run: name: Lint command: | SOURCE_FILES=`find ./ -name \*.c -type f -or -name \*.h -type f` for SOURCE_FILE in $SOURCE_FILES do export FORMATTING_ISSUE_COUNT=`clang-format -output-replacements-xml $SOURCE_FILE | grep offset | wc -l` if [ "$FORMATTING_ISSUE_COUNT" -gt "0" ]; then echo "Source file $SOURCE_FILE contains formatting issues. Please use clang-format tool to resolve found issues." exit 1 fi done docker: - image: cimg/python:3.12 ciso8601-2.3.1/.clang-format000066400000000000000000000010061451720545600153520ustar00rootroot00000000000000# A clang-format style that approximates Python's PEP 7 # Useful for IDE integration BasedOnStyle: Google AlwaysBreakAfterReturnType: All AllowShortIfStatementsOnASingleLine: false AlignAfterOpenBracket: Align AlignConsecutiveMacros: Consecutive BreakBeforeBraces: Stroustrup ColumnLimit: 79 DerivePointerAlignment: false IndentWidth: 4 Language: Cpp PointerAlignment: Right ReflowComments: true SpaceBeforeParens: ControlStatements SpacesBeforeTrailingComments: 2 SpacesInParentheses: false TabWidth: 4 UseTab: Never ciso8601-2.3.1/.github/000077500000000000000000000000001451720545600143425ustar00rootroot00000000000000ciso8601-2.3.1/.github/pull_request_template.md000066400000000000000000000013171451720545600213050ustar00rootroot00000000000000### What are you trying to accomplish? ... ### What approach did you choose and why? ... ### What should reviewers focus on? ... ### The impact of these changes ... ### Testing ... ciso8601-2.3.1/.github/workflows/000077500000000000000000000000001451720545600163775ustar00rootroot00000000000000ciso8601-2.3.1/.github/workflows/build-wheels.yml000066400000000000000000000130011451720545600215010ustar00rootroot00000000000000name: Build Wheels on: workflow_dispatch: inputs: requested_release_tag: description: 'The tag to use for this release (e.g., `v2.3.1`)' required: true jobs: sanity_check: runs-on: ubuntu-latest steps: - uses: actions/checkout@v3 - uses: actions/setup-python@v4 name: Install Python with: python-version: '3.12' - run: | pip install packaging - name: Normalize the release version run: | echo "release_version=`echo '${{ github.event.inputs.requested_release_tag }}' | sed 's/^v//'`" >> $GITHUB_ENV - name: Normalize the release tag run: | echo "release_tag=v${release_version}" >> $GITHUB_ENV - name: Get the VERSION from setup.py run: | echo "ciso8601_version=`grep -Po 'VERSION = "\K[^"]*' setup.py`" >> $GITHUB_ENV - name: Get the latest version from PyPI run: | curl https://pypi.org/pypi/ciso8601/json | python -c 'import json, sys; contents=sys.stdin.read(); parsed = json.loads(contents); print("pypi_version=" + parsed["info"]["version"])' >> $GITHUB_ENV - name: Log all the things run: | echo 'Requested release tag `${{ github.event.inputs.requested_release_tag }}`' echo 'Release version `${{ env.release_version }}`' echo 'Release tag `${{ env.release_tag }}`' echo 'VERSION in setup.py `${{ env.ciso8601_version }}`' echo 'Version in PyPI `${{ env.pypi_version }}`' - name: Verify that the version string we produced looks like a version string run: | echo "${{ env.release_version }}" | sed '/^[0-9]\+\.[0-9]\+\.[0-9]\+$/!{q1}' - name: Verify that the version tag we produced looks like a version tag run: | echo "${{ env.release_tag }}" | sed '/^v[0-9]\+\.[0-9]\+\.[0-9]\+$/!{q1}' - name: Verify that the release version matches the VERSION in setup.py run: | [[ ${{ env.release_version }} == ${{ env.ciso8601_version }} ]] - name: Verify that the `release_version` is larger/newer than the existing release in PyPI run: | python -c 'import sys; from packaging import version; code = 0 if version.parse("${{ env.pypi_version }}") < version.parse("${{ env.release_version }}") else 1; sys.exit(code)' - name: Verify that the `release_version` is present in the CHANGELOG # TODO: Use something like `changelog-cli` to extract the correct version number run: | grep ${{ env.release_version }} CHANGELOG.md - name: Serialize normalized release values run: | echo -e "release_version=${{ env.release_version }}\nrelease_tag=${{ env.release_tag }}" > release_values.txt - name: Share normalized release values uses: actions/upload-artifact@v3 with: name: release_values path: release_values.txt build_wheels: name: Build wheel on ${{ matrix.os }} needs: [sanity_check] runs-on: ${{ matrix.os }} strategy: matrix: os: [ubuntu-latest, windows-latest, macos-latest] steps: - uses: actions/checkout@v3 - name: Build wheels uses: closeio/cibuildwheel@v2.16.2 env: CIBW_SKIP: "pp*-macosx* *-win32 *-manylinux_i686" CIBW_ARCHS_MACOS: x86_64 arm64 universal2 - uses: actions/upload-artifact@v3 with: path: ./wheelhouse/*.whl build_sdist: name: Build source distribution needs: [sanity_check] runs-on: ubuntu-latest steps: - uses: actions/checkout@v3 - uses: actions/setup-python@v4 name: Install Python with: python-version: '3.12' - name: Get build tool run: pip install --upgrade build - name: Build sdist run: python -m build - uses: actions/upload-artifact@v3 with: path: dist/*.tar.gz # create_or_update_draft_release: # # TODO: Figure out how to do this in an idempotent way # name: Create or update draft release in GitHub # needs: [build_wheels, build_sdist, sanity_check] # runs-on: ubuntu-latest # steps: # - name: Get normalized release values # uses: actions/download-artifact@v2 # with: # name: release_values # - name: Load normalized release values # run: | # xargs -a release_values.txt -l -I{} bash -c 'echo {} >> $GITHUB_ENV' # - uses: actions/create-release@v1 # env: # GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} # This token is provided by Actions # with: # tag_name: ${{ env.release_tag }} # commitish: master # Default branch # release_name: ${{ env.release_tag }} # body: "" # TODO: Pull this in using `changelog-cli` # draft: true # upload_to_pypi: # name: Upload wheels to PyPI # needs: [build_wheels, build_sdist, sanity_check] # runs-on: ubuntu-latest # steps: # - uses: actions/download-artifact@v2 # with: # name: artifact # path: dist # - uses: closeio/gh-action-pypi-publish@v1.4.2 # with: # user: __token__ # password: ${{ secrets.PYPI_PASSWORD }} # # repository_url: https://test.pypi.org/legacy/ # Test PyPI # create_release: # name: Create release in GitHub # needs: [upload_to_pypi] # runs-on: ubuntu-latest # steps: # TODO # - run: | # echo "We're doing a release!? ${{ github.event }}" ciso8601-2.3.1/.gitignore000066400000000000000000000010471451720545600147740ustar00rootroot00000000000000# Byte-compiled / optimized / DLL files __pycache__/ *.py[cod] # C extensions *.so # Distribution / packaging .Python env/ bin/ build/ develop-eggs/ dist/ eggs/ lib/ lib64/ parts/ sdist/ var/ *.egg-info/ .installed.cfg *.egg *.eggs # Installer logs pip-log.txt pip-delete-this-directory.txt # Unit test / coverage reports htmlcov/ .tox/ .coverage .cache nosetests.xml coverage.xml # Translations *.mo # Mr Developer .mr.developer.cfg .project .pydevproject # Rope .ropeproject # Django stuff: *.log *.pot # Sphinx documentation docs/_build/ ciso8601-2.3.1/CHANGELOG.md000066400000000000000000000201161451720545600146130ustar00rootroot00000000000000 - [Unreleased](#unreleased) - [2.x.x](#2xx) - [Version 2.3.1](#version-231) - [Version 2.3.0](#version-230) - [Version 2.2.0](#version-220) - [Version 2.1.3](#version-213) - [Version 2.1.2](#version-212) - [Version 2.1.1](#version-211) - [Version 2.1.0](#version-210) - [Version 2.0.1](#version-201) - [Version 2.0.0](#version-200) - [Breaking changes](#breaking-changes) - [Other Changes](#other-changes) - [v1.x.x -\> 2.0.0 Migration guide](#v1xx---200-migration-guide) - [ValueError instead of None](#valueerror-instead-of-none) - [Tightened ISO 8601 conformance](#tightened-iso-8601-conformance) - [`parse_datetime_unaware` has been renamed](#parse_datetime_unaware-has-been-renamed) # Unreleased * # 2.x.x ## Version 2.3.1 * Added Python 3.12 wheels ## Version 2.3.0 * Added Python 3.11 support * Fix the build for PyPy2 ([#116](https://github.com/closeio/ciso8601/pull/116)) * Added missing `fromutc` implementation for `FixedOffset` (#113). Thanks @davidkraljic * Removed improper ability to call `FixedOffset`'s `dst`, `tzname` and `utcoffset` without arguments * Fixed: `datetime.tzname` returns a `str` in Python 2.7, not a `unicode` * Change `METH_VARARGS` to `METH_O`, enhancing performance. ([#130](https://github.com/closeio/ciso8601/pull/130)) * Added support for ISO week dates, ([#139](https://github.com/closeio/ciso8601/pull/139)) * Added support for ordinal dates, ([#140](https://github.com/closeio/ciso8601/pull/140)) ## Version 2.2.0 * Added Python 3.9 support * Switched to using a C implementation of `timezone` objects. * Much faster parse times for timestamps with timezone information * ~2.5x faster on Python 2.7, ~10% faster on Python 3.9 * Thanks to [`pendulum`](https://github.com/sdispater/pendulum) and @sdispater for the code. * Python 2.7 users no longer need to install `pytz` dependency :smiley: * Added caching of tzinfo objects * Parsing is ~1.1x faster for subsequent timestamps that have the same time zone offset. * Caching can be disabled at compile time by setting the `CISO8601_CACHING_ENABLED=0` environment variable * Fixed a memory leak in the case where an invalid timestamp had a non-UTC timezone and extra characters ## Version 2.1.3 * Fixed a problem where non-ASCII characters would give bad error messages (#84). Thanks @olliemath. ## Version 2.1.2 * Fixed a problem where `ciso8601.__version__` was not working (#80). Thanks @ianhoffman. * Added Python 3.8 support (#83) * Added benchmarking scripts (#55) ## Version 2.1.1 * Fixed a problem where builds on Windows were not working (#76). Thanks @alexandrul and @gillesdouaire, and sorry. ## Version 2.1.0 * Added [Mypy](http://mypy-lang.org/)/[PEP 484](https://www.python.org/dev/peps/pep-0484/) typing information (#68, Thanks @NickG123). * Added a new function: `parse_rfc3339`, which strictly parses RFC 3339 (#70). * No longer accept mixed "basic" and "extended" format timestamps (#73). * e.g., `20140203T23:35:27` and `2014-02-03T233527` are not valid in ISO 8601, but were not raising `ValueError`. * Attempting to parse such timestamps now raises `ValueError` ## Version 2.0.1 * Fixed some memory leaks introduced in 2.0.0 (#51) ## Version 2.0.0 Version 2.0.0 was a major rewrite of `ciso8601`. Version 1.x.x had a problem with error handling in the case of invalid timestamps. In 1.x.x, parse_datetime: * All valid datetime strings within the supported subset of ISO 8601 would result in the correct Python datetime (this was good) * Some invalid timestamps will return `None` and others might get truncated and return an incorrect Python datetime (this was bad) A developer with a given timestamp string, could not predict a priori what `ciso8601` is going to return without looking at the code. Fundamentally, this is the problem that version 2 addressed. Fundamentally, `parse_datetime(dt: String): datetime` was rewritten so that it takes a string and either: * Returns a properly parsed Python datetime, **if and only if** that **entire** string conforms to the supported subset of ISO 8601 * Raises an `ValueError` with a description of the reason why the string doesn't conform to the supported subset of ISO 8601 ### Breaking changes 1. Version 2 now raises `ValueError` when a timestamp does not conform to the supported subset of ISO 8601 * This includes trailing characters in the timestamp * No longer accepts single character "day" values * See migration guide below for more examples 2. `parse_datetime_unaware` was renamed to `parse_datetime_as_naive` (See "Migration Guide" below for reasons) ### Other Changes * Attempting to parse a timestamp with time zone information without having pytz installed raises `ImportError` (Only affects Python 2.7). Fixes #19 * Added support for the special case of midnight (24:00:00) that is valid in ISO 8601. Fixes #41 * Fixed bug where "20140200" would not fail, but produce 2014-02-01. Fixes #42 ### v1.x.x -> 2.0.0 Migration guide #### ValueError instead of None Places where you were checking for a return of `None` from ciso8601: ```python timestamp = "2018-01-01T00:00:00+05:00" dt = parse_datetime(timestamp) if dt is None: raise ValueError(f"Could not parse {timestamp}") ``` You should change to now expect `ValueError` to be thrown: ```python timestamp = "2018-01-01T00:00:00+05:00" dt = parse_datetime(timestamp) ``` #### Tightened ISO 8601 conformance The rules with respect to what ciso8601 will consider a conforming ISO 8601 string have been tightened. Now a timestamp will parse **if and only if** the timestamp is 100% conforming to the supported subset of the ISO 8601 specification. ```python # trailing separator "2014-" "2014-01-" "2014-01-01T" "2014-01-01T00:" "2014-01-01T00:00:" "2014-01-01T00:00:00-" "2014-01-01T00:00:00-00:" # Mix of no-separator and separator "201401-02" "2014-0102" "2014-01-02T00:0000" "2014-01-02T0000:00" "2014-01-02T01:23:45Zabcdefghij" # Trailing characters "2014-01-1" # Single digit day "2014-01-01T00:00:00-0:04" # Single digit tzhour "2014-01-01T00:00:00-00:4" # Single digit tzminute ``` These should have been considered bugs in ciso8601 1.x.x, but it may be the case that your code was relying on the previously lax parsing rules. #### `parse_datetime_unaware` has been renamed `parse_datetime_unaware` existed for the case where your input timestamp had time zone information, but you wanted to ignore the time zone information and therefore could save some cycles by not creating the underlying `tzinfo` object. It has been renamed to `parse_datetime_as_naive` for 2 reasons: 1. Developers were assuming that `parse_datetime_unaware` was the function to use for parsing naive timestamps, when really it is for parsing timestamps with time zone information as naive datetimes. `parse_datetime` handles parsing both timestamps with and without time zone information and should be used for all parsing, unless you actually need this use case. See additional description in [the README](https://github.com/closeio/ciso8601/tree/raise-valueerror-on-invalid-dates#ignoring-timezone-information-while-parsing) for a more detailed description of this use case. 2. Python [refers to datetimes without time zone information](https://docs.python.org/3/library/datetime.html) as `naive`, not `unaware` Before switching all instances of `parse_datetime_unaware`, make sure to ask yourself whether you actually intended to use `parse_datetime_unaware`. * If you meant to parse naive timestamps as naive datetimes, use `parse_datetime` instead. * If you actually meant to parse **timestamps with time zone information** as **naive** datetimes, use `parse_datetime_as_naive` instead. | | Input with TZ Info | Input without TZ Info | | ---------------------------------- | ------------------ | --------------------- | | `parse_datetime()` output | tz aware datetime | tz naive datetime | | `parse_datetime_as_naive()` output | tz naive datetime | tz naive datetime | ciso8601-2.3.1/CONTRIBUTING.md000066400000000000000000000265321451720545600152430ustar00rootroot00000000000000# Contributing to ciso8601 :+1::tada: First off, thanks for taking the time to contribute! :tada::+1: The following is a set of guidelines for contributing to ciso8601, which are hosted in the [Close.io Organization](https://github.com/closeio) on GitHub. These are mostly guidelines, not rules. Use your best judgment, and feel free to propose changes to this document in a pull request. #### Table Of Contents [I don't want to read this whole thing, I just have a question!!!](#i-dont-want-to-read-this-whole-thing-i-just-have-a-question) [Design Philosophy](#design-philosophy) [How Can I Contribute?](#how-can-i-contribute) * [Reporting Bugs](#reporting-bugs) * [Suggesting Enhancements](#suggesting-enhancements) * [Developing ciso8601 code](#developing-ciso8601-code) * [General Workflow](#general-workflow) * [C Coding Style](#c-coding-style) * [Supported Python Versions](#supported-python-versions) * [Supported Operating Systems](#supported-operating-systems) * [Functional Testing](#functional-testing) * [Performance Benchmarking](#performance-benchmarking) * [Documentation](#documentation) * [Pull Requests](#pull-requests) ## I don't want to read this whole thing I just have a question!!! Sure. First [search the existing issues](https://github.com/closeio/ciso8601/issues?utf8=%E2%9C%93&q=is%3Aissue) to see if one of the existing issues answers it. If not, simply [create an issue](https://github.com/closeio/ciso8601/issues/new) and ask your question. ## Design Philosophy ciso8601's goal is to be the fastest ISO 8601 parser available for Python. It probably will never support the complete grammar of ISO 8601, but it will be correct for the chosen subset of the grammar. It will also be robust against non-conforming inputs. Beyond that, performance is king. That said, some care should still be taken to ensure cross-platform compatibility and maintainability. For example, this means that we do not hand-code assembly instructions for a specific CPUs/architectures, and instead rely on the native C compilers to take advantage of specific hardware. We are not against the idea of platform-specific code in principle, but it would have to be shown to be produce sufficient benefits to warrant the additional maintenance overhead. ## How Can I Contribute? ### Reporting Bugs This section guides you through submitting a bug report for ciso8601. Following these guidelines helps maintainers and the community understand your report :pencil:, reproduce the behavior :computer: :computer:, and find related reports :mag_right:. Before creating bug reports, please check [this list](#before-submitting-a-bug-report) as you might find out that you don't need to create one. When you are creating a bug report, please [include as many details as possible](#how-do-i-submit-a-good-bug-report). > **Note:** If you find a **Closed** issue that seems like it is the same thing that you're experiencing, open a new issue and include a link to the original issue in the body of your new one. #### Before Submitting A Bug Report * **Perform a [cursory search](https://github.com/closeio/ciso8601/issues?utf8=%E2%9C%93&q=is%3Aissue)** to see if the problem has already been reported. If it has **and the issue is still open**, add a comment to the existing issue instead of opening a new one. #### How Do I Submit A (Good) Bug Report? Bugs are tracked as [GitHub issues](https://guides.github.com/features/issues/). Create an issue on the repository and provide the following information. Explain the problem and include additional details to help maintainers reproduce the problem: * **Use a clear and descriptive title** for the issue to identify the problem. * **Describe the exact steps which reproduce the problem** in as many details as possible. * **Provide specific examples to demonstrate the steps**. Include snippets of code that reproduce the problem (Make sure to use [Markdown code blocks](https://help.github.com/articles/markdown-basics/#multiple-lines) so that it gets formatted in a readable way). * **Describe the behavior you observed after following the steps** and point out what exactly is the problem with that behavior. * **Explain which behavior you expected to see instead and why.** * **Can you reliably reproduce the issue?** If not, provide details about how often the problem happens and under which conditions it normally happens. Include details about your configuration and environment: * **Which version of ciso8601 are you using?** You can get the exact version by running `pip list` in your terminal. If you are not using [the latest version](https://github.com/closeio/ciso8601/releases), does the problem still happen in the latest version? * **What's the name and version of the OS you're using**? ### Suggesting Enhancements This section guides you through submitting an enhancement suggestion for ciso8601, including completely new features and minor improvements to existing functionality. Following these guidelines helps maintainers and the community understand your suggestion :pencil: and find related suggestions :mag_right:. Before creating enhancement suggestions, please check [this list](#before-submitting-an-enhancement-suggestion) as you might find out that you don't need to create one. When you are creating an enhancement suggestion, please [include as many details as possible](#how-do-i-submit-a-good-enhancement-suggestion). #### Before Submitting An Enhancement Suggestion * **Perform a [cursory search](https://github.com/closeio/ciso8601/issues?utf8=%E2%9C%93&q=is%3Aissue)** to see if the enhancement has already been suggested. If it has, don't create a new issue. Consider adding a :+1: [reaction](https://blog.github.com/2016-03-10-add-reactions-to-pull-requests-issues-and-comments/) to the issue description. If you feel that your use case is sufficiently different, add a comment to the existing issue instead of opening a new one. #### How Do I Submit A (Good) Enhancement Suggestion? Enhancement suggestions are tracked as [GitHub issues](https://guides.github.com/features/issues/). Create an issue on the repository and provide the following information: * **Use a clear and descriptive title** for the issue to identify the suggestion. * **Provide a step-by-step description of the suggested enhancement** in as many details as possible. * **Provide specific examples to demonstrate the steps**. Include copy/pasteable snippets which you use in those examples, as [Markdown code blocks](https://help.github.com/articles/markdown-basics/#multiple-lines). * **Describe the current behavior** and **explain which behavior you expected to see instead** and why. * **Explain why this enhancement would be useful** to most ciso8601 users and therefore should be implemented in ciso8601. * **List some other libraries where this enhancement exists** (if you know of any). * **Specify which version of ciso8601 you're using.** You can get the exact version by running `pip list` in your terminal. If you are not using [the latest version](https://github.com/closeio/ciso8601/releases), is the enhancement still needed in the latest version? * **Specify the name and version of the OS you're using.** ### Developing ciso8601 code #### General Workflow ciso8601 uses the same contributor workflow as many other projects hosted on GitHub. 1. Fork the [ciso8601 repo](https://github.com/closeio/ciso8601) (so it becomes `yourname/ciso8601`). 1. Clone that repo (`git clone https://github.com/yourname/ciso8601.git`). 1. Create a new branch (`git checkout -b my-descriptive-branch-name`). 1. Make your changes and commit to that branch (`git commit`). 1. Push your changes to GitHub (`git push`). 1. Create a Pull Request within GitHub's UI. See [this guide](https://opensource.guide/how-to-contribute/#opening-a-pull-request) for more information about each step. #### C Coding Style ciso8601 tries to adhere to the [Python PEP 7](https://www.python.org/dev/peps/pep-0007/) style guide. You can use [ClangFormat](https://clang.llvm.org/docs/ClangFormat.html) to make this mostly automatic. The auto-formatting rules are defined in the [.clang-format](.clang-format) file. If you are using Visual Studio Code as your editor, you can use the ["C/C++"](https://marketplace.visualstudio.com/items?itemName=ms-vscode.cpptools) extension and it will automatically start auto-formatting. #### Supported Python Versions ciso8601 supports a variety of cPython versions, including Python 2.7 (for the full list see the [README](README.rst)). Please make sure that you do not accidentally make use of features that are specific to certain versions of Python. Feel free to make use of modern features of the languages, but you also need to provide mechanisms to support the other versions as well. You can make use of `#ifdef` blocks within the code to make use of version specific features (there are already several examples throughout the code). #### Supported Operating Systems ciso8601 supports running on multiple operating systems, including Windows. Notably, for Python 2.7 on Windows, the compiler (MSVC) places additional restrictions on the C language constructs you can use. Make sure to test changes on both a Windows (MSVC) and Linux (gcc) machine to ensure compatibility. #### Functional Testing ciso8601's functionality/unit tests are found in the [tests.py](tests.py) file. The [`tox`](https://tox.readthedocs.io/en/latest/) command can be used to run the tests: ```bash pip install tox ... tox ``` This will automatically run [nosetests](https://nose.readthedocs.io/en/latest/man.html) command (as specified in the [`tox.ini`](tox.ini) file) to find and run all the tests. Make sure that you have at least the latest stable Python 3 interpreter and the latest Python 2.7 interpreter installed. Any new functionality being developed for ciso8601 should also have tests being written for it. Tests should cover both the "sunny day" (expected, valid input) and "rainy day" (invalid input or error) cases. Many of ciso8601's functionality tests are auto-generated. The code that does this generation is found in the [`generate_test_timestamps.py`](generate_test_timestamps.py) file. It can sometimes be useful to print out all of the test cases and their expected outputs: ```python from generate_test_timestamps import generate_valid_timestamp_and_datetime for timestamp, expected_datetime in generate_valid_timestamp_and_datetime(): print("Input: {0}, Expected: {1}".format(timestamp, expected_datetime)) ``` #### Performance Benchmarking The ciso8601 project was born out of a need for a fast ISO 8601 parser. Therefore the project is concerned with the performance of the library. Changes should be assessed for their performance impact, and the results should be included as part of the Pull Request. #### Documentation All changes in functionality should be documented in the [`README.rst`](README.rst) file. Note that this file uses the [reStructuredText](https://en.wikipedia.org/wiki/ReStructuredText) format, since the file is rendered as part of [ciso8601's entry in PyPI](https://pypi.org/project/ciso8601/), which only supports reStructuredText. You can check your reStructured text for syntax errors using (restructuredtext-lint)[https://github.com/twolfson/restructuredtext-lint]: ``` pip install Pygments restructuredtext-lint rst-lint --encoding=utf-8 README.rst ``` #### Pull Requests * Follow the [C Code](#c-coding-style) style guide. * Document new code and functionality [See "Documentation"](#documentation) ciso8601-2.3.1/LICENSE000066400000000000000000000020621451720545600140070ustar00rootroot00000000000000The MIT License (MIT) Copyright (c) 2014 Close.io Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.ciso8601-2.3.1/MANIFEST.in000066400000000000000000000001411451720545600145340ustar00rootroot00000000000000include LICENSE include README.rst include CHANGELOG.md include isocalendar.h include timezone.h ciso8601-2.3.1/README.rst000066400000000000000000000576771451720545600145170ustar00rootroot00000000000000======== ciso8601 ======== .. image:: https://img.shields.io/circleci/project/github/closeio/ciso8601.svg :target: https://circleci.com/gh/closeio/ciso8601/tree/master .. image:: https://img.shields.io/pypi/v/ciso8601.svg :target: https://pypi.org/project/ciso8601/ .. image:: https://img.shields.io/pypi/pyversions/ciso8601.svg :target: https://pypi.org/project/ciso8601/ ``ciso8601`` converts `ISO 8601`_ or `RFC 3339`_ date time strings into Python datetime objects. Since it's written as a C module, it is much faster than other Python libraries. Tested with cPython 2.7, 3.4, 3.5, 3.6, 3.7, 3.8, 3.9, 3.10, 3.11, 3.12. .. _ISO 8601: https://en.wikipedia.org/wiki/ISO_8601 .. _RFC 3339: https://tools.ietf.org/html/rfc3339 (Interested in working on projects like this? `Close`_ is looking for `great engineers`_ to join our team) .. _Close: https://close.com .. _great engineers: https://jobs.close.com .. contents:: Contents Quick start ----------- .. code:: bash % pip install ciso8601 .. code:: python In [1]: import ciso8601 In [2]: ciso8601.parse_datetime('2014-12-05T12:30:45.123456-05:30') Out[2]: datetime.datetime(2014, 12, 5, 12, 30, 45, 123456, tzinfo=pytz.FixedOffset(330)) In [3]: ciso8601.parse_datetime('20141205T123045') Out[3]: datetime.datetime(2014, 12, 5, 12, 30, 45) Migration to v2 --------------- Version 2.0.0 of ``ciso8601`` changed the core implementation. This was not entirely backwards compatible, and care should be taken when migrating See `CHANGELOG`_ for the Migration Guide. .. _CHANGELOG: https://github.com/closeio/ciso8601/blob/master/CHANGELOG.md When should I not use ``ciso8601``? ----------------------------------- ``ciso8601`` is not necessarily the best solution for every use case (especially since Python 3.11). See `Should I use ciso8601?`_ .. _`Should I use ciso8601?`: https://github.com/closeio/ciso8601/blob/master/why_ciso8601.md Error handling -------------- Starting in v2.0.0, ``ciso8601`` offers strong guarantees when it comes to parsing strings. ``parse_datetime(dt: String): datetime`` is a function that takes a string and either: * Returns a properly parsed Python datetime, **if and only if** the **entire** string conforms to the supported subset of ISO 8601 * Raises a ``ValueError`` with a description of the reason why the string doesn't conform to the supported subset of ISO 8601 If time zone information is provided, an aware datetime object will be returned. Otherwise, a naive datetime is returned. Benchmark --------- Parsing a timestamp with no time zone information (e.g., ``2014-01-09T21:48:00``): .. .. table:: +--------------------------------+-----------+-----------+-----------+----------+--------------------------------------------------+-+----------+----------+-------------------------------+ | Module |Python 3.12|Python 3.11|Python 3.10|Python 3.9|Relative slowdown (versus ciso8601, latest Python)|…|Python 3.8|Python 3.7| Python 2.7 | +================================+===========+===========+===========+==========+==================================================+=+==========+==========+===============================+ |ciso8601 |98 nsec |90 nsec |122 nsec |122 nsec |N/A |…|118 nsec |124 nsec |134 nsec | +--------------------------------+-----------+-----------+-----------+----------+--------------------------------------------------+-+----------+----------+-------------------------------+ |backports.datetime_fromisoformat|N/A |N/A |112 nsec |108 nsec |0.9x |…|106 nsec |118 nsec |N/A | +--------------------------------+-----------+-----------+-----------+----------+--------------------------------------------------+-+----------+----------+-------------------------------+ |datetime (builtin) |129 nsec |132 nsec |N/A |N/A |1.3x |…|N/A |N/A |N/A | +--------------------------------+-----------+-----------+-----------+----------+--------------------------------------------------+-+----------+----------+-------------------------------+ |pendulum |N/A |180 nsec |187 nsec |186 nsec |2.0x |…|196 nsec |200 nsec |8.52 usec | +--------------------------------+-----------+-----------+-----------+----------+--------------------------------------------------+-+----------+----------+-------------------------------+ |udatetime |695 nsec |662 nsec |674 nsec |692 nsec |7.1x |…|724 nsec |713 nsec |586 nsec | +--------------------------------+-----------+-----------+-----------+----------+--------------------------------------------------+-+----------+----------+-------------------------------+ |str2date |6.86 usec |5.78 usec |6.59 usec |6.4 usec |70.0x |…|6.66 usec |6.96 usec |❌ | +--------------------------------+-----------+-----------+-----------+----------+--------------------------------------------------+-+----------+----------+-------------------------------+ |iso8601utils |N/A |N/A |N/A |8.59 usec |70.5x |…|8.6 usec |9.59 usec |11.2 usec | +--------------------------------+-----------+-----------+-----------+----------+--------------------------------------------------+-+----------+----------+-------------------------------+ |iso8601 |10 usec |8.24 usec |8.96 usec |9.21 usec |102.2x |…|9.14 usec |9.63 usec |25.7 usec | +--------------------------------+-----------+-----------+-----------+----------+--------------------------------------------------+-+----------+----------+-------------------------------+ |isodate |11.1 usec |8.76 usec |10.2 usec |9.76 usec |113.6x |…|9.92 usec |11 usec |44.1 usec | +--------------------------------+-----------+-----------+-----------+----------+--------------------------------------------------+-+----------+----------+-------------------------------+ |PySO8601 |17.2 usec |13.6 usec |16 usec |15.8 usec |175.3x |…|16.1 usec |17.1 usec |17.7 usec | +--------------------------------+-----------+-----------+-----------+----------+--------------------------------------------------+-+----------+----------+-------------------------------+ |aniso8601 |22.2 usec |17.8 usec |23.2 usec |23.1 usec |227.0x |…|24.3 usec |27.2 usec |30.7 usec | +--------------------------------+-----------+-----------+-----------+----------+--------------------------------------------------+-+----------+----------+-------------------------------+ |zulu |23.3 usec |19 usec |22 usec |21.3 usec |237.9x |…|21.6 usec |22.7 usec |N/A | +--------------------------------+-----------+-----------+-----------+----------+--------------------------------------------------+-+----------+----------+-------------------------------+ |maya |N/A |36.1 usec |42.5 usec |42.7 usec |401.6x |…|41.3 usec |44.2 usec |N/A | +--------------------------------+-----------+-----------+-----------+----------+--------------------------------------------------+-+----------+----------+-------------------------------+ |python-dateutil |57.6 usec |51.4 usec |63.3 usec |62.6 usec |587.7x |…|63.7 usec |67.3 usec |119 usec | +--------------------------------+-----------+-----------+-----------+----------+--------------------------------------------------+-+----------+----------+-------------------------------+ |arrow |62 usec |54 usec |65.5 usec |65.7 usec |633.0x |…|66.6 usec |70.2 usec |78.8 usec | +--------------------------------+-----------+-----------+-----------+----------+--------------------------------------------------+-+----------+----------+-------------------------------+ |metomi-isodatetime |1.29 msec |1.33 msec |1.76 msec |1.77 msec |13201.1x |…|1.79 msec |1.91 msec |N/A | +--------------------------------+-----------+-----------+-----------+----------+--------------------------------------------------+-+----------+----------+-------------------------------+ |moment |1.81 msec |1.65 msec |1.75 msec |1.79 msec |18474.8x |…|1.78 msec |1.84 msec |N/A | +--------------------------------+-----------+-----------+-----------+----------+--------------------------------------------------+-+----------+----------+-------------------------------+ ciso8601 takes 98 nsec, which is **1.3x faster than datetime (builtin)**, the next fastest Python 3.12 parser in this comparison. .. Parsing a timestamp with time zone information (e.g., ``2014-01-09T21:48:00-05:30``): .. .. table:: +--------------------------------+-----------+-----------+-----------+----------+--------------------------------------------------+-+----------+----------+-------------------------------+ | Module |Python 3.12|Python 3.11|Python 3.10|Python 3.9|Relative slowdown (versus ciso8601, latest Python)|…|Python 3.8|Python 3.7| Python 2.7 | +================================+===========+===========+===========+==========+==================================================+=+==========+==========+===============================+ |ciso8601 |95 nsec |96.8 nsec |128 nsec |123 nsec |N/A |…|125 nsec |125 nsec |140 nsec | +--------------------------------+-----------+-----------+-----------+----------+--------------------------------------------------+-+----------+----------+-------------------------------+ |backports.datetime_fromisoformat|N/A |N/A |147 nsec |149 nsec |1.1x |…|138 nsec |149 nsec |N/A | +--------------------------------+-----------+-----------+-----------+----------+--------------------------------------------------+-+----------+----------+-------------------------------+ |datetime (builtin) |198 nsec |207 nsec |N/A |N/A |2.1x |…|N/A |N/A |N/A | +--------------------------------+-----------+-----------+-----------+----------+--------------------------------------------------+-+----------+----------+-------------------------------+ |pendulum |N/A |225 nsec |214 nsec |211 nsec |2.3x |…|219 nsec |224 nsec |13.5 usec | +--------------------------------+-----------+-----------+-----------+----------+--------------------------------------------------+-+----------+----------+-------------------------------+ |udatetime |799 nsec |803 nsec |805 nsec |830 nsec |8.4x |…|827 nsec |805 nsec |768 nsec | +--------------------------------+-----------+-----------+-----------+----------+--------------------------------------------------+-+----------+----------+-------------------------------+ |str2date |7.73 usec |6.75 usec |7.78 usec |7.8 usec |81.4x |…|7.74 usec |8.13 usec |❌ | +--------------------------------+-----------+-----------+-----------+----------+--------------------------------------------------+-+----------+----------+-------------------------------+ |iso8601 |13.7 usec |11.3 usec |12.7 usec |12.5 usec |143.8x |…|12.4 usec |12.6 usec |31.1 usec | +--------------------------------+-----------+-----------+-----------+----------+--------------------------------------------------+-+----------+----------+-------------------------------+ |isodate |13.7 usec |11.3 usec |12.9 usec |12.7 usec |144.0x |…|12.7 usec |13.9 usec |46.7 usec | +--------------------------------+-----------+-----------+-----------+----------+--------------------------------------------------+-+----------+----------+-------------------------------+ |iso8601utils |N/A |N/A |N/A |21.4 usec |174.9x |…|22.1 usec |23.4 usec |28.3 usec | +--------------------------------+-----------+-----------+-----------+----------+--------------------------------------------------+-+----------+----------+-------------------------------+ |PySO8601 |25.1 usec |20.4 usec |23.2 usec |23.8 usec |263.8x |…|23.5 usec |24.8 usec |25.3 usec | +--------------------------------+-----------+-----------+-----------+----------+--------------------------------------------------+-+----------+----------+-------------------------------+ |zulu |26.3 usec |21.4 usec |25.7 usec |24 usec |277.2x |…|24.5 usec |25.3 usec |N/A | +--------------------------------+-----------+-----------+-----------+----------+--------------------------------------------------+-+----------+----------+-------------------------------+ |aniso8601 |27.7 usec |23.7 usec |30.3 usec |30 usec |291.3x |…|31.6 usec |33.8 usec |39.2 usec | +--------------------------------+-----------+-----------+-----------+----------+--------------------------------------------------+-+----------+----------+-------------------------------+ |maya |N/A |36 usec |41.3 usec |41.8 usec |372.0x |…|42.4 usec |42.7 usec |N/A | +--------------------------------+-----------+-----------+-----------+----------+--------------------------------------------------+-+----------+----------+-------------------------------+ |python-dateutil |70.7 usec |65.1 usec |77.9 usec |80.2 usec |744.0x |…|79.4 usec |83.6 usec |100 usec | +--------------------------------+-----------+-----------+-----------+----------+--------------------------------------------------+-+----------+----------+-------------------------------+ |arrow |73 usec |62.8 usec |74.5 usec |73.9 usec |768.6x |…|75.1 usec |80 usec |148 usec | +--------------------------------+-----------+-----------+-----------+----------+--------------------------------------------------+-+----------+----------+-------------------------------+ |metomi-isodatetime |1.22 msec |1.25 msec |1.72 msec |1.72 msec |12876.3x |…|1.76 msec |1.83 msec |N/A | +--------------------------------+-----------+-----------+-----------+----------+--------------------------------------------------+-+----------+----------+-------------------------------+ |moment |❌ |❌ |❌ |❌ |2305822.8x |…|❌ |❌ |N/A | +--------------------------------+-----------+-----------+-----------+----------+--------------------------------------------------+-+----------+----------+-------------------------------+ ciso8601 takes 95 nsec, which is **2.1x faster than datetime (builtin)**, the next fastest Python 3.12 parser in this comparison. .. .. Tested on Linux 5.15.49-linuxkit using the following modules: .. code:: python aniso8601==9.0.1 arrow==1.3.0 (on Python 3.8, 3.9, 3.10, 3.11, 3.12), arrow==1.2.3 (on Python 3.7), arrow==0.17.0 (on Python 2.7) backports.datetime_fromisoformat==2.0.1 ciso8601==2.3.0 iso8601==2.1.0 (on Python 3.8, 3.9, 3.10, 3.11, 3.12), iso8601==0.1.16 (on Python 2.7) iso8601utils==0.1.2 isodate==0.6.1 maya==0.6.1 metomi-isodatetime==1!3.1.0 moment==0.12.1 pendulum==2.1.2 PySO8601==0.2.0 python-dateutil==2.8.2 str2date==0.905 udatetime==0.0.17 zulu==2.0.0 .. For full benchmarking details (or to run the benchmark yourself), see `benchmarking/README.rst`_ .. _`benchmarking/README.rst`: https://github.com/closeio/ciso8601/blob/master/benchmarking/README.rst Supported subset of ISO 8601 ---------------------------- .. |datetime.fromisoformat| replace:: ``datetime.fromisoformat`` .. _datetime.fromisoformat: https://docs.python.org/3/library/datetime.html#datetime.datetime.fromisoformat ``ciso8601`` only supports a subset of ISO 8601, but supports a superset of what is supported by Python itself (|datetime.fromisoformat|_), and supports the entirety of the `RFC 3339`_ specification. Date formats ^^^^^^^^^^^^ The following date formats are supported: .. table:: :widths: auto ============================= ============== ================== Format Example Supported ============================= ============== ================== ``YYYY-MM-DD`` (extended) ``2018-04-29`` ✅ ``YYYY-MM`` (extended) ``2018-04`` ✅ ``YYYYMMDD`` (basic) ``20180429`` ✅ ``YYYY-Www-D`` (week date) ``2009-W01-1`` ✅ ``YYYY-Www`` (week date) ``2009-W01`` ✅ ``YYYYWwwD`` (week date) ``2009W011`` ✅ ``YYYYWww`` (week date) ``2009W01`` ✅ ``YYYY-DDD`` (ordinal date) ``1981-095`` ✅ ``YYYYDDD`` (ordinal date) ``1981095`` ✅ ============================= ============== ================== Uncommon ISO 8601 date formats are not supported: .. table:: :widths: auto ============================= ============== ================== Format Example Supported ============================= ============== ================== ``--MM-DD`` (omitted year) ``--04-29`` ❌ ``--MMDD`` (omitted year) ``--0429`` ❌ ``±YYYYY-MM`` (>4 digit year) ``+10000-04`` ❌ ``+YYYY-MM`` (leading +) ``+2018-04`` ❌ ``-YYYY-MM`` (negative -) ``-2018-04`` ❌ ============================= ============== ================== Time formats ^^^^^^^^^^^^ Times are optional and are separated from the date by the letter ``T``. Consistent with `RFC 3339`__, ``ciso8601`` also allows either a space character, or a lower-case ``t``, to be used instead of a ``T``. __ https://stackoverflow.com/questions/522251/whats-the-difference-between-iso-8601-and-rfc-3339-date-formats The following time formats are supported: .. table:: :widths: auto =================================== =================== ============== Format Example Supported =================================== =================== ============== ``hh`` ``11`` ✅ ``hhmm`` ``1130`` ✅ ``hh:mm`` ``11:30`` ✅ ``hhmmss`` ``113059`` ✅ ``hh:mm:ss`` ``11:30:59`` ✅ ``hhmmss.ssssss`` ``113059.123456`` ✅ ``hh:mm:ss.ssssss`` ``11:30:59.123456`` ✅ ``hhmmss,ssssss`` ``113059,123456`` ✅ ``hh:mm:ss,ssssss`` ``11:30:59,123456`` ✅ Midnight (special case) ``24:00:00`` ✅ ``hh.hhh`` (fractional hours) ``11.5`` ❌ ``hh:mm.mmm`` (fractional minutes) ``11:30.5`` ❌ =================================== =================== ============== **Note:** Python datetime objects only have microsecond precision (6 digits). Any additional precision will be truncated. Time zone information ^^^^^^^^^^^^^^^^^^^^^ Time zone information may be provided in one of the following formats: .. table:: :widths: auto ========== ========== =========== Format Example Supported ========== ========== =========== ``Z`` ``Z`` ✅ ``z`` ``z`` ✅ ``±hh`` ``+11`` ✅ ``±hhmm`` ``+1130`` ✅ ``±hh:mm`` ``+11:30`` ✅ ========== ========== =========== While the ISO 8601 specification allows the use of MINUS SIGN (U+2212) in the time zone separator, ``ciso8601`` only supports the use of the HYPHEN-MINUS (U+002D) character. Consistent with `RFC 3339`_, ``ciso8601`` also allows a lower-case ``z`` to be used instead of a ``Z``. Strict RFC 3339 parsing ----------------------- ``ciso8601`` parses ISO 8601 datetimes, which can be thought of as a superset of `RFC 3339`_ (`roughly`_). In cases where you might want strict RFC 3339 parsing, ``ciso8601`` offers a ``parse_rfc3339`` method, which behaves in a similar manner to ``parse_datetime``: .. _roughly: https://stackoverflow.com/questions/522251/whats-the-difference-between-iso-8601-and-rfc-3339-date-formats ``parse_rfc3339(dt: String): datetime`` is a function that takes a string and either: * Returns a properly parsed Python datetime, **if and only if** the **entire** string conforms to RFC 3339. * Raises a ``ValueError`` with a description of the reason why the string doesn't conform to RFC 3339. Ignoring time zone information while parsing -------------------------------------------- It takes more time to parse timestamps with time zone information, especially if they're not in UTC. However, there are times when you don't care about time zone information, and wish to produce naive datetimes instead. For example, if you are certain that your program will only parse timestamps from a single time zone, you might want to strip the time zone information and only output naive datetimes. In these limited cases, there is a second function provided. ``parse_datetime_as_naive`` will ignore any time zone information it finds and, as a result, is faster for timestamps containing time zone information. .. code:: python In [1]: import ciso8601 In [2]: ciso8601.parse_datetime_as_naive('2014-12-05T12:30:45.123456-05:30') Out[2]: datetime.datetime(2014, 12, 5, 12, 30, 45, 123456) NOTE: ``parse_datetime_as_naive`` is only useful in the case where your timestamps have time zone information, but you want to ignore it. This is somewhat unusual. If your timestamps don't have time zone information (i.e. are naive), simply use ``parse_datetime``. It is just as fast. ciso8601-2.3.1/RELEASING.md000066400000000000000000000023261451720545600146400ustar00rootroot00000000000000# Releasing ciso8601 The document will describe the process of releasing a new version of `ciso8601` - [Prerequisites](#prerequisites) - [Create the release in GitHub](#create-the-release-in-github) ## Prerequisites * Confirm that [`VERSION`](setup.py) has been changed * Confirm that [`CHANGELOG`](CHANGELOG.md) includes an entry for the version * Confirm that these changes have been merged into the `master`. ## Create the release in GitHub 1. Go to https://github.com/closeio/ciso8601/releases/new and draft a new release at the tag you created for the release. 2. Each release is tagged with a Git tag. In the `Choose a Tag` field type a new tag. The tag must follow the format `v` (i.e., the version with a `v` in front) (e.g., `v2.2.0`). 3. In the `Release Title` field, type the same tag name (e.g., `v2.2.0`) 4. In the `Describe this release` field copy-paste the `CHANGELOG.md` notes for this release. 5. Click `Publish Release` This will trigger a [GitHub Action](.github/workflows/build-wheels.yml) that listens for new tags that follow our format, builds the wheels for the release, and publishes them to PyPI.ciso8601-2.3.1/benchmarking/000077500000000000000000000000001451720545600154325ustar00rootroot00000000000000ciso8601-2.3.1/benchmarking/Dockerfile000066400000000000000000000032141451720545600174240ustar00rootroot00000000000000FROM ubuntu RUN apt-get update && \ apt install -y software-properties-common && \ add-apt-repository ppa:deadsnakes/ppa && \ apt-get update # Install the other dependencies RUN apt-get install -y git curl gcc build-essential # Install tzdata non-iteractively # https://stackoverflow.com/questions/44331836/apt-get-install-tzdata-noninteractive/44333806#44333806 RUN DEBIAN_FRONTEND=noninteractive apt-get install -y tzdata # Install the Python versions RUN apt install -y python2 python2-dev && \ apt install -y python3.7 python3.7-dev python3.7-venv && \ apt install -y python3.8 python3.8-dev python3.8-venv && \ apt install -y python3.9 python3.9-dev python3.9-venv && \ apt install -y python3.10 python3.10-dev python3.10-venv && \ apt install -y python3.11 python3.11-dev python3.11-venv && \ apt install -y python3.12 python3.12-dev python3.12-venv # Make Python 3.12 the default `python` RUN update-alternatives --install /usr/bin/python python /usr/bin/python3.12 10 # Get pip RUN python -m ensurepip --upgrade ADD requirements.txt requirements.txt # Install benchmarking dependencies RUN python -m pip install -r requirements.txt # Work around https://bugs.launchpad.net/ubuntu/+source/tzdata/+bug/1899343, which messes with `moment` RUN echo "Etc/UTC" | tee /etc/timezone && \ dpkg-reconfigure --frontend noninteractive tzdata # Clone the upstream. If you want to use your local copy, run the container with a volume that overwrites this. RUN git clone https://github.com/closeio/ciso8601.git && \ chmod +x /ciso8601/benchmarking/run_benchmarks.sh WORKDIR /ciso8601/benchmarking ENTRYPOINT ./run_benchmarks.sh ciso8601-2.3.1/benchmarking/README.rst000066400000000000000000000076531451720545600171340ustar00rootroot00000000000000===================== Benchmarking ciso8601 ===================== .. contents:: Contents Introduction ------------ ``ciso8601``'s goal is to be the world's fastest ISO 8601 datetime parser for Python. In order to see how we compare, we run benchmarks against each other known ISO 8601 parser. **Note:** We only run benchmarks against open-source parsers that are published as part of Python modules on `PyPI`_. .. _`PyPI`: https://pypi.org/ Quick start: Running the standard benchmarks -------------------------------------------- If you just want to run the standard benchmarks we run for each release, there is a convenience script. .. code:: bash % python -m venv env % source env/bin/activate % pip install -r requirements.txt % ./run_benchmarks.sh This runs the benchmarks and generates reStructuredText files. The contents of these files are then automatically copy-pasted into ciso8601's `README.rst`_. .. _`README.rst`: https://github.com/closeio/ciso8601/blob/master/README.rst Running benchmarks for all Python versions ------------------------------------------ To make it easier to run the benchmarks for all supported Python versions, there is a Dockerfile you can build: .. code:: bash % docker build -t ciso8601_benchmarking . % docker run -it --rm=true -v $(dirname `pwd`):/ciso8601 ciso8601_benchmarking Running custom benchmarks ------------------------- Running a custom benchmark is done by supplying `tox`_ with your custom timestamp: .. code:: bash % python -m venv env % source env/bin/activate % pip install -r requirements.txt % tox '2014-01-09T21:48:00' It calls `perform_comparison.py`_ in each of the supported Python interpreters on your machine. This in turn calls `timeit`_ for each of the modules defined in ``ISO_8601_MODULES``. .. _`tox`: https://tox.readthedocs.io/en/latest/index.html .. _`timeit`: https://docs.python.org/3/library/timeit.html Results are dumped into a collection of CSV files (in the ``benchmark_results`` directory by default). These CSV files can then formatted into reStructuredText tables by `format_results.py`_: .. _`perform_comparison.py`: https://github.com/closeio/ciso8601/blob/master/benchmarking/perform_comparison.py .. _`format_results.py`: https://github.com/closeio/ciso8601/blob/master/benchmarking/format_results.py .. code:: bash % cd benchmarking % python format_results.py benchmark_results/2014-01-09T214800 benchmark_results/benchmark_with_no_time_zone.rst % python format_results.py benchmark_results/2014-01-09T214800-0530 benchmark_results/benchmark_with_time_zone.rst Disclaimer ----------- Because of the way that ``tox`` works (and the way the benchmark is structured more generally), it doesn't make sense to compare the results for a given module across different Python versions. Comparisons between modules within the same Python version are still valid, and indeed, are the goal of the benchmarks. Caching ------- `ciso8601` caches the ``tzinfo`` objects it creates, allowing it to reuse those objects for faster creation of subsequent ``datetime`` objects. For example, for some types of profiling, it makes sense not to have a cache. Caching can be disabled by modifying the `tox.ini`_ and changing ``CISO8601_CACHING_ENABLED`` to ``0``. .. _`tox.ini`: https://github.com/closeio/ciso8601/blob/master/benchmarking/tox.ini FAQs ---- "What about ?" ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ We only run benchmarks against open-source parsers that are published as part of Python modules on PyPI. Do you know of a competing module missing from these benchmarks? We made it easy to add additional modules to our benchmarking: 1. Add the dependency to ``tox.ini`` 1. Add the import statement and the parse statement for the module to ``ISO_8601_MODULES`` in `perform_comparison.py`_ `Submit a pull request`_ and we'll probably add it to our official benchmarks. .. _`Submit a pull request`: https://github.com/closeio/ciso8601/blob/master/CONTRIBUTING.md ciso8601-2.3.1/benchmarking/format_results.py000066400000000000000000000262441451720545600210650ustar00rootroot00000000000000import argparse import csv import os import platform import re from collections import defaultdict, UserDict from packaging import version as version_parse import pytablewriter class Result: def __init__(self, timing, parsed_value, exception, matched_expected): self.timing = timing self.parsed_value = parsed_value self.exception = exception self.matched_expected = matched_expected def formatted_timing(self): return format_duration(self.timing) if self.timing is not None else "" def __str__(self): if self.exception: return f"Raised ``{self.exception}`` Exception" elif not self.matched_expected: return "❌" else: return self.formatted_timing() class ModuleResults(UserDict): def most_modern_result(self): non_exception_results = [(_python_version, result) for _python_version, result in self.data.items() if result.exception is None] return sorted(non_exception_results, key=lambda kvp: kvp[0], reverse=True)[0][1] FILENAME_REGEX_RAW = r"benchmark_timings_python(\d)(\d\d?).csv" FILENAME_REGEX = re.compile(FILENAME_REGEX_RAW) MODULE_VERSION_FILENAME_REGEX_RAW = r"module_versions_python(\d)(\d\d?).csv" MODULE_VERSION_FILENAME_REGEX = re.compile(MODULE_VERSION_FILENAME_REGEX_RAW) UNITS = {"nsec": 1e-9, "usec": 1e-6, "msec": 1e-3, "sec": 1.0} SCALES = sorted([(scale, unit) for unit, scale in UNITS.items()], reverse=True) NOT_APPLICABLE = "N/A" def format_duration(duration): # Based on cPython's `timeit` CLI formatting scale, unit = next(((scale, unit) for scale, unit in SCALES if duration >= scale), SCALES[-1]) precision = 3 return "%.*g %s" % (precision, duration / scale, unit) def format_relative(duration1, duration2): if duration1 is None or duration2 is None: return NOT_APPLICABLE precision = 1 return "%.*fx" % (precision, duration1 / duration2) def format_used_module_versions(module_versions_used): results = [] for module, versions in sorted(module_versions_used.items(), key=lambda x: x[0].lower()): if len(versions) == 1: results.append(f"{module}=={next(iter(versions.keys()))}") else: results.append(", ".join([f"{module}=={version} (on Python {', '.join(version_sort(py_versions))})" for version, py_versions in versions.items()])) return results def version_sort(versions): return [str(v) for v in sorted([version_parse.parse(v) for v in versions])] def relative_slowdown(subject, comparison): most_modern_common_version = next(iter(sorted(set(subject.keys()).intersection(set(comparison)), reverse=True)), None) if not most_modern_common_version: raise ValueError("No common Python version found") return format_relative(subject[most_modern_common_version].timing, comparison[most_modern_common_version].timing) def filepaths(directory, condition): return [os.path.join(parent, f) for parent, _dirs, files in os.walk(directory) for f in files if condition(f)] def load_benchmarking_results(results_directory): calling_code = {} timestamps = set() python_versions = set() results = defaultdict(ModuleResults) files_to_process = filepaths(results_directory, FILENAME_REGEX.match) for csv_file in files_to_process: try: with open(csv_file, "r") as fin: reader = csv.reader(fin, delimiter=",", quotechar='"') major, minor, timestamp = next(reader) major = int(major) minor = int(minor) timestamps.add(timestamp) for module, _setup, stmt, parse_result, count, time_taken, matched, exception in reader: if module == "hardcoded": continue timing = float(time_taken) / int(count) if exception == "" else None exception = exception if exception != "" else None results[module][(major, minor)] = Result( timing, parse_result, exception, matched == "True" ) python_versions.add((major, minor)) calling_code[module] = f"``{stmt.format(timestamp=timestamp)}``" except Exception: print(f"Problem while parsing `{csv_file}`") raise if len(timestamps) > 1: raise NotImplementedError(f"Found a mix of files in the results directory. Found files that represent the parsing of {timestamps}. Support for handling multiple timestamps is not implemented.") python_versions_by_modernity = sorted(python_versions, reverse=True) return results, python_versions_by_modernity, calling_code SPACER_COLUMN = ["…"] def write_benchmarking_results(results_directory, output_file, baseline_module, include_call): results, python_versions_by_modernity, calling_code = load_benchmarking_results(results_directory) modules_by_modern_speed = [module for module, results in sorted([*results.items()], key=lambda kvp: kvp[1].most_modern_result().timing)] # GitHub in desktop browsers displays 830 pixels in a table width before adding a scroll bar. # Experimentally, this means we can show the results from the 4 latest versions of Python and our important slowdown summary before it cuts off # We add a spacer column before continuing with older versions of Python so that the slowdown summary isn't lost in the noise. modern_versions_before_slowdown_summary = 4 writer = pytablewriter.RstGridTableWriter() formatted_python_versions = [f"Python {major}.{minor}" for major, minor in python_versions_by_modernity] writer.headers = ["Module"] + (["Call"] if include_call else []) + formatted_python_versions[0:modern_versions_before_slowdown_summary] + [f"Relative slowdown (versus {baseline_module}, latest Python)"] + SPACER_COLUMN + formatted_python_versions[modern_versions_before_slowdown_summary:] writer.type_hints = [pytablewriter.String] * len(writer.headers) calling_codes = [calling_code[module] for module in modules_by_modern_speed] performance_results = [[results[module].get(python_version, NOT_APPLICABLE) for python_version in python_versions_by_modernity] for module in modules_by_modern_speed] relative_slowdowns = [relative_slowdown(results[module], results[baseline_module]) if module != baseline_module else NOT_APPLICABLE for module in modules_by_modern_speed] writer.value_matrix = [ [module] + ([calling_code[module]] if include_call else []) + performance_by_version[0:modern_versions_before_slowdown_summary] + [relative_slowdown] + SPACER_COLUMN + performance_by_version[modern_versions_before_slowdown_summary:] for module, calling_code, performance_by_version, relative_slowdown in zip(modules_by_modern_speed, calling_codes, performance_results, relative_slowdowns) ] with open(output_file, "w") as fout: writer.stream = fout writer.write_table() fout.write("\n") latest_python_version = python_versions_by_modernity[0] modules_supporting_latest_python = [module for module in modules_by_modern_speed if latest_python_version in results[module]] if len(modules_supporting_latest_python) > 1: baseline_module_timing = results[baseline_module].most_modern_result().formatted_timing() fastest_module, next_fastest_module = modules_supporting_latest_python[0:2] if fastest_module == baseline_module: fout.write(f"{baseline_module} takes {baseline_module_timing}, which is **{relative_slowdown(results[next_fastest_module], results[baseline_module])} faster than {next_fastest_module}**, the next fastest {formatted_python_versions[0]} parser in this comparison.\n") else: fout.write(f"{baseline_module} takes {baseline_module_timing}, which is **{relative_slowdown(results[baseline_module], results[fastest_module])} slower than {fastest_module}**, the fastest {formatted_python_versions[0]} parser in this comparison.\n") def load_module_version_info(results_directory): module_versions_used = defaultdict(dict) files_to_process = filepaths(results_directory, MODULE_VERSION_FILENAME_REGEX.match) for csv_file in files_to_process: with open(csv_file, "r") as fin: reader = csv.reader(fin, delimiter=",", quotechar='"') major, minor = next(reader) for module, version in reader: if version not in module_versions_used[module]: module_versions_used[module][version] = set() module_versions_used[module][version].add(".".join((major, minor))) return module_versions_used def write_module_version_info(results_directory, output_file): with open(output_file, "w") as fout: fout.write(f"Tested on {platform.system()} {platform.release()} using the following modules:\n") fout.write("\n") fout.write(".. code:: python\n") fout.write("\n") for module_version_line in format_used_module_versions(load_module_version_info(results_directory)): fout.write(f" {module_version_line}\n") def main(results_directory, output_file, baseline_module, include_call, module_version_output): write_benchmarking_results(results_directory, output_file, baseline_module, include_call) write_module_version_info(results_directory, os.path.join(os.path.dirname(output_file), module_version_output)) if __name__ == "__main__": OUTPUT_FILE_HELP = "The filepath to use when outputting the reStructuredText results." RESULTS_DIR_HELP = f"Which directory the script should look in to find benchmarking results. Will process any file that match the regexes '{FILENAME_REGEX_RAW}' and '{MODULE_VERSION_FILENAME_REGEX_RAW}'." BASELINE_LIBRARY_DEFAULT = "ciso8601" BASELINE_LIBRARY_HELP = f'The module to make all relative calculations relative to (default: "{BASELINE_LIBRARY_DEFAULT}").' INCLUDE_CALL_DEFAULT = False INCLUDE_CALL_HELP = f"Whether or not to include a column showing the actual code call (default: {INCLUDE_CALL_DEFAULT})." MODULE_VERSION_OUTPUT_FILE_DEFAULT = "benchmark_module_versions.rst" MODULE_VERSION_OUTPUT_FILE_HELP = "The filename to use when outputting the reStructuredText list of module versions. Written to the same directory as `OUTPUT`" parser = argparse.ArgumentParser("Formats the benchmarking results into a nicely formatted block of reStructuredText for use in the README.") parser.add_argument("RESULTS", help=RESULTS_DIR_HELP) parser.add_argument("OUTPUT", help=OUTPUT_FILE_HELP) parser.add_argument("--baseline-module", required=False, default=BASELINE_LIBRARY_DEFAULT, help=BASELINE_LIBRARY_HELP) parser.add_argument("--include-call", required=False, type=bool, default=INCLUDE_CALL_DEFAULT, help=INCLUDE_CALL_HELP) parser.add_argument("--module-version-output", required=False, default=MODULE_VERSION_OUTPUT_FILE_DEFAULT, help=MODULE_VERSION_OUTPUT_FILE_HELP) args = parser.parse_args() if not os.path.exists(args.RESULTS): raise ValueError(f'Results directory "{args.RESULTS}" does not exist.') main(args.RESULTS, args.OUTPUT, args.baseline_module, args.include_call, args.module_version_output) ciso8601-2.3.1/benchmarking/perform_comparison.py000066400000000000000000000260101451720545600217070ustar00rootroot00000000000000import argparse import csv import os import sys import timeit from datetime import datetime, timedelta import pytz if (sys.version_info.major, sys.version_info.minor) >= (3, 5): from metomi.isodatetime.data import TimePoint try: from importlib.metadata import version as get_module_version except ImportError: from importlib_metadata import version as get_module_version ISO_8601_MODULES = { "aniso8601": ("import aniso8601", "aniso8601.parse_datetime('{timestamp}')"), "ciso8601": ("import ciso8601", "ciso8601.parse_datetime('{timestamp}')"), "hardcoded": ("import ciso8601", "ciso8601._hard_coded_benchmark_timestamp()"), "python-dateutil": ("import dateutil.parser", "dateutil.parser.parse('{timestamp}')"), "iso8601": ("import iso8601", "iso8601.parse_date('{timestamp}')"), "isodate": ("import isodate", "isodate.parse_datetime('{timestamp}')"), "PySO8601": ("import PySO8601", "PySO8601.parse('{timestamp}')"), "str2date": ("from str2date import str2date", "str2date('{timestamp}')"), } if (sys.version_info.major, sys.version_info.minor) >= (3, 11): # Python 3.11 added full ISO 8601 parsing ISO_8601_MODULES["datetime (builtin)"] = ("from datetime import datetime", "datetime.fromisoformat('{timestamp}')") if sys.version_info.major >= 3 and (sys.version_info.major, sys.version_info.minor) < (3, 11): # backports.datetime_fromisoformat brings the Python 3.11 logic into older Python 3 versions ISO_8601_MODULES["backports.datetime_fromisoformat"] = ("from backports.datetime_fromisoformat import datetime_fromisoformat", "datetime_fromisoformat('{timestamp}')") if os.name != "nt": # udatetime doesn't support Windows. ISO_8601_MODULES["udatetime"] = ("import udatetime", "udatetime.from_string('{timestamp}')") if (sys.version_info.major, sys.version_info.minor) >= (3, 5): # metomi-isodatetime doesn't support Python < 3.5 ISO_8601_MODULES["metomi-isodatetime"] = ("import metomi.isodatetime.parsers as parse", "parse.TimePointParser().parse('{timestamp}')") if (sys.version_info.major, sys.version_info.minor) >= (3, 6): # zulu v2.0.0+ no longer supports Python < 3.6 ISO_8601_MODULES["zulu"] = ("import zulu", "zulu.parse('{timestamp}')") if (sys.version_info.major, sys.version_info.minor) != (3, 6) and (sys.version_info.major, sys.version_info.minor) <= (3, 9): # iso8601utils installs enum34, which messes with tox in Python 3.6 # https://stackoverflow.com/q/43124775 # https://github.com/silverfernsys/iso8601utils/pull/5 # iso8601utils uses `from collections import Iterable` which no longer works in Python 3.10 # https://github.com/silverfernsys/iso8601utils/issues/6 ISO_8601_MODULES["iso8601utils"] = ("from iso8601utils import parsers", "parsers.datetime('{timestamp}')") if (sys.version_info.major, sys.version_info.minor) != (3, 4): # `arrow` no longer supports Python 3.4 ISO_8601_MODULES["arrow"] = ("import arrow", "arrow.get('{timestamp}').datetime") if sys.version_info.major >= 3 and (sys.version_info.major, sys.version_info.minor) < (3, 12): # `maya` uses a version of `regex` which no longer supports Python 2 # `maya` uses `pendulum`, which doesn't yet support Python 3.12 ISO_8601_MODULES["maya"] = ("import maya", "maya.parse('{timestamp}').datetime()") if (sys.version_info.major, sys.version_info.minor) < (3, 12): # `pendulum` doesn't yet support Python 3.12 ISO_8601_MODULES["pendulum"] = ("from pendulum.parsing import parse_iso8601", "parse_iso8601('{timestamp}')") if (sys.version_info.major, sys.version_info.minor) >= (3, 5): # `moment` is built on `times`, which is built on `arrow`, which no longer supports Python 3.4 # `moment` uses a version of `regex` which no longer supports Python 2 ISO_8601_MODULES["moment"] = ("import moment", "moment.date('{timestamp}').date") class Result: def __init__(self, module, setup, stmt, parse_result, count, time_taken, matched, exception): self.module = module self.setup = setup self.stmt = stmt self.parse_result = parse_result self.count = count self.time_taken = time_taken self.matched = matched self.exception = exception def to_row(self): return [ self.module, self.setup, self.stmt, self.parse_result, self.count, self.time_taken, self.matched, self.exception ] def metomi_compare(timepoint, dt): # Really (s)crappy comparison function # Ignores subsecond accuracy. # https://github.com/metomi/isodatetime/issues/196 offset = timedelta(hours=timepoint.time_zone.hours, minutes=timepoint.time_zone.minutes) return timepoint.year == dt.year and \ timepoint.month_of_year == dt.month and \ timepoint.day_of_month == dt.day and \ timepoint.hour_of_day == dt.hour and \ timepoint.minute_of_hour == dt.minute and \ timepoint.second_of_minute == dt.second and \ offset == dt.tzinfo.utcoffset(dt) def check_roughly_equivalent(dt1, dt2): # For the purposes of our benchmarking, we don't care if the datetime # has tzinfo=UTC or is naive. dt1 = dt1.replace(tzinfo=pytz.UTC) if isinstance(dt1, datetime) and dt1.tzinfo is None else dt1 dt2 = dt2.replace(tzinfo=pytz.UTC) if isinstance(dt2, datetime) and dt2.tzinfo is None else dt2 # Special handling for metomi-isodatetime if (sys.version_info.major, sys.version_info.minor) >= (3, 5) and isinstance(dt1, TimePoint): return metomi_compare(dt1, dt2) return dt1 == dt2 def auto_range_counts(filepath): results = {} if os.path.exists(filepath): with open(filepath, "r") as fin: reader = csv.reader(fin, delimiter=",", quotechar='"') for module, count in reader: results[module] = int(count) return results def update_auto_range_counts(filepath, results): new_counts = dict([[result.module, result.count] for result in results if result.count is not None]) new_auto_range_counts = auto_range_counts(filepath) new_auto_range_counts.update(new_counts) with open(filepath, "w") as fout: auto_range_file_writer = csv.writer(fout, delimiter=",", quotechar='"', lineterminator="\n") for module, count in sorted(new_auto_range_counts.items()): auto_range_file_writer.writerow([module, count]) def write_results(filepath, timestamp, results): with open(filepath, "w") as fout: writer = csv.writer(fout, delimiter=",", quotechar='"', lineterminator="\n") writer.writerow([sys.version_info.major, sys.version_info.minor, timestamp]) for result in results: writer.writerow(result.to_row()) def write_module_versions(filepath): with open(filepath, "w") as fout: module_version_writer = csv.writer(fout, delimiter=",", quotechar='"', lineterminator="\n") module_version_writer.writerow([sys.version_info.major, sys.version_info.minor]) for module, (_setup, _stmt) in sorted(ISO_8601_MODULES.items(), key=lambda x: x[0].lower()): if module == "datetime (builtin)" or module == "hardcoded": continue # Unfortunately, `backports.datetime_fromisoformat` has the distribution name `backports-datetime-fromisoformat` in PyPI # This messes with Python 3.8 and 3.9's get_module_version, so we special case it. if module == "backports.datetime_fromisoformat": module_version = get_module_version("backports-datetime-fromisoformat") else: module_version = get_module_version(module) module_version_writer.writerow([module, module_version]) def run_tests(timestamp, results_directory, compare_to): # `Timer.autorange` only exists in Python 3.6+. We want the tests to run in a reasonable amount of time, # but we don't want to have to hard-code how many times to run each test. # So we make sure to call Python 3.6+ versions first. They output a file that the others use to know how many iterations to run. auto_range_count_filepath = os.path.join(results_directory, "auto_range_counts.csv") test_interation_counts = auto_range_counts(auto_range_count_filepath) exec(ISO_8601_MODULES[compare_to][0]) expected_parse_result = eval(ISO_8601_MODULES[compare_to][1].format(timestamp=timestamp)) results = [] for module, (setup, stmt) in ISO_8601_MODULES.items(): count = None time_taken = None exception = None try: exec(setup) parse_result = eval(stmt.format(timestamp=timestamp)) timer = timeit.Timer(stmt=stmt.format(timestamp=timestamp), setup=setup) if hasattr(timer, 'autorange'): count, time_taken = timer.autorange() else: count = test_interation_counts[module] time_taken = timer.timeit(number=count) except Exception as exc: count = None time_taken = None parse_result = None exception = type(exc) results.append( Result( module, setup, stmt.format(timestamp=timestamp), parse_result if parse_result is not None else "None", count, time_taken, check_roughly_equivalent(parse_result, expected_parse_result), exception, ) ) update_auto_range_counts(auto_range_count_filepath, results) results_filepath = os.path.join(results_directory, "benchmark_timings_python{major}{minor}.csv".format(major=sys.version_info.major, minor=sys.version_info.minor)) write_results(results_filepath, timestamp, results) module_versions_filepath = os.path.join(results_directory, "module_versions_python{major}{minor}.csv".format(major=sys.version_info.major, minor=sys.version_info.minor)) write_module_versions(module_versions_filepath) def sanitize_timestamp_as_filename(timestamp): return timestamp.replace(":", "") if __name__ == "__main__": TIMESTAMP_HELP = "Which ISO 8601 timestamp to parse" BASE_LIBRARY_DEFAULT = "ciso8601" BASE_LIBRARY_HELP = 'The module to make correctness decisions relative to (default: "{default}").'.format(default=BASE_LIBRARY_DEFAULT) RESULTS_DIR_DEFAULT = "benchmark_results" RESULTS_DIR_HELP = 'Which directory the script should output benchmarking results. (default: "{0}")'.format(RESULTS_DIR_DEFAULT) parser = argparse.ArgumentParser("Runs `timeit` to benchmark a variety of ISO 8601 parsers.") parser.add_argument("TIMESTAMP", help=TIMESTAMP_HELP) parser.add_argument("--base-module", required=False, default=BASE_LIBRARY_DEFAULT, help=BASE_LIBRARY_HELP) parser.add_argument("--results", required=False, default=RESULTS_DIR_DEFAULT, help=RESULTS_DIR_HELP) args = parser.parse_args() output_dir = os.path.join(args.results, sanitize_timestamp_as_filename(args.TIMESTAMP)) if not os.path.exists(output_dir): os.makedirs(output_dir) run_tests(args.TIMESTAMP, output_dir, args.base_module) ciso8601-2.3.1/benchmarking/requirements.txt000066400000000000000000000001011451720545600207060ustar00rootroot00000000000000importlib_metadata; python_version < '3.8' pytablewriter tox > 4 ciso8601-2.3.1/benchmarking/rst_include_replace.py000066400000000000000000000053131451720545600220140ustar00rootroot00000000000000import argparse import os import re # Since GitHub doesn't support the use of the reStructuredText `include` directive, # we must copy-paste the results into README.rst. To do this automatically, we came # up with a special comment syntax. This script will replace everything between the # two special comments with the content requested. # For example: # # .. # This content will be replaced by the content of "benchmark_module_versions.rst" # .. # INCLUDE_BLOCK_START = ".. " INCLUDE_BLOCK_END = ".. " def replace_include(target_filepath, include_file, source_filepath): start_block_regex = re.compile(INCLUDE_BLOCK_START.format(filename=include_file)) end_block_regex = re.compile(INCLUDE_BLOCK_END.format(filename=include_file)) with open(source_filepath, "r") as fin: replacement_lines = iter(fin.readlines()) with open(target_filepath, "r") as fin: target_lines = iter(fin.readlines()) with open(target_filepath, "w") as fout: for line in target_lines: if start_block_regex.match(line): fout.write(line) fout.write("\n") # rST requires a blank line after comment lines for replacement_line in replacement_lines: fout.write(replacement_line) next_line = next(target_lines) while not end_block_regex.match(next_line): try: next_line = next(target_lines) except StopIteration: break fout.write("\n") # rST requires a blank line before comment lines fout.write(next_line) else: fout.write(line) if __name__ == "__main__": TARGET_HELP = "The filepath you wish to replace tags within." INCLUDE_TAG_HELP = "The filename within the tag you are hoping to replace. (ex. 'benchmark_with_time_zone.rst')" SOURCE_HELP = "The filepath whose contents should be included into the TARGET file." parser = argparse.ArgumentParser("Formats the benchmarking results into a nicely formatted block of reStructuredText for use in the README.") parser.add_argument("TARGET", help=TARGET_HELP) parser.add_argument("INCLUDE_TAG", help=INCLUDE_TAG_HELP) parser.add_argument("SOURCE", help=SOURCE_HELP) args = parser.parse_args() if not os.path.exists(args.TARGET): raise ValueError(f"TARGET path {args.TARGET} does not exist") if not os.path.exists(args.SOURCE): raise ValueError(f"SOURCE path {args.SOURCE} does not exist") replace_include(args.TARGET, args.INCLUDE_TAG, args.SOURCE) ciso8601-2.3.1/benchmarking/run_benchmarks.sh000077500000000000000000000012261451720545600207730ustar00rootroot00000000000000tox -- '2014-01-09T21:48:00' tox -- '2014-01-09T21:48:00-05:30' python format_results.py benchmark_results/2014-01-09T214800 benchmark_results/benchmark_with_no_time_zone.rst python format_results.py benchmark_results/2014-01-09T214800-0530 benchmark_results/benchmark_with_time_zone.rst python rst_include_replace.py ../README.rst 'benchmark_with_no_time_zone.rst' benchmark_results/benchmark_with_no_time_zone.rst python rst_include_replace.py ../README.rst 'benchmark_with_time_zone.rst' benchmark_results/benchmark_with_time_zone.rst python rst_include_replace.py ../README.rst 'benchmark_module_versions.rst' benchmark_results/benchmark_module_versions.rst ciso8601-2.3.1/benchmarking/tox.ini000066400000000000000000000032721451720545600167510ustar00rootroot00000000000000[tox] requires = tox>=4 envlist = py312,py311,py310,py39,py38,py37 setupdir=.. [testenv] package = sdist setenv = CISO8601_CACHING_ENABLED = 1 deps= ; The libraries needed to run the benchmarking itself -rrequirements.txt ; The actual ISO 8601 parsing libraries aniso8601 ; `arrow` no longer supports Python 3.4 arrow; python_version != '3.4' backports.datetime_fromisoformat; python_version > '3' and python_version < '3.11' iso8601 # iso8601utils installs enum34, which messes with tox in Python 3.6 # https://stackoverflow.com/q/43124775 # https://github.com/silverfernsys/iso8601utils/pull/5 # iso8601utils uses `from collections import Iterable` which no longer works in Python 3.10 # https://github.com/silverfernsys/iso8601utils/issues/6 iso8601utils; python_version != '3.6' and python_version != '3.10' isodate ; `maya` uses a version of `regex` which no longer supports Python 2 ; `maya` uses `pendulum`, which doesn't yet support Python 3.12 maya; python_version > '3' and python_version < '3.12' metomi-isodatetime; python_version >= '3.5' ; `moment` is built on `times`, which is built on `arrow`, which no longer supports Python 3.4 ; `moment` uses a version of `regex` which no longer supports Python 2 moment; python_version >= '3.5' ; `pendulum` doesn't yet support Python 3.12 pendulum; python_version < '3.12' pyso8601 python-dateutil str2date ; `udatetime` doesn't support Windows udatetime; os_name != 'nt' ; `zulu` v2.0.0+ no longer supports Python < 3.6 zulu; python_version >= '3.6' pytz commands= python -W ignore perform_comparison.py {posargs:DEFAULTS} ciso8601-2.3.1/ciso8601/000077500000000000000000000000001451720545600142565ustar00rootroot00000000000000ciso8601-2.3.1/ciso8601/__init__.pyi000066400000000000000000000003251451720545600165400ustar00rootroot00000000000000from datetime import datetime def parse_datetime(datetime_string: str) -> datetime: ... def parse_rfc3339(datetime_string: str) -> datetime: ... def parse_datetime_as_naive(datetime_string: str) -> datetime: ... ciso8601-2.3.1/ciso8601/py.typed000066400000000000000000000000001451720545600157430ustar00rootroot00000000000000ciso8601-2.3.1/generate_test_timestamps.py000066400000000000000000000406151451720545600204610ustar00rootroot00000000000000import datetime import pytz import sys from collections import namedtuple def __merge_dicts(*dict_args): # Only needed for Python <3.5 support. In Python 3.5+, you can use the {**a, **b} syntax. """ From: https://stackoverflow.com/a/26853961 Given any number of dicts, shallow copy and merge into a new dict, precedence goes to key value pairs in latter dicts. """ result = {} for dictionary in dict_args: result.update(dictionary) return result NumberField = namedtuple('NumberField', ['min_width', 'max_width', 'min_value', 'max_value']) NUMBER_FIELDS = { "year": NumberField(4, 4, 1, 9999), "month": NumberField(2, 2, 1, 12), "day": NumberField(2, 2, 1, 31), "ordinal_day": NumberField(3, 3, 1, 365), # Intentionally missing leap year case "iso_week": NumberField(2, 2, 1, 53), "iso_day": NumberField(1, 1, 1, 7), "hour": NumberField(2, 2, 0, 24), # 24 = special midnight value "minute": NumberField(2, 2, 0, 59), "second": NumberField(2, 2, 0, 60), # 60 = Leap second "microsecond": NumberField(1, None, 0, None), # Can have unbounded characters "tzhour": NumberField(2, 2, 0, 23), "tzminute": NumberField(2, 2, 0, 59), } PADDED_NUMBER_FIELD_FORMATS = { field_name: "{{{field_name}:0>{max_width}}}".format( field_name=field_name, max_width=field.max_width if field.max_width is not None else 1, ) for field_name, field in NUMBER_FIELDS.items() } def __generate_valid_formats(year=2014, month=2, day=3, iso_week=6, iso_day=1, ordinal_day=34, hour=1, minute=23, second=45, microsecond=123456, tzhour=4, tzminute=30): # Given a set of values, generates the 400+ different combinations of those values within a valid ISO 8601 string. # Returns a Python format string, the fields in the format string, and the corresponding parameters you could pass to the datetime constructor # These can be used by generate_valid_timestamp_and_datetime and generate_invalid_timestamp_and_datetime to produce test cases valid_basic_calendar_date_formats = [ ("{year}{month}{day}", set(["year", "month", "day"]), {"year": year, "month": month, "day": day}) ] valid_extended_calendar_date_formats = [ ("{year}-{month}", set(["year", "month"]), {"year": year, "month": month, "day": 1}), ("{year}-{month}-{day}", set(["year", "month", "day"]), {"year": year, "month": month, "day": day}), ] valid_basic_week_date_formats = [ ("{year}W{iso_week}", set(["year", "iso_week"]), {"year": year, "iso_week": iso_week, "iso_day": 1}), ("{year}W{iso_week}{iso_day}", set(["year", "iso_week", "iso_day"]), {"year": year, "iso_week": iso_week, "iso_day": iso_day}) ] valid_extended_week_date_formats = [ ("{year}-W{iso_week}", set(["year", "iso_week"]), {"year": year, "iso_week": iso_week, "iso_day": 1}), ("{year}-W{iso_week}-{iso_day}", set(["year", "iso_week", "iso_day"]), {"year": year, "iso_week": iso_week, "iso_day": iso_day}) ] valid_basic_ordinal_date_formats = [ ("{year}{ordinal_day}", set(["year", "ordinal_day"]), {"year": year, "ordinal_day": ordinal_day}), ] valid_extended_ordinal_date_formats = [ ("{year}-{ordinal_day}", set(["year", "ordinal_day"]), {"year": year, "ordinal_day": ordinal_day}), ] valid_date_and_time_separators = [None, "T", "t", " "] valid_basic_time_formats = [ ("{hour}", set(["hour"]), {"hour": hour}), ("{hour}{minute}", set(["hour", "minute"]), {"hour": hour, "minute": minute}), ("{hour}{minute}{second}", set(["hour", "minute", "second"]), {"hour": hour, "minute": minute, "second": second}) ] valid_extended_time_formats = [ ("{hour}", set(["hour"]), {"hour": hour}), ("{hour}:{minute}", set(["hour", "minute"]), {"hour": hour, "minute": minute}), ("{hour}:{minute}:{second}", set(["hour", "minute", "second"]), {"hour": hour, "minute": minute, "second": second}), ] valid_subseconds = [ ("", set(), {}), (".{microsecond}", set(["microsecond"]), {"microsecond": microsecond}), # TODO: Generate the trimmed 0's version? (",{microsecond}", set(["microsecond"]), {"microsecond": microsecond}), ] valid_tz_info_formats = [ ("", set(), {}), ("Z", set(), {"tzinfo": pytz.UTC}), ("z", set(), {"tzinfo": pytz.UTC}), ("-{tzhour}", set(["tzhour"]), {"tzinfo": pytz.FixedOffset(-1 * tzhour * 60)}), ("+{tzhour}", set(["tzhour"]), {"tzinfo": pytz.FixedOffset(1 * tzhour * 60)}), ("-{tzhour}{tzminute}", set(["tzhour", "tzminute"]), {"tzinfo": pytz.FixedOffset(-1 * ((tzhour * 60) + tzminute))}), ("+{tzhour}{tzminute}", set(["tzhour", "tzminute"]), {"tzinfo": pytz.FixedOffset(1 * ((tzhour * 60) + tzminute))}), ("-{tzhour}:{tzminute}", set(["tzhour", "tzminute"]), {"tzinfo": pytz.FixedOffset(-1 * ((tzhour * 60) + tzminute))}), ("+{tzhour}:{tzminute}", set(["tzhour", "tzminute"]), {"tzinfo": pytz.FixedOffset(1 * ((tzhour * 60) + tzminute))}) ] format_date_time_combinations = [ (valid_basic_calendar_date_formats, valid_basic_time_formats), (valid_extended_calendar_date_formats, valid_extended_time_formats), (valid_basic_ordinal_date_formats, valid_basic_time_formats), (valid_extended_ordinal_date_formats, valid_extended_time_formats), ] if (sys.version_info.major, sys.version_info.minor) >= (3, 8): # We rely on datetime.datetime.fromisocalendar # to generate the expected values, but that was added in Python 3.8 format_date_time_combinations += [ (valid_basic_week_date_formats, valid_basic_time_formats), (valid_extended_week_date_formats, valid_extended_time_formats) ] for valid_calendar_date_formats, valid_time_formats in format_date_time_combinations: for calendar_format, calendar_fields, calendar_params in valid_calendar_date_formats: if "iso_week" in calendar_fields: dt = datetime.datetime.fromisocalendar(calendar_params["year"], calendar_params["iso_week"], calendar_params["iso_day"]) calendar_params = __merge_dicts(calendar_params, { "month": dt.month, "day": dt.day }) del(calendar_params["iso_week"]) del(calendar_params["iso_day"]) if "ordinal_day" in calendar_fields: dt = datetime.datetime(calendar_params["year"], 1, 1) + (datetime.timedelta(days=(calendar_params["ordinal_day"] - 1))) calendar_params = __merge_dicts(calendar_params, { "month": dt.month, "day": dt.day }) del(calendar_params["ordinal_day"]) for date_and_time_separator in valid_date_and_time_separators: if date_and_time_separator is None: full_format = calendar_format datetime_params = calendar_params yield (full_format, calendar_fields, datetime_params) else: for time_format, time_fields, time_params in valid_time_formats: for subsecond_format, subsecond_fields, subsecond_params in valid_subseconds: for tz_info_format, tz_info_fields, tz_info_params in valid_tz_info_formats: if "second" in time_fields: # Add subsecond full_format = calendar_format + date_and_time_separator + time_format + subsecond_format + tz_info_format fields = set().union(calendar_fields, time_fields, subsecond_fields, tz_info_fields) datetime_params = __merge_dicts(calendar_params, time_params, subsecond_params, tz_info_params) elif subsecond_format == "": # Arbitrary choice of subsecond format. We don't want duplicates, so we only yield for one of them. full_format = calendar_format + date_and_time_separator + time_format + tz_info_format fields = set().union(calendar_fields, time_fields, tz_info_fields) datetime_params = __merge_dicts(calendar_params, time_params, tz_info_params) else: # Ignore other subsecond formats continue yield (full_format, fields, datetime_params) def __pad_params(**kwargs): # Pads parameters to the required field widths. return {key: PADDED_NUMBER_FIELD_FORMATS[key].format(**{key: value}) if key in PADDED_NUMBER_FIELD_FORMATS else value for key, value in kwargs.items()} def generate_valid_timestamp_and_datetime(year=2014, month=2, day=3, iso_week=6, iso_day=1, ordinal_day=34, hour=1, minute=23, second=45, microsecond=123456, tzhour=4, tzminute=30): # Given a set of values, generates the 400+ different combinations of those values within a valid ISO 8601 string, and the corresponding datetime # This can be used to generate test cases of valid ISO 8601 timestamps. # Note that this will produce many test cases that exercise the exact same code pathways (i.e., offer no additional coverage). # Given a knowledge of the code, this is excessive, but these serve as a good set of black box tests (i.e., You could apply these to any ISO 8601 parse). kwargs = { "year": year, "month": month, "day": day, "iso_week": iso_week, "iso_day": iso_day, "ordinal_day": ordinal_day, "hour": hour, "minute": minute, "second": second, "microsecond": microsecond, "tzhour": tzhour, "tzminute": tzminute, } for timestamp_format, _fields, datetime_params in __generate_valid_formats(**kwargs): # Pad each field to the appropriate width padded_kwargs = __pad_params(**kwargs) timestamp = timestamp_format.format(**padded_kwargs) yield (timestamp, datetime.datetime(**datetime_params)) def generate_invalid_timestamp(year=2014, month=2, day=3, iso_week=6, iso_day=1, ordinal_day=34, hour=1, minute=23, second=45, microsecond=123456, tzhour=4, tzminute=30): # At the very least, each field can be invalid in the following ways: # - Have too few characters # - Have too many characters # - Contain invalid characters # - Have a value that is too small # - Have a value that is too large # # This function takes each valid format (from `__generate_valid_formats()`), and mangles each field within the format to be invalid in each of the above ways. # It also tests the case of trailing characters after each format. # Note that this will produce many test cases that exercise the exact same code pathways (i.e., offer no additional coverage). # Given a knowledge of the code, this is excessive, but these serve as a good set of black box tests (i.e., You could apply these to any ISO 8601 parse). # This does not produce every invalid timestamp format though. For simplicity of the code, it does not cover the cases of: # - The fields having 0 characters (Many fields (like day, minute, second etc.) are optional. So unless the field follows a separator, it is valid to have 0 characters) # - Invalid day numbers for a given month (ex. "2014-02-31") # - Invalid separators (ex. "2014=04=01") # - Ordinal dates in leap years # - Missing/Mismatched separators (ex. "2014-0101T0000:00") # - Hour = 24, but not Special midnight case (ex. "24:00:01") # - Timestamps that bear no resemblance to ISO 8601 # These cases will need to be test separately kwargs = { "year": year, "month": month, "day": day, "iso_week": iso_week, "iso_day": iso_day, "ordinal_day": ordinal_day, "hour": hour, "minute": minute, "second": second, "microsecond": microsecond, "tzhour": tzhour, "tzminute": tzminute, } for timestamp_format, fields, _datetime_params in __generate_valid_formats(**kwargs): for field_name in fields: mangled_kwargs = __pad_params(**kwargs) field = NUMBER_FIELDS.get(field_name, None) if field is not None: # Too few characters for length in range(1, field.min_width): if timestamp_format.startswith("{year}W{iso_week}{iso_day}") and field_name == "iso_week": # If you reduce the iso_week field to 1 character, then the iso_day will make it into # a valid "{year}W{iso_week}" timestamp continue if timestamp_format.startswith("{year}{month}{day}") and (field_name == "month" or field_name == "day"): # If you reduce the month or day field to 1 character, then it will make it into # a valid "{year}{ordinal_day}" timestamp continue if timestamp_format.startswith("{year}{month}{day}") and field_name == "year" and length == 3: # If you reduce the year field to 3 characters, then it will make it into # a valid "{year}{ordinal_day}" timestamp continue if timestamp_format.startswith("{year}-{ordinal_day}") and field_name == "ordinal_day" and length == 2: # If you reduce the ordinal_day field to 2 characters, then it will make it into # a valid "{year}-{month}" timestamp continue str_value = str(__pad_params(**{field_name: kwargs[field_name]})[field_name])[0:length] mangled_kwargs[field_name] = "{{:0>{length}}}".format(length=length).format(str_value) timestamp = timestamp_format.format(**mangled_kwargs) yield (timestamp, "{0} has too few characters".format(field_name)) # Too many characters if field.max_width is not None: if timestamp_format.startswith("{year}-{month}") and field_name == "month": # If you extend the month field to 3 characters, then it will make it into # a valid "{year}{ordinal_day}" timestamp continue mangled_kwargs[field_name] = "{{:0>{length}}}".format(length=field.max_width + 1).format(kwargs[field_name]) timestamp = timestamp_format.format(**mangled_kwargs) yield (timestamp, "{0} has too many characters".format(field_name)) # Too small of value if (field.min_value - 1) >= 0: mangled_kwargs[field_name] = __pad_params(**{field_name: field.min_value - 1})[field_name] timestamp = timestamp_format.format(**mangled_kwargs) yield (timestamp, "{0} has too small value".format(field_name)) # Too large of value if field.max_value is not None: mangled_kwargs[field_name] = __pad_params(**{field_name: field.max_value + 1})[field_name] timestamp = timestamp_format.format(**mangled_kwargs) yield (timestamp, "{0} has too large value".format(field_name)) # Invalid characters max_invalid_characters = field.max_width if field.max_width is not None else 1 # ex. 2014 -> a, aa, aaa for length in range(1, max_invalid_characters): mangled_kwargs[field_name] = "a" * length timestamp = timestamp_format.format(**mangled_kwargs) yield (timestamp, "{0} has invalid characters".format(field_name)) # ex. 2014 -> aaaa, 2aaa, 20aa, 201a for length in range(0, max_invalid_characters): str_value = str(__pad_params(**{field_name: kwargs[field_name]})[field_name])[0:length] mangled_kwargs[field_name] = "{{:a<{length}}}".format(length=max_invalid_characters).format(str_value) timestamp = timestamp_format.format(**mangled_kwargs) yield (timestamp, "{0} has invalid characters".format(field_name)) # Trailing characters timestamp = timestamp_format.format(**__pad_params(**kwargs)) + "EXTRA" yield (timestamp, "{0} has extra characters".format(field_name)) ciso8601-2.3.1/isocalendar.c000066400000000000000000000254301451720545600154360ustar00rootroot00000000000000/* This file was originally taken from cPython's code base * (`Modules/_datetimemodule.c`) at commit * 27d8dc2c9d3de886a884f79f0621d4586c0e0f7a * * Below is a copy of the Python 3.11 code license * (from https://docs.python.org/3/license.html): * * PSF LICENSE AGREEMENT FOR PYTHON 3.11.0 * * 1. This LICENSE AGREEMENT is between the Python Software Foundation ("PSF"), * and the Individual or Organization ("Licensee") accessing and otherwise * using Python 3.11.0 software in source or binary form and its associated * documentation. * * 2. Subject to the terms and conditions of this License Agreement, PSF hereby * grants Licensee a nonexclusive, royalty-free, world-wide license to * reproduce, analyze, test, perform and/or display publicly, prepare * derivative works, distribute, and otherwise use Python 3.11.0 alone or in * any derivative version, provided, however, that PSF's License Agreement * and PSF's notice of copyright, i.e., "Copyright © 2001-2022 Python * Software Foundation; All Rights Reserved" are retained in Python 3.11.0 * alone or in any derivative version prepared by Licensee. * * 3. In the event Licensee prepares a derivative work that is based on or * incorporates Python 3.11.0 or any part thereof, and wants to make the * derivative work available to others as provided herein, then Licensee * hereby agrees to include in any such work a brief summary of the changes * made to Python 3.11.0. * * 4. PSF is making Python 3.11.0 available to Licensee on an "AS IS" basis. * PSF MAKES NO REPRESENTATIONS OR WARRANTIES, EXPRESS OR IMPLIED. BY WAY * OF EXAMPLE, BUT NOT LIMITATION, PSF MAKES NO AND DISCLAIMS ANY * REPRESENTATION OR WARRANTY OF MERCHANTABILITY OR FITNESS FOR ANY * PARTICULAR PURPOSE OR THAT THE USE OF PYTHON 3.11.0 WILL NOT INFRINGE ANY * THIRD PARTY RIGHTS. * * 5. PSF SHALL NOT BE LIABLE TO LICENSEE OR ANY OTHER USERS OF PYTHON 3.11.0 * FOR ANY INCIDENTAL, SPECIAL, OR CONSEQUENTIAL DAMAGES OR LOSS AS A RESULT * OF MODIFYING, DISTRIBUTING, OR OTHERWISE USING PYTHON 3.11.0, OR ANY * DERIVATIVE THEREOF, EVEN IF ADVISED OF THE POSSIBILITY THEREOF. * * 6. This License Agreement will automatically terminate upon a material * breach of its terms and conditions. * * 7. Nothing in this License Agreement shall be deemed to create any * relationship of agency, partnership, or joint venture between PSF and * Licensee. This License Agreement does not grant permission to use PSF * trademarks or trade name in a trademark sense to endorse or promote * products or services of Licensee, or any third party. * * 8. By copying, installing or otherwise using Python 3.11.0, Licensee agrees * to be bound by the terms and conditions of this License Agreement. */ #include "isocalendar.h" #include "Python.h" /* --------------------------------------------------------------------------- * General calendrical helper functions */ /* For each month ordinal in 1..12, the number of days in that month, * and the number of days before that month in the same year. These * are correct for non-leap years only. */ static const int _days_in_month[] = { 0, /* unused; this vector uses 1-based indexing */ 31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31, }; static const int _days_before_month[] = { 0, /* unused; this vector uses 1-based indexing */ 0, 31, 59, 90, 120, 151, 181, 212, 243, 273, 304, 334, 365 // Useful for month + 1 accesses for December }; /* year -> 1 if leap year, else 0. */ static int is_leap(int year) { /* Cast year to unsigned. The result is the same either way, but * C can generate faster code for unsigned mod than for signed * mod (especially for % 4 -- a good compiler should just grab * the last 2 bits when the LHS is unsigned). */ const unsigned int ayear = (unsigned int)year; return ayear % 4 == 0 && (ayear % 100 != 0 || ayear % 400 == 0); } /* year, month -> number of days in that month in that year */ static int days_in_month(int year, int month) { assert(month >= 1); assert(month <= 12); if (month == 2 && is_leap(year)) return 29; else return _days_in_month[month]; } /* year, month -> number of days in year preceding first day of month */ static int days_before_month(int year, int month) { int days; assert(month >= 1); assert(month <= 12); days = _days_before_month[month]; if (month > 2 && is_leap(year)) ++days; return days; } /* year -> number of days before January 1st of year. Remember that we * start with year 1, so days_before_year(1) == 0. */ static int days_before_year(int year) { int y = year - 1; /* This is incorrect if year <= 0; we really want the floor * here. But so long as MINYEAR is 1, the smallest year this * can see is 1. */ assert(year >= 1); return y * 365 + y / 4 - y / 100 + y / 400; } /* Number of days in 4, 100, and 400 year cycles. That these have * the correct values is asserted in the module init function. */ #define DI4Y 1461 /* days_before_year(5); days in 4 years */ #define DI100Y 36524 /* days_before_year(101); days in 100 years */ #define DI400Y 146097 /* days_before_year(401); days in 400 years */ /* ordinal -> year, month, day, considering 01-Jan-0001 as day 1. */ static void ord_to_ymd(int ordinal, int *year, int *month, int *day) { int n, n1, n4, n100, n400, leapyear, preceding; /* ordinal is a 1-based index, starting at 1-Jan-1. The pattern of * leap years repeats exactly every 400 years. The basic strategy is * to find the closest 400-year boundary at or before ordinal, then * work with the offset from that boundary to ordinal. Life is much * clearer if we subtract 1 from ordinal first -- then the values * of ordinal at 400-year boundaries are exactly those divisible * by DI400Y: * * D M Y n n-1 * -- --- ---- ---------- ---------------- * 31 Dec -400 -DI400Y -DI400Y -1 * 1 Jan -399 -DI400Y +1 -DI400Y 400-year boundary * ... * 30 Dec 000 -1 -2 * 31 Dec 000 0 -1 * 1 Jan 001 1 0 400-year boundary * 2 Jan 001 2 1 * 3 Jan 001 3 2 * ... * 31 Dec 400 DI400Y DI400Y -1 * 1 Jan 401 DI400Y +1 DI400Y 400-year boundary */ assert(ordinal >= 1); --ordinal; n400 = ordinal / DI400Y; n = ordinal % DI400Y; *year = n400 * 400 + 1; /* Now n is the (non-negative) offset, in days, from January 1 of * year, to the desired date. Now compute how many 100-year cycles * precede n. * Note that it's possible for n100 to equal 4! In that case 4 full * 100-year cycles precede the desired day, which implies the * desired day is December 31 at the end of a 400-year cycle. */ n100 = n / DI100Y; n = n % DI100Y; /* Now compute how many 4-year cycles precede it. */ n4 = n / DI4Y; n = n % DI4Y; /* And now how many single years. Again n1 can be 4, and again * meaning that the desired day is December 31 at the end of the * 4-year cycle. */ n1 = n / 365; n = n % 365; *year += n100 * 100 + n4 * 4 + n1; if (n1 == 4 || n100 == 4) { assert(n == 0); *year -= 1; *month = 12; *day = 31; return; } /* Now the year is correct, and n is the offset from January 1. We * find the month via an estimate that's either exact or one too * large. */ leapyear = n1 == 3 && (n4 != 24 || n100 == 3); assert(leapyear == is_leap(*year)); *month = (n + 50) >> 5; preceding = (_days_before_month[*month] + (*month > 2 && leapyear)); if (preceding > n) { /* estimate is too large */ *month -= 1; preceding -= days_in_month(*year, *month); } n -= preceding; assert(0 <= n); assert(n < days_in_month(*year, *month)); *day = n + 1; } /* year, month, day -> ordinal, considering 01-Jan-0001 as day 1. */ static int ymd_to_ord(int year, int month, int day) { return days_before_year(year) + days_before_month(year, month) + day; } /* Day of week, where Monday==0, ..., Sunday==6. 1/1/1 was a Monday. */ static int weekday(int year, int month, int day) { return (ymd_to_ord(year, month, day) + 6) % 7; } /* Ordinal of the Monday starting week 1 of the ISO year. Week 1 is the * first calendar week containing a Thursday. */ static int iso_week1_monday(int year) { int first_day = ymd_to_ord(year, 1, 1); /* ord of 1/1 */ /* 0 if 1/1 is a Monday, 1 if a Tue, etc. */ int first_weekday = (first_day + 6) % 7; /* ordinal of closest Monday at or before 1/1 */ int week1_monday = first_day - first_weekday; if (first_weekday > 3) /* if 1/1 was Fri, Sat, Sun */ week1_monday += 7; return week1_monday; } int iso_to_ymd(const int iso_year, const int iso_week, const int iso_day, int *year, int *month, int *day) { if (iso_week <= 0 || iso_week >= 53) { int out_of_range = 1; if (iso_week == 53) { // ISO years have 53 weeks in it on years starting with a Thursday // and on leap years starting on Wednesday int first_weekday = weekday(iso_year, 1, 1); if (first_weekday == 3 || (first_weekday == 2 && is_leap(iso_year))) { out_of_range = 0; } } if (out_of_range) { return -2; } } if (iso_day <= 0 || iso_day >= 8) { return -3; } // Convert (Y, W, D) to (Y, M, D) in-place int day_1 = iso_week1_monday(iso_year); int day_offset = (iso_week - 1) * 7 + iso_day - 1; ord_to_ymd(day_1 + day_offset, year, month, day); return 0; } int ordinal_to_ymd(const int iso_year, int ordinal_day, int *year, int *month, int *day) { if (ordinal_day < 1) { return -1; } /* January */ if (ordinal_day <= _days_before_month[2]) { *year = iso_year; *month = 1; *day = ordinal_day - _days_before_month[1]; return 0; } /* February */ if (ordinal_day <= (_days_before_month[3] + (is_leap(iso_year) ? 1 : 0))) { *year = iso_year; *month = 2; *day = ordinal_day - _days_before_month[2]; return 0; } if (is_leap(iso_year)) { ordinal_day -= 1; } /* March - December */ for (int i = 3; i <= 12; i++) { if (ordinal_day <= _days_before_month[i + 1]) { *year = iso_year; *month = i; *day = ordinal_day - _days_before_month[i]; return 0; } } return -2; } ciso8601-2.3.1/isocalendar.h000066400000000000000000000004361451720545600154420ustar00rootroot00000000000000#ifndef ISO_CALENDER_H #define ISO_CALENDER_H int iso_to_ymd(const int iso_year, const int iso_week, const int iso_day, int *year, int *month, int *day); int ordinal_to_ymd(const int iso_year, const int ordinal_day, int *year, int *month, int *day); #endif ciso8601-2.3.1/module.c000066400000000000000000000646751451720545600144550ustar00rootroot00000000000000#include #include #include #include "isocalendar.h" #include "timezone.h" #define STRINGIZE(x) #x #define EXPAND_AND_STRINGIZE(x) STRINGIZE(x) #define PY_VERSION_AT_LEAST_33 \ ((PY_MAJOR_VERSION == 3 && PY_MINOR_VERSION >= 3) || PY_MAJOR_VERSION > 3) #define PY_VERSION_AT_LEAST_36 \ ((PY_MAJOR_VERSION == 3 && PY_MINOR_VERSION >= 6) || PY_MAJOR_VERSION > 3) #define PY_VERSION_AT_LEAST_37 \ ((PY_MAJOR_VERSION == 3 && PY_MINOR_VERSION >= 7) || PY_MAJOR_VERSION > 3) #define PY_VERSION_AT_LEAST_38 \ ((PY_MAJOR_VERSION == 3 && PY_MINOR_VERSION >= 8) || PY_MAJOR_VERSION > 3) /* PyPy compatibility for cPython 3.7's Timezone API was added to PyPy 7.3.6 * https://foss.heptapod.net/pypy/pypy/-/merge_requests/826 * But was then reverted in 7.3.7 for PyPy 3.7: * https://foss.heptapod.net/pypy/pypy/-/commit/eeeafcf905afa0f26049ac29dc00f5b295171f99 * It is still present in 7.3.7 for PyPy 3.8+ */ #ifdef PYPY_VERSION #define SUPPORTS_37_TIMEZONE_API \ (PYPY_VERSION_NUM >= 0x07030600) && PY_VERSION_AT_LEAST_38 #else #define SUPPORTS_37_TIMEZONE_API PY_VERSION_AT_LEAST_37 #endif static PyObject *utc; #if CISO8601_CACHING_ENABLED /* 2879 = (1439 * 2) + 1, number of offsets from UTC possible in * Python (i.e., [-1439, 1439]). * * 0 - 1438 = Negative offsets [-1439..-1] * 1439 = Zero offset * 1440 - 2878 = Positive offsets [1...1439] */ static PyObject *tz_cache[2879] = {NULL}; #endif #define PARSE_INTEGER(field, length, field_name) \ for (i = 0; i < length; i++) { \ if (*c >= '0' && *c <= '9') { \ field = 10 * field + *c++ - '0'; \ } \ else { \ return format_unexpected_character_exception( \ field_name, c, (c - str) / sizeof(char), length - i); \ } \ } #define PARSE_FRACTIONAL_SECOND() \ for (i = 0; i < 6; i++) { \ if (*c >= '0' && *c <= '9') { \ usecond = 10 * usecond + *c++ - '0'; \ } \ else if (i == 0) { \ /* We need at least one digit. */ \ /* Trailing '.' or ',' is not allowed */ \ return format_unexpected_character_exception( \ "subsecond", c, (c - str) / sizeof(char), 1); \ } \ else \ break; \ } \ \ /* Omit excessive digits */ \ while (*c >= '0' && *c <= '9') c++; \ \ /* If we break early, fully expand the usecond */ \ while (i++ < 6) usecond *= 10; #if PY_VERSION_AT_LEAST_33 #define PARSE_SEPARATOR(separator, field_name) \ if (separator) { \ c++; \ } \ else { \ PyObject *unicode_str = PyUnicode_FromString(c); \ PyObject *unicode_char = PyUnicode_Substring(unicode_str, 0, 1); \ PyErr_Format(PyExc_ValueError, \ "Invalid character while parsing %s ('%U', Index: %lu)", \ field_name, unicode_char, (c - str) / sizeof(char)); \ Py_DECREF(unicode_str); \ Py_DECREF(unicode_char); \ return NULL; \ } #else #define PARSE_SEPARATOR(separator, field_name) \ if (separator) { \ c++; \ } \ else { \ if (isascii((int)*c)) { \ PyErr_Format( \ PyExc_ValueError, \ "Invalid character while parsing %s ('%c', Index: %lu)", \ field_name, *c, (c - str) / sizeof(char)); \ } \ else { \ PyErr_Format(PyExc_ValueError, \ "Invalid character while parsing %s (Index: %lu)", \ field_name, (c - str) / sizeof(char)); \ } \ return NULL; \ } #endif static void * format_unexpected_character_exception(char *field_name, const char *c, size_t index, int expected_character_count) { if (*c == '\0') { PyErr_Format( PyExc_ValueError, "Unexpected end of string while parsing %s. Expected %d more " "character%s", field_name, expected_character_count, (expected_character_count != 1) ? "s" : ""); } else { #if PY_VERSION_AT_LEAST_33 PyObject *unicode_str = PyUnicode_FromString(c); PyObject *unicode_char = PyUnicode_Substring(unicode_str, 0, 1); PyErr_Format(PyExc_ValueError, "Invalid character while parsing %s ('%U', Index: %zu)", field_name, unicode_char, index); Py_DECREF(unicode_str); Py_DECREF(unicode_char); #else if (isascii((int)*c)) { PyErr_Format( PyExc_ValueError, "Invalid character while parsing %s ('%c', Index: %zu)", field_name, *c, index); } else { PyErr_Format(PyExc_ValueError, "Invalid character while parsing %s (Index: %zu)", field_name, index); } #endif } return NULL; } #define IS_CALENDAR_DATE_SEPARATOR (*c == '-') #define IS_ISOCALENDAR_SEPARATOR (*c == 'W') #define IS_DATE_AND_TIME_SEPARATOR (*c == 'T' || *c == ' ' || *c == 't') #define IS_TIME_SEPARATOR (*c == ':') #define IS_TIME_ZONE_SEPARATOR \ (*c == 'Z' || *c == '-' || *c == '+' || *c == 'z') #define IS_FRACTIONAL_SEPARATOR (*c == '.' || (*c == ',' && !rfc3339_only)) static PyObject * _parse(PyObject *self, PyObject *dtstr, int parse_any_tzinfo, int rfc3339_only) { PyObject *obj; PyObject *tzinfo = Py_None; #if PY_VERSION_AT_LEAST_33 Py_ssize_t len; #endif int i; const char *str; const char *c; int year = 0, month = 0, day = 0, hour = 0, minute = 0, second = 0, usecond = 0; int iso_week = 0, iso_day = 0; int ordinal_day = 0; int time_is_midnight = 0; int tzhour = 0, tzminute = 0, tzsign = 0; #if CISO8601_CACHING_ENABLED int tz_index = 0; #endif PyObject *delta; PyObject *temp; int extended_date_format = 0; #if PY_MAJOR_VERSION >= 3 if (!PyUnicode_Check(dtstr)) { #else if (!PyString_Check(dtstr)) { #endif PyErr_SetString(PyExc_TypeError, "argument must be str"); return NULL; } #if PY_VERSION_AT_LEAST_33 str = c = PyUnicode_AsUTF8AndSize(dtstr, &len); #else str = c = PyString_AsString(dtstr); #endif /* Year */ PARSE_INTEGER(year, 4, "year") #if !PY_VERSION_AT_LEAST_36 /* Python 3.6+ does this validation as part of datetime's C API * constructor. See * https://github.com/python/cpython/commit/b67f0967386a9c9041166d2bbe0a421bd81e10bc * We skip ` || year < datetime.MAXYEAR)`, since ciso8601 currently doesn't * support 5 character years, so it is impossible. */ if (year < 1) /* datetime.MINYEAR = 1, which is not exposed to the C API. */ { PyErr_Format(PyExc_ValueError, "year %d is out of range", year); return NULL; } #endif if (IS_CALENDAR_DATE_SEPARATOR) { c++; extended_date_format = 1; if (IS_ISOCALENDAR_SEPARATOR) { /* Separated ISO Calendar week and day (i.e., Www-D) */ c++; if (rfc3339_only) { PyErr_SetString(PyExc_ValueError, "Datetime string not in RFC 3339 format."); return NULL; } PARSE_INTEGER(iso_week, 2, "iso_week") if (*c != '\0' && !IS_DATE_AND_TIME_SEPARATOR) { /* Optional Day */ PARSE_SEPARATOR(IS_CALENDAR_DATE_SEPARATOR, "date separator ('-')") PARSE_INTEGER(iso_day, 1, "iso_day") } else { iso_day = 1; } int rv = iso_to_ymd(year, iso_week, iso_day, &year, &month, &day); if (rv) { PyErr_Format(PyExc_ValueError, "Invalid ISO Calendar date"); return NULL; } } else { /* Separated month and may (i.e., MM-DD) or ordinal date (i.e., DDD) */ /* For sake of simplicity, we'll assume that it is a month * If we find out later that it's an ordinal day, then we'll adjust */ PARSE_INTEGER(month, 2, "month") if (*c != '\0' && !IS_DATE_AND_TIME_SEPARATOR) { if (IS_CALENDAR_DATE_SEPARATOR) { /* Optional day */ c++; PARSE_INTEGER(day, 2, "day") } else { /* Ordinal day */ PARSE_INTEGER(ordinal_day, 1, "ordinal day") ordinal_day = (month * 10) + ordinal_day; int rv = ordinal_to_ymd(year, ordinal_day, &year, &month, &day); if (rv) { PyErr_Format( PyExc_ValueError, "Invalid ordinal day: %d is %s for year %d", ordinal_day, rv == -1 ? "too small" : "too large", year); return NULL; } } } else if (rfc3339_only) { PyErr_SetString(PyExc_ValueError, "Datetime string not in RFC 3339 format."); return NULL; } else { day = 1; } } } else if (rfc3339_only) { PyErr_SetString(PyExc_ValueError, "Datetime string not in RFC 3339 format."); return NULL; } else { if (IS_ISOCALENDAR_SEPARATOR) { /* Non-separated ISO Calendar week and day (i.e., WwwD) */ c++; PARSE_INTEGER(iso_week, 2, "iso_week") if (*c != '\0' && !IS_DATE_AND_TIME_SEPARATOR) { /* Optional Day */ PARSE_INTEGER(iso_day, 1, "iso_day") } else { iso_day = 1; } int rv = iso_to_ymd(year, iso_week, iso_day, &year, &month, &day); if (rv) { PyErr_Format(PyExc_ValueError, "Invalid ISO Calendar date"); return NULL; } } else { /* Non-separated Month and Day (i.e., MMDD) or ordinal date (i.e., DDD)*/ /* For sake of simplicity, we'll assume that it is a month * If we find out later that it's an ordinal day, then we'll adjust */ PARSE_INTEGER(month, 2, "month") PARSE_INTEGER(ordinal_day, 1, "ordinal day") if (*c == '\0' || IS_DATE_AND_TIME_SEPARATOR) { /* Ordinal day */ ordinal_day = (month * 10) + ordinal_day; int rv = ordinal_to_ymd(year, ordinal_day, &year, &month, &day); if (rv) { PyErr_Format(PyExc_ValueError, "Invalid ordinal day: %d is %s for year %d", ordinal_day, rv == -1 ? "too small" : "too large", year); return NULL; } } else { /* Day */ /* Note that YYYYMM is not a valid timestamp. If the calendar * date is not separated, a day is required (i.e., YYMMDD) */ PARSE_INTEGER(day, 1, "day") day = (ordinal_day * 10) + day; } } } #if !PY_VERSION_AT_LEAST_36 /* Validation of date fields * These checks are needed for Python <3.6 support. See * https://github.com/closeio/ciso8601/pull/30 Python 3.6+ does this * validation as part of datetime's C API constructor. See * https://github.com/python/cpython/commit/b67f0967386a9c9041166d2bbe0a421bd81e10bc */ if (month < 1 || month > 12) { PyErr_SetString(PyExc_ValueError, "month must be in 1..12"); return NULL; } if (day < 1) { PyErr_SetString(PyExc_ValueError, "day is out of range for month"); return NULL; } /* Validate max day based on month */ switch (month) { case 2: /* In the Gregorian calendar three criteria must be taken into * account to identify leap years: * -The year can be evenly divided by 4; * -If the year can be evenly divided by 100, it is NOT a leap * year, unless; * -The year is also evenly divisible by 400. Then it is a leap * year. */ if (day > 28) { unsigned int leap = (year % 4 == 0) && (year % 100 || (year % 400 == 0)); if (leap == 0 || day > 29) { PyErr_SetString(PyExc_ValueError, "day is out of range for month"); return NULL; } } break; case 4: case 6: case 9: case 11: if (day > 30) { PyErr_SetString(PyExc_ValueError, "day is out of range for month"); return NULL; } break; default: /* For other months i.e. 1, 3, 5, 7, 8, 10 and 12 */ if (day > 31) { PyErr_SetString(PyExc_ValueError, "day is out of range for month"); return NULL; } break; } #endif if (*c != '\0') { /* Date and time separator */ PARSE_SEPARATOR(IS_DATE_AND_TIME_SEPARATOR, "date and time separator (i.e., 'T', 't', or ' ')") /* Hour */ PARSE_INTEGER(hour, 2, "hour") if (*c != '\0' && !IS_TIME_ZONE_SEPARATOR) { /* Optional minute and second */ if (IS_TIME_SEPARATOR) { /* Separated Minute and Second * (i.e., mm:ss) */ c++; /* Minute */ PARSE_INTEGER(minute, 2, "minute") if (*c != '\0' && !IS_TIME_ZONE_SEPARATOR) { /* Optional Second */ PARSE_SEPARATOR(IS_TIME_SEPARATOR, "time separator (':')") /* Second */ PARSE_INTEGER(second, 2, "second") /* Optional Fractional Second */ if (IS_FRACTIONAL_SEPARATOR) { c++; PARSE_FRACTIONAL_SECOND() } } else if (rfc3339_only) { PyErr_SetString(PyExc_ValueError, "RFC 3339 requires the second to be " "specified."); return NULL; } if (!extended_date_format) { PyErr_SetString( PyExc_ValueError, "Cannot combine \"basic\" date format with" " \"extended\" time format (Should be either " "`YYYY-MM-DDThh:mm:ss` or `YYYYMMDDThhmmss`)."); return NULL; } } else if (rfc3339_only) { PyErr_SetString(PyExc_ValueError, "Colons separating time components are " "mandatory in RFC 3339."); return NULL; } else { /* Non-separated Minute and Second (i.e., mmss) */ /* Minute */ PARSE_INTEGER(minute, 2, "minute") if (*c != '\0' && !IS_TIME_ZONE_SEPARATOR) { /* Optional Second */ /* Second */ PARSE_INTEGER(second, 2, "second") /* Optional Fractional Second */ if (IS_FRACTIONAL_SEPARATOR) { c++; PARSE_FRACTIONAL_SECOND() } } if (extended_date_format) { PyErr_SetString( PyExc_ValueError, "Cannot combine \"extended\" date format with" " \"basic\" time format (Should be either " "`YYYY-MM-DDThh:mm:ss` or `YYYYMMDDThhmmss`)."); return NULL; } } } else if (rfc3339_only) { PyErr_SetString(PyExc_ValueError, "Minute and second are mandatory in RFC 3339"); return NULL; } if (hour == 24 && minute == 0 && second == 0 && usecond == 0) { /* Special case of 24:00:00, that is allowed in ISO 8601. It is * equivalent to 00:00:00 the following day */ if (rfc3339_only) { PyErr_SetString(PyExc_ValueError, "An hour value of 24, while sometimes legal " "in ISO 8601, is explicitly forbidden by RFC " "3339."); return NULL; } hour = 0, minute = 0, second = 0, usecond = 0; time_is_midnight = 1; } #if !PY_VERSION_AT_LEAST_36 /* Validate hour/minute/second * Only needed for Python <3.6 support. * Python 3.6+ does this validation as part of datetime's constructor). */ if (hour > 23) { PyErr_SetString(PyExc_ValueError, "hour must be in 0..23"); return NULL; } if (minute > 59) { PyErr_SetString(PyExc_ValueError, "minute must be in 0..59"); return NULL; } if (second > 59) { PyErr_SetString(PyExc_ValueError, "second must be in 0..59"); return NULL; } #endif /* Optional tzinfo */ if (IS_TIME_ZONE_SEPARATOR) { if (*c == '+') { tzsign = 1; } else if (*c == '-') { tzsign = -1; } c++; if (tzsign != 0) { /* tz hour */ PARSE_INTEGER(tzhour, 2, "tz hour") if (IS_TIME_SEPARATOR) { /* Optional separator */ c++; /* tz minute */ PARSE_INTEGER(tzminute, 2, "tz minute") } else if (rfc3339_only) { PyErr_SetString(PyExc_ValueError, "Separator between hour and minute in UTC " "offset is mandatory in RFC 3339"); return NULL; } else if (*c != '\0') { /* Optional tz minute */ PARSE_INTEGER(tzminute, 2, "tz minute") } } /* It's not entirely clear whether this validation check is * necessary under ISO 8601. For now, we will err on the side of * caution and prevent suspected invalid timestamps. If we need to * loosen this restriction later, we can. */ if (tzminute > 59) { PyErr_SetString(PyExc_ValueError, "tzminute must be in 0..59"); return NULL; } if (parse_any_tzinfo) { tzminute += 60 * tzhour; tzminute *= tzsign; if (tzminute == 0) { tzinfo = utc; } else if (abs(tzminute) >= 1440) { /* Format the error message as if we were still using pytz * for Python 2 and datetime.timezone for Python 3. * This is done to maintain complete backwards * compatibility with ciso8601 2.0.x. Perhaps change to a * simpler message in ciso8601 v3.0.0. */ #if PY_MAJOR_VERSION >= 3 delta = PyDelta_FromDSU(0, tzminute * 60, 0); PyErr_Format(PyExc_ValueError, "offset must be a timedelta" " strictly between -timedelta(hours=24) and" " timedelta(hours=24)," " not %R.", delta); Py_DECREF(delta); #else PyErr_Format(PyExc_ValueError, "('absolute offset is too large', %d)", tzminute); #endif return NULL; } else { #if CISO8601_CACHING_ENABLED tz_index = tzminute + 1439; if ((tzinfo = tz_cache[tz_index]) == NULL) { tzinfo = new_fixed_offset(60 * tzminute); if (tzinfo == NULL) /* i.e., PyErr_Occurred() */ return NULL; tz_cache[tz_index] = tzinfo; } #else tzinfo = new_fixed_offset(60 * tzminute); if (tzinfo == NULL) /* i.e., PyErr_Occurred() */ return NULL; #endif } } } else if (rfc3339_only) { PyErr_SetString(PyExc_ValueError, "UTC offset is mandatory in RFC 3339 format."); return NULL; } } else if (rfc3339_only) { PyErr_SetString(PyExc_ValueError, "Time is mandatory in RFC 3339 format."); return NULL; } /* Make sure that there is no more to parse. */ if (*c != '\0') { PyErr_Format(PyExc_ValueError, "unconverted data remains: '%s'", c); #if !CISO8601_CACHING_ENABLED if (tzinfo != Py_None && tzinfo != utc) Py_DECREF(tzinfo); #endif return NULL; } obj = PyDateTimeAPI->DateTime_FromDateAndTime( year, month, day, hour, minute, second, usecond, tzinfo, PyDateTimeAPI->DateTimeType); #if !CISO8601_CACHING_ENABLED if (tzinfo != Py_None && tzinfo != utc) Py_DECREF(tzinfo); #endif if (obj && time_is_midnight) { delta = PyDelta_FromDSU(1, 0, 0); /* 1 day */ temp = obj; obj = PyNumber_Add(temp, delta); Py_DECREF(delta); Py_DECREF(temp); } return obj; } static PyObject * parse_datetime_as_naive(PyObject *self, PyObject *dtstr) { return _parse(self, dtstr, 0, 0); } static PyObject * parse_datetime(PyObject *self, PyObject *dtstr) { return _parse(self, dtstr, 1, 0); } static PyObject * parse_rfc3339(PyObject *self, PyObject *dtstr) { return _parse(self, dtstr, 1, 1); } static PyObject * _hard_coded_benchmark_timestamp(PyObject *self, PyObject *ignored) { return PyDateTimeAPI->DateTime_FromDateAndTime( 2014, 1, 9, 21, 48, 0, 0, Py_None, PyDateTimeAPI->DateTimeType); } static PyMethodDef CISO8601Methods[] = { {"parse_datetime", (PyCFunction)parse_datetime, METH_O, "Parse a ISO8601 date time string."}, {"parse_datetime_as_naive", parse_datetime_as_naive, METH_O, "Parse a ISO8601 date time string, ignoring the time zone component."}, {"parse_rfc3339", parse_rfc3339, METH_O, "Parse an RFC 3339 date time string."}, {"_hard_coded_benchmark_timestamp", _hard_coded_benchmark_timestamp, METH_NOARGS, "Return a datetime using hardcoded values (for benchmarking purposes)"}, {NULL, NULL, 0, NULL}}; #if PY_MAJOR_VERSION >= 3 static struct PyModuleDef moduledef = { PyModuleDef_HEAD_INIT, "ciso8601", NULL, -1, CISO8601Methods, NULL, NULL, NULL, NULL, }; #endif PyMODINIT_FUNC #if PY_MAJOR_VERSION >= 3 PyInit_ciso8601(void) #else initciso8601(void) #endif { #if PY_MAJOR_VERSION >= 3 PyObject *module = PyModule_Create(&moduledef); #else PyObject *module = Py_InitModule("ciso8601", CISO8601Methods); #endif /* CISO8601_VERSION is defined in setup.py */ PyModule_AddStringConstant(module, "__version__", EXPAND_AND_STRINGIZE(CISO8601_VERSION)); PyDateTime_IMPORT; // PyMODINIT_FUNC is void in Python 2, returns PyObject* in Python 3 if (initialize_timezone_code(module) < 0) { #if PY_MAJOR_VERSION >= 3 return NULL; #else return; #endif } #if SUPPORTS_37_TIMEZONE_API utc = PyDateTime_TimeZone_UTC; #else utc = new_fixed_offset(0); #endif // PyMODINIT_FUNC is void in Python 2, returns PyObject* in Python 3 #if PY_MAJOR_VERSION >= 3 return module; #endif } ciso8601-2.3.1/pyproject.toml000066400000000000000000000003071451720545600157160ustar00rootroot00000000000000[tool.pylint.'MESSAGES CONTROL'] max-line-length = 120 disable = "C0114, C0115, C0116, C0301" [tool.autopep8] max_line_length = 120 ignore = ["E501"] in-place = true recursive = true aggressive = 3 ciso8601-2.3.1/setup.py000066400000000000000000000054521451720545600145220ustar00rootroot00000000000000import os from setuptools import setup, Extension # workaround for open() with encoding='' python2/3 compatibility from io import open with open("README.rst", encoding="utf-8") as file: long_description = file.read() # We want to force all warnings to be considered errors. That way we get to catch potential issues during # development and at PR review time. # But since ciso8601 is a source distribution, exotic compiler configurations can cause spurious warnings that # would fail the installation. So we only want to treat warnings as errors during development. if os.environ.get("STRICT_WARNINGS", "0") == "1": # We can't use `extra_compile_args`, since the cl.exe (Windows) and gcc compilers don't use the same flags. # Further, there is not an easy way to tell which compiler is being used. # Instead we rely on each compiler looking at their appropriate environment variable. # GCC/Clang try: _ = os.environ["CFLAGS"] except KeyError: os.environ["CFLAGS"] = "" os.environ["CFLAGS"] += " -Werror" # cl.exe try: _ = os.environ["_CL_"] except KeyError: os.environ["_CL_"] = "" os.environ["_CL_"] += " /WX" VERSION = "2.3.1" CISO8601_CACHING_ENABLED = int(os.environ.get('CISO8601_CACHING_ENABLED', '1') == '1') setup( name="ciso8601", version=VERSION, description="Fast ISO8601 date time parser for Python written in C", long_description=long_description, url="https://github.com/closeio/ciso8601", license="MIT", ext_modules=[ Extension( "ciso8601", sources=["module.c", "timezone.c", "isocalendar.c"], define_macros=[ ("CISO8601_VERSION", VERSION), ("CISO8601_CACHING_ENABLED", CISO8601_CACHING_ENABLED), ], ) ], packages=["ciso8601"], package_data={"ciso8601": ["__init__.pyi", "py.typed"]}, test_suite="tests", tests_require=[ "pytz", ], classifiers=[ "Intended Audience :: Developers", "License :: OSI Approved :: MIT License", "Operating System :: OS Independent", "Programming Language :: Python", "Programming Language :: Python :: 2", "Programming Language :: Python :: 2.7", "Programming Language :: Python :: 3", "Programming Language :: Python :: 3.4", "Programming Language :: Python :: 3.5", "Programming Language :: Python :: 3.6", "Programming Language :: Python :: 3.7", "Programming Language :: Python :: 3.8", "Programming Language :: Python :: 3.9", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", "Topic :: Software Development :: Libraries :: Python Modules", ], ) ciso8601-2.3.1/tests/000077500000000000000000000000001451720545600141445ustar00rootroot00000000000000ciso8601-2.3.1/tests/__init__.py000066400000000000000000000000001451720545600162430ustar00rootroot00000000000000ciso8601-2.3.1/tests/test_timezone.py000066400000000000000000000132331451720545600174110ustar00rootroot00000000000000# -*- coding: utf-8 -*- import sys import unittest from datetime import datetime, timedelta from ciso8601 import FixedOffset if sys.version_info.major == 2: # We use add `unittest.TestCase.assertRaisesRegex` method, which is called `assertRaisesRegexp` in Python 2. unittest.TestCase.assertRaisesRegex = unittest.TestCase.assertRaisesRegexp class TimezoneTestCase(unittest.TestCase): def test_utcoffset(self): if sys.version_info >= (3, 2): from datetime import timezone for minutes in range(-1439, 1440): td = timedelta(minutes=minutes) tz = timezone(td) built_in_dt = datetime(2014, 2, 3, 10, 35, 27, 234567, tzinfo=tz) our_dt = datetime(2014, 2, 3, 10, 35, 27, 234567, tzinfo=FixedOffset(minutes * 60)) self.assertEqual(built_in_dt.utcoffset(), our_dt.utcoffset(), "`utcoffset` output did not match for offset: {minutes}".format(minutes=minutes)) self.assertEqual(built_in_dt.tzinfo.utcoffset(built_in_dt), our_dt.tzinfo.utcoffset(our_dt), "`tzinfo.utcoffset` output did not match for offset: {minutes}".format(minutes=minutes)) else: for seconds in [0, +0, -0, -4980, +45240]: offset = FixedOffset(seconds) our_dt = datetime(2014, 2, 3, 10, 35, 27, 234567, tzinfo=offset) self.assertEqual(our_dt.utcoffset(), timedelta(seconds=seconds)) self.assertEqual(offset.utcoffset(our_dt), timedelta(seconds=seconds)) def test_dst(self): if sys.version_info >= (3, 2): from datetime import timezone for minutes in range(-1439, 1440): td = timedelta(minutes=minutes) tz = timezone(td) built_in_dt = datetime(2014, 2, 3, 10, 35, 27, 234567, tzinfo=tz) our_dt = datetime(2014, 2, 3, 10, 35, 27, 234567, tzinfo=FixedOffset(minutes * 60)) self.assertEqual(built_in_dt.dst(), our_dt.dst(), "`dst` output did not match for offset: {minutes}".format(minutes=minutes)) self.assertEqual(built_in_dt.tzinfo.dst(built_in_dt), our_dt.tzinfo.dst(our_dt), "`tzinfo.dst` output did not match for offset: {minutes}".format(minutes=minutes)) else: for seconds in [0, +0, -0, -4980, +45240]: offset = FixedOffset(seconds) our_dt = datetime(2014, 2, 3, 10, 35, 27, 234567, tzinfo=offset) self.assertIsNone(our_dt.dst()) self.assertIsNone(offset.dst(our_dt)) def test_tzname(self): if sys.version_info >= (3, 2): from datetime import timezone for minutes in range(-1439, 1440): td = timedelta(minutes=minutes) tz = timezone(td) built_in_dt = datetime(2014, 2, 3, 10, 35, 27, 234567, tzinfo=tz) our_dt = datetime(2014, 2, 3, 10, 35, 27, 234567, tzinfo=FixedOffset(minutes * 60)) self.assertEqual(built_in_dt.tzname(), our_dt.tzname(), "`tzname` output did not match for offset: {minutes}".format(minutes=minutes)) self.assertEqual(built_in_dt.tzinfo.tzname(built_in_dt), our_dt.tzinfo.tzname(our_dt), "`tzinfo.tzname` output did not match for offset: {minutes}".format(minutes=minutes)) else: for seconds, expected_tzname in [(0, "UTC+00:00"), (+0, "UTC+00:00"), (-0, "UTC+00:00"), (-4980, "UTC-01:23"), (+45240, "UTC+12:34")]: offset = FixedOffset(seconds) our_dt = datetime(2014, 2, 3, 10, 35, 27, 234567, tzinfo=offset) self.assertEqual(our_dt.tzname(), expected_tzname) self.assertEqual(offset.tzname(our_dt), expected_tzname) def test_fromutc(self): # https://github.com/closeio/ciso8601/issues/108 our_dt = datetime(2014, 2, 3, 10, 35, 27, 234567, tzinfo=FixedOffset(60 * 60)) expected_dt = datetime(2014, 2, 3, 11, 35, 27, 234567, tzinfo=FixedOffset(60 * 60)) self.assertEqual(expected_dt, our_dt.tzinfo.fromutc(our_dt)) if sys.version_info >= (3, 2): from datetime import timezone td = timedelta(minutes=60) tz = timezone(td) built_in_dt = datetime(2014, 2, 3, 10, 35, 27, 234567, tzinfo=tz) built_in_result = built_in_dt.tzinfo.fromutc(built_in_dt) self.assertEqual(expected_dt, built_in_result) def test_fromutc_straddling_a_day_boundary(self): our_dt = datetime(2020, 2, 29, 23, 35, 27, 234567, tzinfo=FixedOffset(60 * 60)) expected_dt = datetime(2020, 3, 1, 0, 35, 27, 234567, tzinfo=FixedOffset(60 * 60)) self.assertEqual(expected_dt, our_dt.tzinfo.fromutc(our_dt)) def test_fromutc_fails_if_given_non_datetime(self): our_dt = datetime(2014, 2, 3, 10, 35, 27, 234567, tzinfo=FixedOffset(60 * 60)) with self.assertRaises(TypeError, msg="fromutc: argument must be a datetime"): our_dt.tzinfo.fromutc(123) def test_fromutc_fails_if_tzinfo_is_none(self): our_dt = datetime(2014, 2, 3, 10, 35, 27, 234567, tzinfo=FixedOffset(60 * 60)) other_dt = datetime(2014, 2, 3, 10, 35, 27, 234567, tzinfo=None) with self.assertRaises(ValueError, msg="fromutc: dt.tzinfo is not self"): our_dt.tzinfo.fromutc(other_dt) def test_fromutc_fails_if_tzinfo_is_some_other_offset(self): our_dt = datetime(2014, 2, 3, 10, 35, 27, 234567, tzinfo=FixedOffset(60 * 60)) other_dt = datetime(2014, 2, 3, 10, 35, 27, 234567, tzinfo=FixedOffset(120 * 60)) with self.assertRaises(ValueError, msg="fromutc: dt.tzinfo is not self"): our_dt.tzinfo.fromutc(other_dt) if __name__ == '__main__': unittest.main() ciso8601-2.3.1/tests/tests.py000066400000000000000000000536761451720545600157010ustar00rootroot00000000000000# -*- coding: utf-8 -*- import copy import datetime import pickle import platform import re import sys import unittest from ciso8601 import _hard_coded_benchmark_timestamp, FixedOffset, parse_datetime, parse_datetime_as_naive, parse_rfc3339 from generate_test_timestamps import generate_valid_timestamp_and_datetime, generate_invalid_timestamp if sys.version_info.major == 2: # We use add `unittest.TestCase.assertRaisesRegex` method, which is called `assertRaisesRegexp` in Python 2. unittest.TestCase.assertRaisesRegex = unittest.TestCase.assertRaisesRegexp class ValidTimestampTestCase(unittest.TestCase): def test_auto_generated_valid_formats(self): for (timestamp, expected_datetime) in generate_valid_timestamp_and_datetime(): try: self.assertEqual(parse_datetime(timestamp), expected_datetime) except Exception: print("Had problems parsing: {timestamp}".format(timestamp=timestamp)) raise def test_parse_as_naive_auto_generated_valid_formats(self): for (timestamp, expected_datetime) in generate_valid_timestamp_and_datetime(): try: self.assertEqual(parse_datetime_as_naive(timestamp), expected_datetime.replace(tzinfo=None)) except Exception: print("Had problems parsing: {timestamp}".format(timestamp=timestamp)) raise def test_excessive_subsecond_precision(self): self.assertEqual( parse_datetime("20140203T103527.234567891234"), datetime.datetime(2014, 2, 3, 10, 35, 27, 234567), ) def test_leap_year(self): # There is nothing unusual about leap years in ISO 8601. # We just want to make sure that they work in general. for leap_year in (1600, 2000, 2016): self.assertEqual( parse_datetime("{}-02-29".format(leap_year)), datetime.datetime(leap_year, 2, 29, 0, 0, 0, 0), ) def test_special_midnight(self): self.assertEqual( parse_datetime("2014-02-03T24:00:00"), datetime.datetime(2014, 2, 4, 0, 0, 0), ) def test_returns_built_in_utc_if_available(self): # Python 3.7 added a built-in UTC object at the C level (`PyDateTime_TimeZone_UTC`) # PyPy added support for it in 7.3.6, but only for PyPy 3.8+ timestamp = '2018-01-01T00:00:00.00Z' if sys.version_info >= (3, 7) and \ (platform.python_implementation() == 'CPython' or (platform.python_implementation() == 'PyPy' and sys.version_info >= (3, 8) and sys.pypy_version_info >= (7, 3, 6))): self.assertIs(parse_datetime(timestamp).tzinfo, datetime.timezone.utc) else: self.assertIsInstance(parse_datetime(timestamp).tzinfo, FixedOffset) def test_ordinal(self): self.assertEqual( parse_datetime("2014-001"), datetime.datetime(2014, 1, 1, 0, 0, 0), ) self.assertEqual( parse_datetime("2014-031"), datetime.datetime(2014, 1, 31, 0, 0, 0), ) self.assertEqual( parse_datetime("2014-032"), datetime.datetime(2014, 2, 1, 0, 0, 0), ) self.assertEqual( parse_datetime("2014-059"), datetime.datetime(2014, 2, 28, 0, 0, 0), ) self.assertEqual( parse_datetime("2014-060"), datetime.datetime(2014, 3, 1, 0, 0, 0), ) self.assertEqual( parse_datetime("2016-060"), # Leap year datetime.datetime(2016, 2, 29, 0, 0, 0), ) self.assertEqual( parse_datetime("2014-365"), datetime.datetime(2014, 12, 31, 0, 0, 0), ) self.assertEqual( parse_datetime("2016-365"), # Leap year datetime.datetime(2016, 12, 30, 0, 0, 0), ) self.assertEqual( parse_datetime("2016-366"), # Leap year datetime.datetime(2016, 12, 31, 0, 0, 0), ) class InvalidTimestampTestCase(unittest.TestCase): # Many invalid test cases are covered by `test_parse_auto_generated_invalid_formats`, # But it doesn't cover all invalid cases, so we test those here. # See `generate_test_timestamps.generate_invalid_timestamp` for details. def test_parse_auto_generated_invalid_formats(self): for timestamp, reason in generate_invalid_timestamp(): try: with self.assertRaises(ValueError, msg="Timestamp '{0}' was supposed to be invalid ({1}), but parsing it didn't raise ValueError.".format(timestamp, reason)): parse_datetime(timestamp) except Exception as exc: print("Timestamp '{0}' was supposed to raise ValueError ({1}), but raised {2} instead".format(timestamp, reason, type(exc).__name__)) raise def test_non_ascii_characters(self): if sys.version_info >= (3, 3): self.assertRaisesRegex( ValueError, r"Invalid character while parsing date and time separator \(i.e., 'T', 't', or ' '\) \('🐵', Index: 10\)", parse_datetime, "2019-01-01🐵01:02:03Z", ) self.assertRaisesRegex( ValueError, r"Invalid character while parsing day \('🐵', Index: 8\)", parse_datetime, "2019-01-🐵", ) else: self.assertRaisesRegex( ValueError, r"Invalid character while parsing ordinal day \(Index: 7\)", parse_datetime, "2019-01🐵01", ) self.assertRaisesRegex( ValueError, r"Invalid character while parsing day \(Index: 8\)", parse_datetime, "2019-01-🐵", ) def test_invalid_calendar_separator(self): self.assertRaisesRegex( ValueError, r"Invalid character while parsing month", parse_datetime, "2018=01=01", ) self.assertRaisesRegex( ValueError, r"Invalid character while parsing ordinal day \('=', Index: 7\)", parse_datetime, "2018-01=01", ) self.assertRaisesRegex( ValueError, r"Invalid character while parsing date and time separator \(i.e., 'T', 't', or ' '\) \('2', Index: 8\)", parse_datetime, "2018-0102", ) self.assertRaisesRegex( ValueError, r"Invalid character while parsing ordinal day \('-', Index: 6\)", parse_datetime, "201801-01", ) def test_invalid_empty_but_required_fields(self): self.assertRaisesRegex( ValueError, r"Unexpected end of string while parsing year. Expected 4 more characters", parse_datetime, "", ) self.assertRaisesRegex( ValueError, r"Unexpected end of string while parsing month. Expected 2 more characters", parse_datetime, "2018-", ) self.assertRaisesRegex( ValueError, r"Unexpected end of string while parsing day. Expected 2 more characters", parse_datetime, "2018-01-", ) self.assertRaisesRegex( ValueError, r"Unexpected end of string while parsing hour. Expected 2 more characters", parse_datetime, "2018-01-01T", ) self.assertRaisesRegex( ValueError, r"Unexpected end of string while parsing minute. Expected 2 more characters", parse_datetime, "2018-01-01T00:", ) self.assertRaisesRegex( ValueError, r"Unexpected end of string while parsing second. Expected 2 more characters", parse_datetime, "2018-01-01T00:00:", ) self.assertRaisesRegex( ValueError, r"Unexpected end of string while parsing subsecond. Expected 1 more character", parse_datetime, "2018-01-01T00:00:00.", ) self.assertRaisesRegex( ValueError, r"Unexpected end of string while parsing tz hour. Expected 2 more characters", parse_datetime, "2018-01-01T00:00:00.00+", ) self.assertRaisesRegex( ValueError, r"Unexpected end of string while parsing tz minute. Expected 2 more characters", parse_datetime, "2018-01-01T00:00:00.00-00:", ) def test_invalid_day_for_month(self): if platform.python_implementation() == 'PyPy' and sys.version_info.major >= 3: for non_leap_year in (1700, 1800, 1900, 2014): self.assertRaisesRegex( ValueError, r"('day must be in 1..28', 29)", parse_datetime, "{}-02-29".format(non_leap_year), ) self.assertRaisesRegex( ValueError, r"('day must be in 1..31', 32)", parse_datetime, "2014-01-32", ) self.assertRaisesRegex( ValueError, r"('day must be in 1..30', 31)", parse_datetime, "2014-06-31", ) self.assertRaisesRegex( ValueError, r"('day must be in 1..30', 0)", parse_datetime, "2014-06-00", ) else: for non_leap_year in (1700, 1800, 1900, 2014): self.assertRaisesRegex( ValueError, r"day is out of range for month", parse_datetime, "{}-02-29".format(non_leap_year), ) self.assertRaisesRegex( ValueError, r"day is out of range for month", parse_datetime, "2014-01-32", ) self.assertRaisesRegex( ValueError, r"day is out of range for month", parse_datetime, "2014-06-31", ) self.assertRaisesRegex( ValueError, r"day is out of range for month", parse_datetime, "2014-06-00", ) def test_invalid_ordinal(self): self.assertRaisesRegex( ValueError, r"Invalid ordinal day: 0 is too small", parse_datetime, "2014-000", ) self.assertRaisesRegex( ValueError, r"Invalid ordinal day: 0 is too small", parse_datetime, "2014000", ) self.assertRaisesRegex( ValueError, r"Invalid ordinal day: 366 is too large for year 2014", parse_datetime, "2014-366", # Not a leap year ) self.assertRaisesRegex( ValueError, r"Invalid ordinal day: 366 is too large for year 2014", parse_datetime, "2014366", # Not a leap year ) self.assertRaisesRegex( ValueError, r"Invalid ordinal day: 999 is too large for year 2014", parse_datetime, "2014-999", ) self.assertRaisesRegex( ValueError, r"Invalid ordinal day: 999 is too large for year 2014", parse_datetime, "2014999", ) def test_invalid_yyyymm_format(self): self.assertRaisesRegex( ValueError, r"Unexpected end of string while parsing ordinal day. Expected 1 more character", parse_datetime, "201406", ) def test_invalid_date_and_time_separator(self): self.assertRaisesRegex( ValueError, r"Invalid character while parsing date and time separator \(i.e., 'T', 't', or ' '\) \('_', Index: 10\)", parse_datetime, "2018-01-01_00:00:00", ) def test_invalid_hour_24(self): # A value of hour = 24 is only valid in the special case of 24:00:00 self.assertRaisesRegex( ValueError, r"hour must be in 0..23", parse_datetime, "2014-02-03T24:35:27", ) def test_invalid_time_separator(self): self.assertRaisesRegex( ValueError, r"Invalid character while parsing time separator \(':'\) \('=', Index: 16\)", parse_datetime, "2018-01-01T00:00=00", ) self.assertRaisesRegex( ValueError, r"Invalid character while parsing time separator \(':'\) \('0', Index: 16\)", parse_datetime, "2018-01-01T00:0000", ) self.assertRaisesRegex( ValueError, r"Invalid character while parsing second \(':', Index: 15\)", parse_datetime, "2018-01-01T0000:00", ) def test_invalid_tz_minute(self): self.assertRaisesRegex( ValueError, r"tzminute must be in 0..59", parse_datetime, "2018-01-01T00:00:00.00-00:99", ) def test_invalid_tz_offsets_too_large(self): # The TZ offsets with an absolute value >= 1440 minutes are not supported by the tzinfo spec. # See https://docs.python.org/3/library/datetime.html#datetime.tzinfo.utcoffset invalid_offsets = [("-24", -1440), ("+24", 1440), ("-99", -5940), ("+99", 5940)] for offset_string, offset_minutes in invalid_offsets: # Error message differs whether or not we are using pytz or datetime.timezone # (and also by which Python version. Python 3.7 has different timedelta.repr()) # Of course we no longer use either, but for backwards compatibility # with v2.0.x, we did not change the error messages. if sys.version_info.major >= 3: expected_error_message = re.escape("offset must be a timedelta strictly between -timedelta(hours=24) and timedelta(hours=24), not {0}.".format(repr(datetime.timedelta(minutes=offset_minutes)))) else: expected_error_message = re.escape("'absolute offset is too large', {0}".format(offset_minutes)) self.assertRaisesRegex( ValueError, expected_error_message, parse_datetime, "2018-01-01T00:00:00.00{0}".format(offset_string), ) self.assertRaisesRegex( ValueError, r"tzminute must be in 0..59", parse_datetime, "2018-01-01T00:00:00.00-23:60", ) def test_mixed_basic_and_extended_formats(self): """ Both dates and times have "basic" and "extended" formats. But when you combine them into a datetime, the date and time components must have the same format. """ self.assertRaisesRegex( ValueError, r"Cannot combine \"extended\" date format with \"basic\" time format", parse_datetime, "2014-01-02T010203", ), self.assertRaisesRegex( ValueError, r"Cannot combine \"basic\" date format with \"extended\" time format", parse_datetime, "20140102T01:02:03", ) class Rfc3339TestCase(unittest.TestCase): def test_valid_rfc3339_timestamps(self): """ Validate that valid RFC 3339 datetimes are parseable by parse_rfc3339 and produce the same result as parse_datetime. """ for string in [ "2018-01-02T03:04:05Z", "2018-01-02t03:04:05z", "2018-01-02 03:04:05z", "2018-01-02T03:04:05+00:00", "2018-01-02T03:04:05-00:00", "2018-01-02T03:04:05.12345Z", "2018-01-02T03:04:05+01:23", "2018-01-02T03:04:05-12:34", "2018-01-02T03:04:05-12:34", ]: self.assertEqual( parse_datetime(string), parse_rfc3339(string) ) def test_invalid_rfc3339_timestamps(self): """ Validate that datetime strings that are valid ISO 8601 but invalid RFC 3339 trigger a ValueError when passed to RFC 3339, and that this ValueError explicitly mentions RFC 3339. """ for timestamp in [ "2018-01-02", # Missing mandatory time "2018-01-02T03", # Missing mandatory minute and second "2018-01-02T03Z", # Missing mandatory minute and second "2018-01-02T03:04", # Missing mandatory minute and second "2018-01-02T03:04Z", # Missing mandatory minute and second "2018-01-02T03:04:01+04", # Missing mandatory offset minute "2018-01-02T03:04:05", # Missing mandatory offset "2018-01-02T03:04:05.12345", # Missing mandatory offset "2018-01-02T24:00:00Z", # 24:00:00 is not valid in RFC 3339 "20180102T03:04:05-12:34", # Missing mandatory date separators "2018-01-02T030405-12:34", # Missing mandatory time separators "2018-01-02T03:04:05-1234", # Missing mandatory offset separator "2018-01-02T03:04:05,12345Z", # Invalid comma fractional second separator ]: with self.assertRaisesRegex(ValueError, r"RFC 3339", msg="Timestamp '{0}' was supposed to be invalid, but parsing it didn't raise ValueError.".format(timestamp)): parse_rfc3339(timestamp) class FixedOffsetTestCase(unittest.TestCase): def test_all_valid_offsets(self): [FixedOffset(i * 60) for i in range(-1439, 1440)] def test_offsets_outside_valid_range(self): invalid_offsets = [-1440, 1440, 10000, -10000] for invalid_offset in invalid_offsets: with self.assertRaises(ValueError, msg="Fixed offset of {0} minutes was supposed to be invalid, but it didn't raise ValueError.".format(invalid_offset)): FixedOffset(invalid_offset * 60) class PicklingTestCase(unittest.TestCase): # Found as a result of https://github.com/movermeyer/backports.datetime_fromisoformat/issues/12 def test_basic_pickle_and_copy(self): dt = parse_datetime('2018-11-01 20:42:09') dt2 = pickle.loads(pickle.dumps(dt)) self.assertEqual(dt, dt2) dt3 = copy.deepcopy(dt) self.assertEqual(dt, dt3) # FixedOffset dt = parse_datetime('2018-11-01 20:42:09+01:30') dt2 = pickle.loads(pickle.dumps(dt)) self.assertEqual(dt, dt2) dt3 = copy.deepcopy(dt) self.assertEqual(dt, dt3) class GithubIssueRegressionTestCase(unittest.TestCase): # These are test cases that were provided in GitHub issues submitted to ciso8601. # They are kept here as regression tests. # They might not have any additional value above-and-beyond what is already tested in the normal unit tests. def test_issue_5(self): self.assertRaisesRegex( ValueError, r"Invalid character while parsing minute \(':', Index: 14\)", parse_datetime, "2014-02-03T10::27", ) def test_issue_6(self): self.assertRaisesRegex( ValueError, r"Invalid character while parsing second \('.', Index: 17\)", parse_datetime, "2014-02-03 04:05:.123456", ) def test_issue_8(self): self.assertRaisesRegex( ValueError, r"hour must be in 0..23", parse_datetime, "2001-01-01T24:01:01", ) self.assertRaisesRegex( ValueError, r"month must be in 1..12", parse_datetime, "07722968", ) def test_issue_13(self): self.assertRaisesRegex( ValueError, r"month must be in 1..12", parse_datetime, "2014-13-01", ) def test_issue_22(self): if platform.python_implementation() == 'PyPy' and sys.version_info.major >= 3: self.assertRaisesRegex( ValueError, r"('day must be in 1..30', 31)", parse_datetime, "2016-11-31T12:34:34.521059", ) else: self.assertRaisesRegex( ValueError, r"day is out of range for month", parse_datetime, "2016-11-31T12:34:34.521059", ) def test_issue_35(self): self.assertRaisesRegex( ValueError, r"Invalid character while parsing date and time separator \(i.e., 'T', 't', or ' '\) \('2', Index: 8\)", parse_datetime, "2017-0012-27T13:35:19+0200", ) def test_issue_42(self): if platform.python_implementation() == 'PyPy' and sys.version_info.major >= 3: self.assertRaisesRegex( ValueError, r"('day must be in 1..28', 0)", parse_datetime, "20140200", ) else: self.assertRaisesRegex( ValueError, r"day is out of range for month", parse_datetime, "20140200", ) def test_issue_71(self): self.assertRaisesRegex( ValueError, r"Cannot combine \"basic\" date format with \"extended\" time format", parse_datetime, "20010203T04:05:06Z", ) self.assertRaisesRegex( ValueError, r"Cannot combine \"basic\" date format with \"extended\" time format", parse_datetime, "20010203T04:05", ) class HardCodedBenchmarkTimestampTestCase(unittest.TestCase): def test_returns_expected_hardcoded_datetime(self): self.assertEqual( _hard_coded_benchmark_timestamp(), datetime.datetime(2014, 1, 9, 21, 48, 0, 0), ) if __name__ == "__main__": unittest.main() ciso8601-2.3.1/timezone.c000066400000000000000000000173731451720545600150130ustar00rootroot00000000000000/* This code was originally copied from Pendulum (https://github.com/sdispater/pendulum/blob/13ff4a0250177f77e4ff2e7bd1f442d954e66b22/pendulum/parsing/_iso8601.c#L176) Pendulum (like ciso8601) is MIT licensed, so we have included a copy of its license here. */ /* Copyright (c) 2015 Sébastien Eustace Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ #include "timezone.h" #include #include #include #define SECS_PER_MIN 60 #define SECS_PER_HOUR (60 * SECS_PER_MIN) #define TWENTY_FOUR_HOURS_IN_SECONDS 86400 #define PY_VERSION_AT_LEAST_36 \ ((PY_MAJOR_VERSION == 3 && PY_MINOR_VERSION >= 6) || PY_MAJOR_VERSION > 3) /* * class FixedOffset(tzinfo): */ typedef struct { // Seconds offset from UTC. // Must be in range (-86400, 86400) seconds exclusive. // i.e., (-1440, 1440) minutes exclusive. PyObject_HEAD int offset; } FixedOffset; static int FixedOffset_init(FixedOffset *self, PyObject *args, PyObject *kwargs) { int offset; if (!PyArg_ParseTuple(args, "i", &offset)) return -1; if (abs(offset) >= TWENTY_FOUR_HOURS_IN_SECONDS) { PyErr_Format(PyExc_ValueError, "offset must be an integer in the range (-86400, 86400), " "exclusive"); return -1; } self->offset = offset; return 0; } static PyObject * FixedOffset_utcoffset(FixedOffset *self, PyObject *dt) { return PyDelta_FromDSU(0, self->offset, 0); } static PyObject * FixedOffset_dst(FixedOffset *self, PyObject *dt) { Py_RETURN_NONE; } static PyObject * FixedOffset_fromutc(FixedOffset *self, PyDateTime_DateTime *dt) { if (!PyDateTime_Check(dt)) { PyErr_SetString(PyExc_TypeError, "fromutc: argument must be a datetime"); return NULL; } if (!dt->hastzinfo || dt->tzinfo != (PyObject *)self) { PyErr_SetString(PyExc_ValueError, "fromutc: dt.tzinfo " "is not self"); return NULL; } return PyNumber_Add((PyObject *)dt, FixedOffset_utcoffset(self, (PyObject *)self)); } static PyObject * FixedOffset_tzname(FixedOffset *self, PyObject *dt) { int offset = self->offset; if (offset == 0) { #if PY_VERSION_AT_LEAST_36 return PyUnicode_FromString("UTC"); #elif PY_MAJOR_VERSION >= 3 return PyUnicode_FromString("UTC+00:00"); #else return PyString_FromString("UTC+00:00"); #endif } else { char result_tzname[10] = {0}; char sign = '+'; if (offset < 0) { sign = '-'; offset *= -1; } snprintf(result_tzname, 10, "UTC%c%02u:%02u", sign, (offset / SECS_PER_HOUR) & 31, offset / SECS_PER_MIN % SECS_PER_MIN); #if PY_MAJOR_VERSION >= 3 return PyUnicode_FromString(result_tzname); #else return PyString_FromString(result_tzname); #endif } } static PyObject * FixedOffset_repr(FixedOffset *self) { return FixedOffset_tzname(self, NULL); } static PyObject * FixedOffset_getinitargs(FixedOffset *self) { PyObject *args = PyTuple_Pack(1, PyLong_FromLong(self->offset)); return args; } /* * Class member / class attributes */ static PyMemberDef FixedOffset_members[] = { {"offset", T_INT, offsetof(FixedOffset, offset), 0, "UTC offset"}, {NULL}}; /* * Class methods */ static PyMethodDef FixedOffset_methods[] = { {"utcoffset", (PyCFunction)FixedOffset_utcoffset, METH_O, PyDoc_STR("Return fixed offset.")}, {"dst", (PyCFunction)FixedOffset_dst, METH_O, PyDoc_STR("Return None.")}, {"fromutc", (PyCFunction)FixedOffset_fromutc, METH_O, PyDoc_STR("datetime in UTC -> datetime in local time.")}, {"tzname", (PyCFunction)FixedOffset_tzname, METH_O, PyDoc_STR("Returns offset as 'UTC(+|-)HH:MM'")}, {"__getinitargs__", (PyCFunction)FixedOffset_getinitargs, METH_NOARGS, PyDoc_STR("pickle support")}, {NULL}}; static PyTypeObject FixedOffset_type = { PyVarObject_HEAD_INIT(NULL, 0) "ciso8601.FixedOffset", /* tp_name */ sizeof(FixedOffset), /* tp_basicsize */ 0, /* tp_itemsize */ 0, /* tp_dealloc */ 0, /* tp_print */ 0, /* tp_getattr */ 0, /* tp_setattr */ 0, /* tp_as_async */ (reprfunc)FixedOffset_repr, /* tp_repr */ 0, /* tp_as_number */ 0, /* tp_as_sequence */ 0, /* tp_as_mapping */ 0, /* tp_hash */ 0, /* tp_call */ (reprfunc)FixedOffset_repr, /* tp_str */ 0, /* tp_getattro */ 0, /* tp_setattro */ 0, /* tp_as_buffer */ Py_TPFLAGS_DEFAULT | Py_TPFLAGS_BASETYPE, /* tp_flags */ "TZInfo with fixed offset", /* tp_doc */ }; /* * Instantiate new FixedOffset_type object * Skip overhead of calling PyObject_New and PyObject_Init. * Directly allocate object. * Note that this also doesn't do any validation of the offset parameter. * Callers must ensure that offset is within \ * the range (-86400, 86400), exclusive. */ PyObject * new_fixed_offset_ex(int offset, PyTypeObject *type) { FixedOffset *self = (FixedOffset *)(type->tp_alloc(type, 0)); if (self != NULL) self->offset = offset; return (PyObject *)self; } PyObject * new_fixed_offset(int offset) { return new_fixed_offset_ex(offset, &FixedOffset_type); } /* ------------------------------------------------------------- */ int initialize_timezone_code(PyObject *module) { PyDateTime_IMPORT; FixedOffset_type.tp_new = PyType_GenericNew; FixedOffset_type.tp_base = PyDateTimeAPI->TZInfoType; FixedOffset_type.tp_methods = FixedOffset_methods; FixedOffset_type.tp_members = FixedOffset_members; FixedOffset_type.tp_init = (initproc)FixedOffset_init; if (PyType_Ready(&FixedOffset_type) < 0) return -1; Py_INCREF(&FixedOffset_type); if (PyModule_AddObject(module, "FixedOffset", (PyObject *)&FixedOffset_type) < 0) { Py_DECREF(module); Py_DECREF(&FixedOffset_type); return -1; } return 0; } ciso8601-2.3.1/timezone.h000066400000000000000000000002441451720545600150050ustar00rootroot00000000000000#ifndef CISO_TZINFO_H #define CISO_TZINFO_H #include PyObject * new_fixed_offset(int offset); int initialize_timezone_code(PyObject *module); #endif ciso8601-2.3.1/tox.ini000066400000000000000000000005051451720545600143150ustar00rootroot00000000000000[tox] requires = tox>=4 envlist = {py27,py34,py35,py36,py37,py38,py39,py310,py311,py312}-caching_{enabled,disabled} [testenv] package = sdist setenv = STRICT_WARNINGS = 1 caching_enabled: CISO8601_CACHING_ENABLED = 1 caching_disabled: CISO8601_CACHING_ENABLED = 0 deps = pytz nose commands=nosetests ciso8601-2.3.1/why_ciso8601.md000066400000000000000000000052711451720545600154740ustar00rootroot00000000000000# Should I use ciso8601? `ciso8601`'s goal is to be the world's fastest ISO 8601 datetime parser for Python. However, `ciso8601` is not the right choice for all use cases. This document aims to describe some considerations to make when choosing a timestamp parsing library. - [Do you care about the performance of timestamp parsing?](#do-you-care-about-the-performance-of-timestamp-parsing) - [Do you need strict RFC 3339 parsing?](#do-you-need-strict-rfc-3339-parsing) - [Do you need to support Python \< 3.11?](#do-you-need-to-support-python--311) - [Do you need to support Python 2.7?](#do-you-need-to-support-python-27) ### Flowchart ```mermaid graph TD; A[Do you care about the performance of timestamp parsing?] A--yes-->Y; A--no-->C; C[Do you need to support Python 2.7?]; C--yes-->Y C--no-->E E[Do you need strict RFC 3339 parsing?]; E--yes-->Y; E--no-->H; H[Do you need to support Python < 3.11?] H--yes-->V; H--no-->Z; V[Use `backports.datetime_fromisoformat`] Y[Use `ciso8601`] Z[Use `datetime.fromisoformat`] ``` ## Do you care about the performance of timestamp parsing? In most Python programs, performance is not a primary concern. Even for performance-sensitive programs, timestamp parsing performance is often a negligible portion of the time spent, and not a performance bottleneck. **Note:** Since Python 3.11+, the performance of cPython's `datetime.fromisoformat` is now very good. See [the benchmarks](https://github.com/closeio/ciso8601#benchmark). If you really, truly want to use the fastest parser, then `ciso8601` aims to be the fastest. See [the benchmarks](https://github.com/closeio/ciso8601#benchmark) to see how it compares to other options. ## Do you need strict RFC 3339 parsing? RFC 3339 can be (roughly) thought of as a subset of ISO 8601. If you need strict timestamp parsing that will complain if the given timestamp isn't strictly RFC 3339 compliant, then [`ciso8601` has a `parse_rfc3339` method](https://github.com/closeio/ciso8601#strict-rfc-3339-parsing). ## Do you need to support Python < 3.11? Since Python 3.11, `datetime.fromisoformat` supports parsing nearly any ISO 8601 timestamp, and the cPython implementation is [very performant](https://github.com/closeio/ciso8601#benchmark). If you need to support older versions of Python 3, consider [`backports.datetime_fromisoformat`](https://github.com/movermeyer/backports.datetime_fromisoformat). ## Do you need to support Python 2.7? `ciso8601` still supports Python 2.7, and is [much faster](https://github.com/closeio/ciso8601#benchmark) than other options for this [deprecated version of Python](https://pythonclock.org/).