├── .coveragerc ├── .github ├── CODE_OF_CONDUCT.md ├── CONTRIBUTING.md ├── ISSUE_TEMPLATE │ ├── bug_report.md │ ├── feature_request.md │ └── question.md └── workflows │ └── main.yml ├── .gitignore ├── .pre-commit-config.yaml ├── DEMO.ipynb ├── LICENSE ├── MANIFEST.in ├── README.rst ├── demo.yml ├── docs ├── Makefile ├── make.bat └── source │ ├── conf.py │ ├── index.rst │ └── user │ ├── install.rst │ └── quickstart.rst ├── setup.cfg ├── setup.py ├── src └── maya │ ├── __init__.py │ ├── compat.py │ └── core.py ├── tests ├── __init__.py ├── conftest.py ├── test_maya.py └── test_maya_interval.py └── tox.ini /.coveragerc: -------------------------------------------------------------------------------- 1 | [run] 2 | branch = True 3 | source = 4 | maya 5 | 6 | [paths] 7 | source = 8 | src 9 | .tox/*/site-packages 10 | 11 | [report] 12 | show_missing = True 13 | -------------------------------------------------------------------------------- /.github/CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | In the interest of fostering an open and welcoming environment, we as contributors and maintainers pledge to making participation in our project and our community a harassment-free experience for everyone, regardless of age, body size, disability, ethnicity, gender identity and expression, level of experience, nationality, personal appearance, race, religion, or sexual identity and orientation. 6 | 7 | ## Our Standards 8 | 9 | Examples of behavior that contributes to creating a positive environment include: 10 | 11 | * Using welcoming and inclusive language 12 | * Being respectful of differing viewpoints and experiences 13 | * Gracefully accepting constructive criticism 14 | * Focusing on what is best for the community 15 | * Showing empathy towards other community members 16 | 17 | Examples of unacceptable behavior by participants include: 18 | 19 | * The use of sexualized language or imagery and unwelcome sexual attention or advances 20 | * Trolling, insulting/derogatory comments, and personal or political attacks 21 | * Public or private harassment 22 | * Publishing others' private information, such as a physical or electronic address, without explicit permission 23 | * Other conduct which could reasonably be considered inappropriate in a professional setting 24 | 25 | ## Our Responsibilities 26 | 27 | Project maintainers are responsible for clarifying the standards of acceptable behavior and are expected to take appropriate and fair corrective action in response to any instances of unacceptable behavior. 28 | 29 | Project maintainers have the right and responsibility to remove, edit, or reject comments, commits, code, wiki edits, issues, and other contributions that are not aligned to this Code of Conduct, or to ban temporarily or permanently any contributor for other behaviors that they deem inappropriate, threatening, offensive, or harmful. 30 | 31 | ## Scope 32 | 33 | This Code of Conduct applies both within project spaces and in public spaces when an individual is representing the project or its community. Examples of representing a project or community include using an official project e-mail address, posting via an official social media account, or acting as an appointed representative at an online or offline event. Representation of a project may be further defined and clarified by project maintainers. 34 | 35 | ## Enforcement 36 | 37 | Instances of abusive, harassing, or otherwise unacceptable behavior may be reported by contacting the project team at tuxtimo@gmail.com. The project team will review and investigate all complaints, and will respond in a way that it deems appropriate to the circumstances. The project team is obligated to maintain confidentiality with regard to the reporter of an incident. Further details of specific enforcement policies may be posted separately. 38 | 39 | Project maintainers who do not follow or enforce the Code of Conduct in good faith may face temporary or permanent repercussions as determined by other members of the project's leadership. 40 | 41 | ## Attribution 42 | 43 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, available at [http://contributor-covenant.org/version/1/4][version] 44 | 45 | [homepage]: http://contributor-covenant.org 46 | [version]: http://contributor-covenant.org/version/1/4/ 47 | -------------------------------------------------------------------------------- /.github/CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing 2 | 3 | Thank you for helping maya to get a better piece of software. 4 | 5 | ## Support 6 | 7 | If you have any questions regarding the usage of maya please use the Question Issue Template or ask on [StackOverflow](https://stackoverflow.com). 8 | 9 | ## Reporting Issues / Proposing Features 10 | 11 | Before you submit an Issue or proposing a Feature check the existing Issues in order to avoid duplicates.
12 | Please make sure you provide enough information to work on your submitted Issue or proposed Feature: 13 | 14 | * Which version of maya are you using? 15 | * Which version of python are you using? 16 | * On which platform are you running maya? 17 | 18 | Make sure to use the GitHub Template when reporting an issue. 19 | 20 | ## Pull Requests 21 | 22 | We are very happy to receive Pull Requests considering: 23 | 24 | * Style Guide. Follow the rules of [PEP8](http://legacy.python.org/dev/peps/pep-0008/), but you may ignore *too-long-lines* and similar warnings. There is a *pylintrc* file for more information. 25 | * Tests. If our change affects python code inside the source code directory, please make sure your code is covered by an automated test case. 26 | 27 | ### Testing 28 | 29 | To test the maya source code against all supported python versions you should use *tox*: 30 | 31 | ```bash 32 | cd ~/work/maya 33 | pip install tox 34 | tox 35 | ``` 36 | 37 | However, if you want to test your code on certain circumstances you can create a *virtualenv*: 38 | 39 | ``` 40 | cd ~/work/maya 41 | virtualenv env 42 | source env/bin/activate 43 | pip install -e '.[dev]' 44 | commands = coverage run --parallel -m pytest -s --failed-first 45 | ``` 46 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug Report 3 | about: Create a bug report to help us improve maya 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Important notices** 11 | Before you add a new report, we ask you kindly to acknowledge the following: 12 | 13 | [-] I have read the contributing guide lines at https://github.com/timofurrer/maya/blob/master/.github/CONTRIBUTING.md 14 | 15 | [-] I have read and respect the code of conduct at https://github.com/timofurrer/maya/blob/master/.github/CODE_OF_CONDUCT.md 16 | 17 | [-] I have searched the existing issues and I'm convinced that mine is new. 18 | 19 | **Describe the bug** 20 | A clear and concise description of what the bug is. 21 | 22 | **Environment and Version** 23 | * OS (incl. terminal and shell used): ... 24 | * Python Version: ... 25 | * maya Version: ... 26 | * Your timezone: ... 27 | 28 | **To Reproduce** 29 | A clear and concise description of steps to reproduce the behavior 30 | you are experiencing. 31 | 32 | **Expected behavior** 33 | A clear and concise description of what you expected to happen. 34 | 35 | **Screenshots** 36 | If applicable, add screenshots to help explain your problem. 37 | 38 | **Relevant log files** 39 | If applicable, information from log files supporting your claim. 40 | 41 | **Additional context** 42 | Add any other context about the problem here. 43 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature Request 3 | about: Suggest an idea for a new feature or enhancement for maya 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Important notices** 11 | Before you add a new request, we ask you kindly to acknowledge the following: 12 | 13 | [-] I have read the contributing guide lines at https://github.com/timofurrer/maya/blob/master/.github/CONTRIBUTING.md 14 | 15 | [-] I have read and respect the code of conduct at https://github.com/timofurrer/maya/blob/master/.github/CODE_OF_CONDUCT.md 16 | 17 | [-] I have searched the existing issues and I'm convinced that mine is new. 18 | 19 | **Is your Feature Request related to a problem? Please describe.** 20 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] 21 | 22 | **Describe the solution you'd like** 23 | A clear and concise description of what you want to happen. 24 | 25 | **Describe alternatives you've considered** 26 | A clear and concise description of any alternative solutions or features you've considered. 27 | 28 | **Additional context** 29 | Add any other context or screenshots about the Feature Request here. 30 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/question.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Ask a Question 3 | about: Ask a Question about maya (Usage, Development, ...) 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Important notices** 11 | Before you add a new report, we ask you kindly to acknowledge the following: 12 | 13 | [-] I have read the contributing guide lines at https://github.com/timofurrer/maya/blob/master/.github/CONTRIBUTING.md 14 | 15 | [-] I have read and respect the code of conduct at https://github.com/timofurrer/maya/blob/master/.github/CODE_OF_CONDUCT.md 16 | 17 | [-] I have searched the existing issues and I'm convinced that mine is new. 18 | 19 | **Ask your Question** 20 | Ask your question here! If the question is related to a particular environment or behavior please make sure to add some context. 21 | -------------------------------------------------------------------------------- /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | name: Continuous Integration and Deployment 2 | 3 | on: [push, pull_request] 4 | 5 | jobs: 6 | build: 7 | 8 | runs-on: ${{ matrix.os }} 9 | strategy: 10 | fail-fast: false 11 | matrix: 12 | python-version: ["3.6", "3.7", "3.8", "3.9", "3.10"] 13 | os: [ubuntu-latest, macOS-latest, windows-latest] 14 | 15 | steps: 16 | - uses: actions/checkout@v3 17 | - name: Set up Python ${{ matrix.python-version }} 18 | uses: actions/setup-python@v4.2.0 19 | with: 20 | python-version: ${{ matrix.python-version }} 21 | - name: Setup build and test environment 22 | run: | 23 | python -m pip install --upgrade pip setuptools wheel 24 | - name: Build Python Package 25 | run: | 26 | python -m pip install ".[tests]" 27 | - name: Lint with flake8 28 | run: | 29 | pip install flake8 30 | flake8 --show-source src/ tests/ 31 | - name: Check Manifest 32 | run: | 33 | pip install check-manifest 34 | check-manifest 35 | - name: Test with pytest 36 | run: | 37 | coverage run --parallel -m pytest 38 | - name: Report code coverage 39 | run: | 40 | coverage combine 41 | coverage report 42 | coverage xml 43 | 44 | docs: 45 | runs-on: ubuntu-latest 46 | 47 | steps: 48 | - uses: actions/checkout@v3 49 | - name: Set up Python 3.7 50 | uses: actions/setup-python@v4.2.0 51 | with: 52 | python-version: 3.7 53 | - name: Setup docs environment 54 | run: | 55 | python -m pip install ".[docs]" 56 | - name: Build documentation with sphinx 57 | run: | 58 | sphinx-build -W -b html -d doctrees docs/source docs/_build/html 59 | 60 | publish: 61 | needs: [build, docs] 62 | runs-on: ubuntu-latest 63 | 64 | if: startsWith(github.event.ref, 'refs/tags') && github.ref == 'refs/heads/master' 65 | steps: 66 | - uses: actions/checkout@v3 67 | - name: Set up Python 3.7 68 | uses: actions/setup-python@v4.2.0 69 | with: 70 | python-version: 3.7 71 | - name: Build Package 72 | run: | 73 | python -m pip install --upgrade pip setuptools wheel 74 | python setup.py sdist bdist_wheel --universal 75 | - name: Publish Package on PyPI 76 | uses: pypa/gh-action-pypi-publish@master 77 | with: 78 | user: __token__ 79 | password: ${{ secrets.pypi_token }} 80 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | env/ 12 | build/ 13 | develop-eggs/ 14 | dist/ 15 | downloads/ 16 | eggs/ 17 | .eggs/ 18 | lib/ 19 | lib64/ 20 | parts/ 21 | sdist/ 22 | var/ 23 | wheels/ 24 | *.egg-info/ 25 | .installed.cfg 26 | *.egg 27 | Pipfile.lock 28 | 29 | # PyInstaller 30 | # Usually these files are written by a python script from a template 31 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 32 | *.manifest 33 | *.spec 34 | 35 | # Installer logs 36 | pip-log.txt 37 | pip-delete-this-directory.txt 38 | 39 | # Unit test / coverage reports 40 | htmlcov/ 41 | .tox/ 42 | .coverage 43 | .coverage.* 44 | .cache 45 | nosetests.xml 46 | coverage.xml 47 | *,cover 48 | .hypothesis/ 49 | 50 | # Translations 51 | *.mo 52 | *.pot 53 | 54 | # Django stuff: 55 | *.log 56 | local_settings.py 57 | 58 | # Flask stuff: 59 | instance/ 60 | .webassets-cache 61 | 62 | # Scrapy stuff: 63 | .scrapy 64 | 65 | # Sphinx documentation 66 | docs/_build/ 67 | 68 | # PyBuilder 69 | target/ 70 | 71 | # Jupyter Notebook 72 | .ipynb_checkpoints 73 | 74 | # pyenv 75 | .python-version 76 | 77 | # celery beat schedule file 78 | celerybeat-schedule 79 | 80 | # dotenv 81 | .env 82 | 83 | # virtualenv 84 | .venv/ 85 | venv/ 86 | ENV/ 87 | 88 | # Spyder project settings 89 | .spyderproject 90 | 91 | # Rope project settings 92 | .ropeproject 93 | 94 | # PyCharm 95 | .idea/ 96 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | repos: 2 | - repo: https://github.com/ambv/black 3 | rev: 19.3b0 4 | hooks: 5 | - id: black 6 | language_version: python3.7 7 | # override until resolved: https://github.com/ambv/black/issues/402 8 | files: \.pyi?$ 9 | types: [] 10 | 11 | - repo: https://gitlab.com/pycqa/flake8 12 | rev: 3.7.7 13 | hooks: 14 | - id: flake8 15 | language_version: python3.7 16 | 17 | #- repo: https://github.com/asottile/seed-isort-config 18 | #rev: v1.9.1 19 | #hooks: 20 | #- id: seed-isort-config 21 | 22 | #- repo: https://github.com/pre-commit/mirrors-isort 23 | #rev: v4.3.20 24 | #hooks: 25 | #- id: isort 26 | #language_version: python3.7 27 | 28 | - repo: https://github.com/pre-commit/pre-commit-hooks 29 | rev: v2.2.3 30 | hooks: 31 | - id: trailing-whitespace 32 | - id: end-of-file-fixer 33 | -------------------------------------------------------------------------------- /DEMO.ipynb: -------------------------------------------------------------------------------- 1 | { 2 | "cells": [ 3 | { 4 | "cell_type": "markdown", 5 | "metadata": {}, 6 | "source": [ 7 | "# Maya: Datetimes for Humans™" 8 | ] 9 | }, 10 | { 11 | "cell_type": "markdown", 12 | "metadata": {}, 13 | "source": [ 14 | "Datetimes are very frustrating to work with in Python, especially when dealing with different locales on different systems. This library exists to make the simple things much easier, while admitting that time is an illusion (timezones doubly so).\n", 15 | "\n", 16 | "Datetimes should be interacted with via an API written for humans.\n", 17 | "\n", 18 | "Maya is mostly built around the headaches and use-cases around parsing datetime data from websites." 19 | ] 20 | }, 21 | { 22 | "cell_type": "markdown", 23 | "metadata": {}, 24 | "source": [ 25 | "## Basic Usage of Maya" 26 | ] 27 | }, 28 | { 29 | "cell_type": "code", 30 | "execution_count": 33, 31 | "metadata": {}, 32 | "outputs": [], 33 | "source": [ 34 | "import maya" 35 | ] 36 | }, 37 | { 38 | "cell_type": "code", 39 | "execution_count": 34, 40 | "metadata": {}, 41 | "outputs": [ 42 | { 43 | "data": { 44 | "text/plain": [ 45 | "" 46 | ] 47 | }, 48 | "execution_count": 34, 49 | "metadata": {}, 50 | "output_type": "execute_result" 51 | } 52 | ], 53 | "source": [ 54 | "maya.now()" 55 | ] 56 | }, 57 | { 58 | "cell_type": "code", 59 | "execution_count": 35, 60 | "metadata": {}, 61 | "outputs": [ 62 | { 63 | "data": { 64 | "text/plain": [ 65 | "" 66 | ] 67 | }, 68 | "execution_count": 35, 69 | "metadata": {}, 70 | "output_type": "execute_result" 71 | } 72 | ], 73 | "source": [ 74 | "tomorrow = maya.when('tomorrow')\n", 75 | "tomorrow" 76 | ] 77 | }, 78 | { 79 | "cell_type": "code", 80 | "execution_count": 36, 81 | "metadata": {}, 82 | "outputs": [ 83 | { 84 | "data": { 85 | "text/plain": [ 86 | "'tomorrow'" 87 | ] 88 | }, 89 | "execution_count": 36, 90 | "metadata": {}, 91 | "output_type": "execute_result" 92 | } 93 | ], 94 | "source": [ 95 | "tomorrow.slang_date()" 96 | ] 97 | }, 98 | { 99 | "cell_type": "code", 100 | "execution_count": 37, 101 | "metadata": {}, 102 | "outputs": [ 103 | { 104 | "data": { 105 | "text/plain": [ 106 | "'in 23 hours'" 107 | ] 108 | }, 109 | "execution_count": 37, 110 | "metadata": {}, 111 | "output_type": "execute_result" 112 | } 113 | ], 114 | "source": [ 115 | "tomorrow.slang_time()" 116 | ] 117 | }, 118 | { 119 | "cell_type": "code", 120 | "execution_count": 38, 121 | "metadata": {}, 122 | "outputs": [ 123 | { 124 | "data": { 125 | "text/plain": [ 126 | "'2018-12-15T17:45:14.872356Z'" 127 | ] 128 | }, 129 | "execution_count": 38, 130 | "metadata": {}, 131 | "output_type": "execute_result" 132 | } 133 | ], 134 | "source": [ 135 | "# Also: MayaDT.from_iso8601(...)\n", 136 | "tomorrow.iso8601()" 137 | ] 138 | }, 139 | { 140 | "cell_type": "code", 141 | "execution_count": 39, 142 | "metadata": {}, 143 | "outputs": [ 144 | { 145 | "data": { 146 | "text/plain": [ 147 | "'Sat, 15 Dec 2018 17:45:14 GMT'" 148 | ] 149 | }, 150 | "execution_count": 39, 151 | "metadata": {}, 152 | "output_type": "execute_result" 153 | } 154 | ], 155 | "source": [ 156 | "# Also: MayaDT.from_rfc2822(...)\n", 157 | "tomorrow.rfc2822()" 158 | ] 159 | }, 160 | { 161 | "cell_type": "code", 162 | "execution_count": 40, 163 | "metadata": {}, 164 | "outputs": [ 165 | { 166 | "data": { 167 | "text/plain": [ 168 | "'2018-12-15T17:45:14.8Z'" 169 | ] 170 | }, 171 | "execution_count": 40, 172 | "metadata": {}, 173 | "output_type": "execute_result" 174 | } 175 | ], 176 | "source": [ 177 | "# Also: MayaDT.from_rfc3339(...)\n", 178 | "tomorrow.rfc3339()" 179 | ] 180 | }, 181 | { 182 | "cell_type": "code", 183 | "execution_count": 41, 184 | "metadata": {}, 185 | "outputs": [ 186 | { 187 | "data": { 188 | "text/plain": [ 189 | "datetime.datetime(2018, 12, 15, 17, 45, 14, 872356, tzinfo=)" 190 | ] 191 | }, 192 | "execution_count": 41, 193 | "metadata": {}, 194 | "output_type": "execute_result" 195 | } 196 | ], 197 | "source": [ 198 | "tomorrow.datetime()" 199 | ] 200 | }, 201 | { 202 | "cell_type": "code", 203 | "execution_count": 42, 204 | "metadata": {}, 205 | "outputs": [ 206 | { 207 | "data": { 208 | "text/plain": [ 209 | "datetime.datetime(2016, 12, 16, 13, 23, 45, 423992)" 210 | ] 211 | }, 212 | "execution_count": 42, 213 | "metadata": {}, 214 | "output_type": "execute_result" 215 | } 216 | ], 217 | "source": [ 218 | "# Automatically parse datetime strings and generate naive datetimes.\n", 219 | "scraped = '2016-12-16 18:23:45.423992+00:00'\n", 220 | "maya.parse(scraped).datetime(to_timezone='US/Eastern', naive=True)" 221 | ] 222 | }, 223 | { 224 | "cell_type": "code", 225 | "execution_count": 43, 226 | "metadata": {}, 227 | "outputs": [], 228 | "source": [ 229 | "rand_day = maya.when('2011-02-07', timezone='US/Eastern')" 230 | ] 231 | }, 232 | { 233 | "cell_type": "code", 234 | "execution_count": 44, 235 | "metadata": {}, 236 | "outputs": [ 237 | { 238 | "data": { 239 | "text/plain": [ 240 | "" 241 | ] 242 | }, 243 | "execution_count": 44, 244 | "metadata": {}, 245 | "output_type": "execute_result" 246 | } 247 | ], 248 | "source": [ 249 | "# Maya speaks Python.\n", 250 | "from datetime import datetime\n", 251 | "\n", 252 | "maya.MayaDT.from_datetime(datetime.utcnow())" 253 | ] 254 | }, 255 | { 256 | "cell_type": "code", 257 | "execution_count": 45, 258 | "metadata": {}, 259 | "outputs": [ 260 | { 261 | "data": { 262 | "text/plain": [ 263 | "" 264 | ] 265 | }, 266 | "execution_count": 45, 267 | "metadata": {}, 268 | "output_type": "execute_result" 269 | } 270 | ], 271 | "source": [ 272 | "import time\n", 273 | "\n", 274 | "maya.MayaDT.from_struct(time.gmtime())" 275 | ] 276 | }, 277 | { 278 | "cell_type": "code", 279 | "execution_count": 46, 280 | "metadata": {}, 281 | "outputs": [ 282 | { 283 | "data": { 284 | "text/plain": [ 285 | "" 286 | ] 287 | }, 288 | "execution_count": 46, 289 | "metadata": {}, 290 | "output_type": "execute_result" 291 | } 292 | ], 293 | "source": [ 294 | "maya.MayaDT(time.time())" 295 | ] 296 | }, 297 | { 298 | "cell_type": "code", 299 | "execution_count": 47, 300 | "metadata": {}, 301 | "outputs": [ 302 | { 303 | "data": { 304 | "text/plain": [ 305 | "7" 306 | ] 307 | }, 308 | "execution_count": 47, 309 | "metadata": {}, 310 | "output_type": "execute_result" 311 | } 312 | ], 313 | "source": [ 314 | "rand_day.day" 315 | ] 316 | }, 317 | { 318 | "cell_type": "code", 319 | "execution_count": 48, 320 | "metadata": {}, 321 | "outputs": [ 322 | { 323 | "data": { 324 | "text/plain": [ 325 | "17" 326 | ] 327 | }, 328 | "execution_count": 48, 329 | "metadata": {}, 330 | "output_type": "execute_result" 331 | } 332 | ], 333 | "source": [ 334 | "rand_day.add(days=10).day" 335 | ] 336 | }, 337 | { 338 | "cell_type": "code", 339 | "execution_count": 49, 340 | "metadata": {}, 341 | "outputs": [ 342 | { 343 | "data": { 344 | "text/plain": [ 345 | "'UTC'" 346 | ] 347 | }, 348 | "execution_count": 49, 349 | "metadata": {}, 350 | "output_type": "execute_result" 351 | } 352 | ], 353 | "source": [ 354 | "# Always.\n", 355 | "rand_day.timezone" 356 | ] 357 | }, 358 | { 359 | "cell_type": "code", 360 | "execution_count": 50, 361 | "metadata": {}, 362 | "outputs": [ 363 | { 364 | "data": { 365 | "text/plain": [ 366 | "" 367 | ] 368 | }, 369 | "execution_count": 50, 370 | "metadata": {}, 371 | "output_type": "execute_result" 372 | } 373 | ], 374 | "source": [ 375 | "# Range of hours in a day:\n", 376 | "maya.intervals(start=maya.now(), end=maya.now().add(days=1), interval=60*60)" 377 | ] 378 | }, 379 | { 380 | "cell_type": "code", 381 | "execution_count": 51, 382 | "metadata": {}, 383 | "outputs": [ 384 | { 385 | "data": { 386 | "text/plain": [ 387 | "'Mon, 21 Feb 1994 03:00:00 GMT'" 388 | ] 389 | }, 390 | "execution_count": 51, 391 | "metadata": {}, 392 | "output_type": "execute_result" 393 | } 394 | ], 395 | "source": [ 396 | "# snap modifiers\n", 397 | "dt = maya.when('Mon, 21 Feb 1994 21:21:42 GMT')\n", 398 | "dt.snap('@d+3h').rfc2822()" 399 | ] 400 | }, 401 | { 402 | "cell_type": "markdown", 403 | "metadata": {}, 404 | "source": [ 405 | "## Advanced Usage of Maya" 406 | ] 407 | }, 408 | { 409 | "cell_type": "markdown", 410 | "metadata": {}, 411 | "source": [ 412 | "In addition to timestamps, Maya also includes a wonderfully powerful MayaInterval class, which represents a range of time (e.g. an event). With this class, you can perform a multitude of advanced calendar calculations with finesse and ease.\n", 413 | "\n", 414 | "For example:" 415 | ] 416 | }, 417 | { 418 | "cell_type": "code", 419 | "execution_count": 52, 420 | "metadata": {}, 421 | "outputs": [], 422 | "source": [ 423 | "from maya import MayaInterval" 424 | ] 425 | }, 426 | { 427 | "cell_type": "code", 428 | "execution_count": 53, 429 | "metadata": {}, 430 | "outputs": [], 431 | "source": [ 432 | "# Create an event that is one hour long, starting now.\n", 433 | "event_start = maya.now()\n", 434 | "event_end = event_start.add(hours=1)" 435 | ] 436 | }, 437 | { 438 | "cell_type": "code", 439 | "execution_count": 54, 440 | "metadata": {}, 441 | "outputs": [ 442 | { 443 | "data": { 444 | "text/plain": [ 445 | " end=>" 446 | ] 447 | }, 448 | "execution_count": 54, 449 | "metadata": {}, 450 | "output_type": "execute_result" 451 | } 452 | ], 453 | "source": [ 454 | "MayaInterval(start=event_start, end=event_end)" 455 | ] 456 | }, 457 | { 458 | "cell_type": "markdown", 459 | "metadata": {}, 460 | "source": [ 461 | "From here, there are a number of methods available to you, which you can use to compare this event to another event." 462 | ] 463 | }, 464 | { 465 | "cell_type": "markdown", 466 | "metadata": {}, 467 | "source": [ 468 | "## Do your own experiments here...\n", 469 | "\n", 470 | "Try `maya` youself by adding your code below and running your own experiments 👇" 471 | ] 472 | }, 473 | { 474 | "cell_type": "code", 475 | "execution_count": null, 476 | "metadata": {}, 477 | "outputs": [], 478 | "source": [ 479 | "import maya\n", 480 | "\n", 481 | "# your code here\n", 482 | "maya." 483 | ] 484 | } 485 | ], 486 | "metadata": { 487 | "kernelspec": { 488 | "display_name": "Python 3", 489 | "language": "python", 490 | "name": "python3" 491 | }, 492 | "language_info": { 493 | "codemirror_mode": { 494 | "name": "ipython", 495 | "version": 3 496 | }, 497 | "file_extension": ".py", 498 | "mimetype": "text/x-python", 499 | "name": "python", 500 | "nbconvert_exporter": "python", 501 | "pygments_lexer": "ipython3", 502 | "version": "3.6.5" 503 | } 504 | }, 505 | "nbformat": 4, 506 | "nbformat_minor": 2 507 | } 508 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2016 Kenneth Reitz 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | # Additional package data 2 | 3 | # Metadata 4 | include LICENSE *.md *.rst *.toml *.yml *.yaml 5 | graft .github 6 | 7 | # Jupyter Notebooks 8 | include *.ipynb 9 | 10 | # Stubs 11 | recursive-include src *.pyi 12 | 13 | # Tests 14 | include tox.ini .coveragerc conftest.py 15 | recursive-include tests *.py 16 | 17 | # Documentation 18 | include docs/Makefile docs/docutils.conf 19 | recursive-include docs *.bat 20 | recursive-include docs *.png 21 | recursive-include docs *.svg 22 | recursive-include docs *.py 23 | recursive-include docs *.rst 24 | recursive-include docs *.ico 25 | prune docs/_build 26 | 27 | # Just to keep check-manifest happy; on releases those files are gone. 28 | # Last rule wins! 29 | exclude changelog.d/*.rst 30 | include changelog.d/towncrier_template.rst 31 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | Maya: Datetimes for Humans™ 2 | =========================== 3 | 4 | .. image:: https://img.shields.io/pypi/v/maya.svg 5 | :target: https://pypi.python.org/pypi/maya 6 | 7 | .. image:: https://github.com/timofurrer/maya/workflows/Continuous%20Integration%20and%20Deployment/badge.svg 8 | :target: https://github.com/timofurrer/maya/actions 9 | 10 | 11 | Datetimes are very frustrating to work with in Python, especially when dealing 12 | with different locales on different systems. This library exists to make the 13 | simple things **much** easier, while admitting that time is an illusion 14 | (timezones doubly so). 15 | 16 | Datetimes should be interacted with via an API written for humans. 17 | 18 | Maya is mostly built around the headaches and use-cases around parsing datetime data from websites. 19 | 20 | 21 | ☤ Basic Usage of Maya 22 | --------------------- 23 | 24 | Behold, datetimes for humans! 25 | 26 | .. code-block:: pycon 27 | 28 | >>> now = maya.now() 29 | 30 | 31 | >>> tomorrow = maya.when('tomorrow') 32 | 33 | 34 | >>> tomorrow.slang_date() 35 | 'tomorrow' 36 | 37 | >>> tomorrow.slang_time() 38 | '23 hours from now' 39 | 40 | # Also: MayaDT.from_iso8601(...) 41 | >>> tomorrow.iso8601() 42 | '2017-02-10T22:17:01.445418Z' 43 | 44 | # Also: MayaDT.from_rfc2822(...) 45 | >>> tomorrow.rfc2822() 46 | 'Fri, 10 Feb 2017 22:17:01 GMT' 47 | 48 | # Also: MayaDT.from_rfc3339(...) 49 | >>> tomorrow.rfc3339() 50 | '2017-02-10T22:17:01.44Z' 51 | 52 | >>> tomorrow.datetime() 53 | datetime.datetime(2016, 12, 16, 15, 11, 30, 263350, tzinfo=) 54 | 55 | # Automatically parse datetime strings and generate naive datetimes. 56 | >>> scraped = '2016-12-16 18:23:45.423992+00:00' 57 | >>> maya.parse(scraped).datetime(to_timezone='US/Eastern', naive=True) 58 | datetime.datetime(2016, 12, 16, 13, 23, 45, 423992) 59 | 60 | >>> rand_day = maya.when('2011-02-07', timezone='US/Eastern') 61 | 62 | 63 | # Maya speaks Python. 64 | >>> m = maya.MayaDT.from_datetime(datetime.utcnow()) 65 | >>> print(m) 66 | Wed, 20 Sep 2017 17:24:32 GMT 67 | 68 | >>> m = maya.MayaDT.from_struct(time.gmtime()) 69 | >>> print(m) 70 | Wed, 20 Sep 2017 17:24:32 GMT 71 | 72 | >>> m = maya.MayaDT(time.time()) 73 | >>> print(m) 74 | Wed, 20 Sep 2017 17:24:32 GMT 75 | 76 | >>> rand_day.day 77 | 7 78 | 79 | >>> rand_day.add(days=10).day 80 | 17 81 | 82 | # Always. 83 | >>> rand_day.timezone 84 | UTC 85 | 86 | # Range of hours in a day: 87 | >>> maya.intervals(start=maya.now(), end=maya.now().add(days=1), interval=60*60) 88 | 89 | 90 | # snap modifiers 91 | >>> dt = maya.when('Mon, 21 Feb 1994 21:21:42 GMT') 92 | >>> dt.snap('@d+3h').rfc2822() 93 | 'Mon, 21 Feb 1994 03:00:00 GMT' 94 | 95 | # snap modifiers within a timezone 96 | >>> dt = maya.when('Mon, 21 Feb 1994 21:21:42 GMT') 97 | >>> dt.snap_tz('+3h@d', 'Australia/Perth').rfc2822() 98 | 'Mon, 21 Feb 1994 16:00:00 GMT' 99 | 100 | ☤ Advanced Usage of Maya 101 | ------------------------ 102 | 103 | In addition to timestamps, Maya also includes a wonderfully powerful ``MayaInterval`` class, which represents a range of time (e.g. an event). With this class, you can perform a multitude of advanced calendar calculations with finesse and ease. 104 | 105 | For example: 106 | 107 | .. code-block:: pycon 108 | 109 | >>> from maya import MayaInterval 110 | 111 | # Create an event that is one hour long, starting now. 112 | >>> event_start = maya.now() 113 | >>> event_end = event_start.add(hours=1) 114 | 115 | >>> event = MayaInterval(start=event_start, end=event_end) 116 | 117 | From here, there are a number of methods available to you, which you can use to compare this event to another event. 118 | 119 | 120 | 121 | ☤ Why is this useful? 122 | --------------------- 123 | 124 | - All timezone algebra will behave identically on all machines, regardless of system locale. 125 | - Complete symmetric import and export of both ISO 8601 and RFC 2822 datetime stamps. 126 | - Fantastic parsing of both dates written for/by humans and machines (``maya.when()`` vs ``maya.parse()``). 127 | - Support for human slang, both import and export (e.g. `an hour ago`). 128 | - Datetimes can very easily be generated, with or without tzinfo attached. 129 | - This library is based around epoch time, but dates before Jan 1 1970 are indeed supported, via negative integers. 130 | - Maya never panics, and always carries a towel. 131 | 132 | 133 | ☤ What about Delorean_, Arrow_, & Pendulum_? 134 | -------------------------------------------- 135 | 136 | All these projects complement each other, and are friends. Pendulum, for example, helps power Maya's parsing. 137 | 138 | Arrow, for example, is a fantastic library, but isn't what I wanted in a datetime library. In many ways, it's better than Maya for certain things. In some ways, in my opinion, it's not. 139 | 140 | I simply desire a sane API for datetimes that made sense to me for all the things I'd ever want to do—especially when dealing with timezone algebra. Arrow doesn't do all of the things I need (but it does a lot more!). Maya does do exactly what I need. 141 | 142 | I think these projects complement each-other, personally. Maya is great for parsing websites, and dealing with calendar events! 143 | 144 | .. _Delorean: https://delorean.readthedocs.io/ 145 | .. _Arrow: https://arrow.readthedocs.io/ 146 | .. _Pendulum: https://pendulum.eustace.io/ 147 | 148 | 149 | ☤ Installing Maya 150 | ----------------- 151 | 152 | Installation is easy, with: 153 | 154 | $ pip install maya 155 | 156 | 157 | How to Contribute 158 | ----------------- 159 | 160 | #. Check for open issues or open a fresh issue to start a discussion around a feature idea or a bug. 161 | #. Fork `the repository`_ on GitHub to start making your changes to the **master** branch (or branch off of it). 162 | #. Write a test which shows that the bug was fixed or that the feature works as expected. 163 | #. Send a pull request and bug the maintainer until it gets merged and published. :) 164 | 165 | .. _`the repository`: http://github.com/timofurrer/maya 166 | -------------------------------------------------------------------------------- /demo.yml: -------------------------------------------------------------------------------- 1 | requirements: 2 | - maya==0.6.0 3 | -------------------------------------------------------------------------------- /docs/Makefile: -------------------------------------------------------------------------------- 1 | # Minimal makefile for Sphinx documentation 2 | # 3 | 4 | # You can set these variables from the command line. 5 | SPHINXOPTS = 6 | SPHINXBUILD = python -msphinx 7 | SPHINXPROJ = maya 8 | SOURCEDIR = source 9 | BUILDDIR = _build 10 | 11 | # Put it first so that "make" without argument is like "make help". 12 | help: 13 | @$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) 14 | 15 | .PHONY: help Makefile 16 | 17 | # Catch-all target: route all unknown targets to Sphinx using the new 18 | # "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS). 19 | %: Makefile 20 | @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) 21 | -------------------------------------------------------------------------------- /docs/make.bat: -------------------------------------------------------------------------------- 1 | @ECHO OFF 2 | 3 | pushd %~dp0 4 | 5 | REM Command file for Sphinx documentation 6 | 7 | if "%SPHINXBUILD%" == "" ( 8 | set SPHINXBUILD=python -msphinx 9 | ) 10 | set SOURCEDIR=source 11 | set BUILDDIR=build 12 | set SPHINXPROJ=maya 13 | 14 | if "%1" == "" goto help 15 | 16 | %SPHINXBUILD% >NUL 2>NUL 17 | if errorlevel 9009 ( 18 | echo. 19 | echo.The Sphinx module was not found. Make sure you have Sphinx installed, 20 | echo.then set the SPHINXBUILD environment variable to point to the full 21 | echo.path of the 'sphinx-build' executable. Alternatively you may add the 22 | echo.Sphinx directory to PATH. 23 | echo. 24 | echo.If you don't have Sphinx installed, grab it from 25 | echo.http://sphinx-doc.org/ 26 | exit /b 1 27 | ) 28 | 29 | %SPHINXBUILD% -M %1 %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% 30 | goto end 31 | 32 | :help 33 | %SPHINXBUILD% -M help %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% 34 | 35 | :end 36 | popd 37 | -------------------------------------------------------------------------------- /docs/source/conf.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # -*- coding: utf-8 -*- 3 | # 4 | # maya documentation build configuration file, created by 5 | # sphinx-quickstart on Sun May 28 15:46:10 2017. 6 | # 7 | # This file is execfile()d with the current directory set to its 8 | # containing dir. 9 | # 10 | # Note that not all possible configuration values are present in this 11 | # autogenerated file. 12 | # 13 | # All configuration values have a default; values that are commented out 14 | # serve to show the default. 15 | 16 | # If extensions (or modules to document with autodoc) are in another directory, 17 | # add these directories to sys.path here. If the directory is relative to the 18 | # documentation root, use os.path.abspath to make it absolute, like shown here. 19 | # 20 | import os 21 | import sys 22 | 23 | # sys.path.insert(0, os.path.abspath('.')) 24 | sys.path.insert(0, os.path.abspath("..")) 25 | sys.path.insert(0, os.path.abspath("_themes")) 26 | 27 | import maya # noqa 28 | 29 | # -- General configuration ------------------------------------------------ 30 | 31 | # If your documentation needs a minimal Sphinx version, state it here. 32 | # 33 | # needs_sphinx = '1.0' 34 | 35 | # Add any Sphinx extension module names here, as strings. They can be 36 | # extensions coming with Sphinx (named 'sphinx.ext.*') or your custom 37 | # ones. 38 | extensions = [ 39 | "sphinx.ext.autodoc", 40 | "sphinx.ext.todo", 41 | "sphinx.ext.coverage", 42 | "sphinx.ext.viewcode", 43 | ] 44 | 45 | # Add any paths that contain templates here, relative to this directory. 46 | templates_path = ["_templates"] 47 | 48 | # The suffix(es) of source filenames. 49 | # You can specify multiple suffix as a list of string: 50 | # 51 | # source_suffix = ['.rst', '.md'] 52 | source_suffix = ".rst" 53 | 54 | # The master toctree document. 55 | master_doc = "index" 56 | 57 | # General information about the project. 58 | project = "maya" 59 | copyright = "2017, Kenneth Reitz" 60 | author = "Kenneth Reitz" 61 | 62 | # The version info for the project you're documenting, acts as replacement for 63 | # |version| and |release|, also used in various other places throughout the 64 | # built documents. 65 | # 66 | # The short X.Y version. 67 | version = maya.__version__ 68 | # The full version, including alpha/beta/rc tags. 69 | release = maya.__version__ 70 | 71 | # The language for content autogenerated by Sphinx. Refer to documentation 72 | # for a list of supported languages. 73 | # 74 | # This is also used if you do content translation via gettext catalogs. 75 | # Usually you set "language" from the command line for these cases. 76 | language = "en" 77 | 78 | # List of patterns, relative to source directory, that match files and 79 | # directories to ignore when looking for source files. 80 | # This patterns also effect to html_static_path and html_extra_path 81 | exclude_patterns = [] 82 | 83 | # The name of the Pygments (syntax highlighting) style to use. 84 | pygments_style = "sphinx" 85 | 86 | # If true, `todo` and `todoList` produce output, else they produce nothing. 87 | todo_include_todos = True 88 | 89 | 90 | # -- Options for HTML output ---------------------------------------------- 91 | 92 | # The theme to use for HTML and HTML Help pages. See the documentation for 93 | # a list of builtin themes. 94 | # 95 | html_theme = "alabaster" 96 | 97 | # Theme options are theme-specific and customize the look and feel of a theme 98 | # further. For a list of options available for each theme, see the 99 | # documentation. 100 | # 101 | # html_theme_options = {} 102 | 103 | # Add any paths that contain custom static files (such as style sheets) here, 104 | # relative to this directory. They are copied after the builtin static files, 105 | # so a file named "default.css" will overwrite the builtin "default.css". 106 | # html_static_path = ["_static"] 107 | 108 | 109 | # -- Options for HTMLHelp output ------------------------------------------ 110 | 111 | # Output file base name for HTML help builder. 112 | htmlhelp_basename = "mayadoc" 113 | 114 | 115 | # -- Options for LaTeX output --------------------------------------------- 116 | 117 | latex_elements = { 118 | # The paper size ('letterpaper' or 'a4paper'). 119 | # 120 | # 'papersize': 'letterpaper', 121 | # The font size ('10pt', '11pt' or '12pt'). 122 | # 123 | # 'pointsize': '10pt', 124 | # Additional stuff for the LaTeX preamble. 125 | # 126 | # 'preamble': '', 127 | # Latex figure (float) alignment 128 | # 129 | # 'figure_align': 'htbp', 130 | } 131 | 132 | # Grouping the document tree into LaTeX files. List of tuples 133 | # (source start file, target name, title, 134 | # author, documentclass [howto, manual, or own class]). 135 | latex_documents = [ 136 | (master_doc, "maya.tex", "maya Documentation", "Kenneth Reitz", "manual") 137 | ] 138 | 139 | 140 | # -- Options for manual page output --------------------------------------- 141 | 142 | # One entry per manual page. List of tuples 143 | # (source start file, name, description, authors, manual section). 144 | man_pages = [(master_doc, "maya", "maya Documentation", [author], 1)] 145 | 146 | 147 | # -- Options for Texinfo output ------------------------------------------- 148 | 149 | # Grouping the document tree into Texinfo files. List of tuples 150 | # (source start file, target name, title, author, 151 | # dir menu entry, description, category) 152 | texinfo_documents = [ 153 | ( 154 | master_doc, 155 | "maya", 156 | "maya Documentation", 157 | author, 158 | "maya", 159 | "One line description of project.", 160 | "Miscellaneous", 161 | ) 162 | ] 163 | -------------------------------------------------------------------------------- /docs/source/index.rst: -------------------------------------------------------------------------------- 1 | .. maya documentation master file, created by 2 | sphinx-quickstart on Sun May 28 15:46:10 2017. 3 | You can adapt this file completely to your liking, but it should at least 4 | contain the root `toctree` directive. 5 | 6 | Maya: Datetime for Humans 7 | ================================ 8 | Release v\ |version|. (:ref:`Installation `) 9 | 10 | .. image:: https://img.shields.io/pypi/v/maya.svg 11 | :target: https://pypi.python.org/pypi/maya 12 | 13 | .. image:: https://travis-ci.org/kennethreitz/maya.svg?branch=master 14 | :target: https://travis-ci.org/kennethreitz/maya 15 | 16 | .. image:: https://img.shields.io/badge/SayThanks-!-1EAEDB.svg 17 | :target: https://saythanks.io/to/kennethreitz 18 | 19 | ☤ Behold, datetimes for humans! 20 | ------------------------------- 21 | .. code-block:: pycon 22 | 23 | >>> now = maya.now() 24 | 25 | 26 | >>> tomorrow = maya.when('tomorrow') 27 | 28 | 29 | >>> tomorrow.slang_date() 30 | 'tomorrow' 31 | 32 | >>> tomorrow.slang_time() 33 | '23 hours from now' 34 | 35 | # Also: MayaDT.from_iso8601(...) 36 | >>> tomorrow.iso8601() 37 | '2017-02-10T22:17:01.445418Z' 38 | 39 | # Also: MayaDT.from_rfc2822(...) 40 | >>> tomorrow.rfc2822() 41 | 'Fri, 10 Feb 2017 22:17:01 GMT' 42 | 43 | # Also: MayaDT.from_rfc3339(...) 44 | >>> tomorrow.rfc3339() 45 | '2017-02-10T22:17:01.44Z' 46 | 47 | >>> tomorrow.datetime() 48 | datetime.datetime(2016, 12, 16, 15, 11, 30, 263350, tzinfo=) 49 | 50 | # Automatically parse datetime strings and generate naive datetimes. 51 | >>> scraped = '2016-12-16 18:23:45.423992+00:00' 52 | >>> maya.parse(scraped).datetime(to_timezone='US/Eastern', naive=True) 53 | datetime.datetime(2016, 12, 16, 13, 23, 45, 423992) 54 | 55 | >>> rand_day = maya.when('2011-02-07', timezone='US/Eastern') 56 | 57 | 58 | >>> rand_day.day 59 | 7 60 | 61 | >>> rand_day.add(days=10).day 62 | 17 63 | 64 | # Always. 65 | >>> rand_day.timezone 66 | UTC 67 | 68 | # Range of hours in a day: 69 | >>> maya.interval( 70 | ... start=maya.now(), 71 | ... end=maya.now().add(days=1), 72 | ... interval=60*60) 73 | 74 | 75 | 76 | Table of Contents 77 | ----------------- 78 | .. toctree:: 79 | :maxdepth: 2 80 | 81 | user/install 82 | user/quickstart 83 | -------------------------------------------------------------------------------- /docs/source/user/install.rst: -------------------------------------------------------------------------------- 1 | .. _install: 2 | 3 | Installation 4 | ============ 5 | 6 | Pip Install Maya 7 | ---------------- 8 | 9 | To install maya, simply use:: 10 | $ pip install maya 11 | 12 | Source Code 13 | ----------- 14 | Maya is actively developed on `Github 15 | `_ 16 | 17 | You can either clone the public repository:: 18 | 19 | $ git clone git://github.com/kennethreitz/maya.git 20 | 21 | Or, download the `tarball `_:: 22 | 23 | $ curl -OL https://github.com/kennethreitz/maya/tarball/master 24 | # optionally, zipball is also available (for Windows users). 25 | 26 | Once you have a copy of the source, you can embed it in your own Python 27 | package, or install it into your site-packages easily:: 28 | 29 | $ python setup.py install 30 | -------------------------------------------------------------------------------- /docs/source/user/quickstart.rst: -------------------------------------------------------------------------------- 1 | .. _quickstart: 2 | 3 | Quickstart 4 | ========== 5 | 6 | .. module::maya 7 | 8 | Ready for a simple datetime tool? This doc provides some tools to use in your 9 | busy workflow. 10 | 11 | Parse a Date 12 | ------------ 13 | Parsing a date from a string with Maya is 🍰! 14 | 15 | First, you'll need to import maya:: 16 | 17 | >>> import maya 18 | 19 | There are currently two ways to make sense of datetime: 20 | 21 | - ``maya.parse`` 22 | - ``maya.when`` 23 | 24 | A simple answer is that you should use parse on machine output, and when on human input. 25 | 26 | Use as follows:: 27 | 28 | >>> recent_win = maya.parse('2016-11-02T20:00PM') 29 | >>> old_win = maya.when('October 14, 1908') 30 | >>> grandpas_date = maya.when('108 years ago') 31 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [bdist_wheel] 2 | universal = 1 3 | 4 | [metadata] 5 | # ensure LICENSE is included in wheel metadata 6 | license_file = LICENSE 7 | 8 | [flake8] 9 | max-line-length = 100 10 | ignore = E203 11 | 12 | [tool:pytest] 13 | testpaths = tests/ 14 | 15 | [isort] 16 | known_first_party=maya 17 | known_third_party=humanize,pytz,dateparser,tzlocal,pendulum,snaptime 18 | multi_line_output=3 19 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | import codecs 2 | import os 3 | import re 4 | 5 | from setuptools import find_packages, setup 6 | 7 | #: Holds a list of packages to install with the binary distribution 8 | PACKAGES = find_packages(where="src") 9 | META_FILE = os.path.abspath("src/maya/__init__.py") 10 | KEYWORDS = ["datetime", "timezone", "scrape", "web"] 11 | CLASSIFIERS = [ 12 | "Development Status :: 5 - Production/Stable", 13 | "Intended Audience :: Developers", 14 | "License :: OSI Approved :: MIT License", 15 | "Natural Language :: English", 16 | "Operating System :: OS Independent", 17 | "Programming Language :: Python :: 2", 18 | "Programming Language :: Python :: 2.7", 19 | "Programming Language :: Python :: 3", 20 | "Programming Language :: Python :: 3.6", 21 | "Programming Language :: Python :: Implementation", 22 | "Programming Language :: Python :: Implementation :: CPython", 23 | "Topic :: Software Development :: Libraries :: Python Modules", 24 | ] 25 | 26 | #: Holds the runtime requirements for the end user 27 | INSTALL_REQUIRES = [ 28 | "humanize", 29 | "pytz", 30 | "dateparser>=0.7.0", 31 | "tzlocal", 32 | "pendulum>=2.0.2", 33 | "snaptime", 34 | ] 35 | #: Holds runtime requirements and development requirements 36 | EXTRAS_REQUIRES = { 37 | # extras for contributors 38 | "docs": ["sphinx"], 39 | "tests": ["freezegun", "coverage", "pytest", "pytest-mock"], 40 | } 41 | EXTRAS_REQUIRES["dev"] = ( 42 | EXTRAS_REQUIRES["tests"] + EXTRAS_REQUIRES["docs"] + ["pre-commit"] 43 | ) 44 | 45 | #: Holds the contents of the README file 46 | with codecs.open("README.rst", encoding="utf-8") as readme: 47 | __README_CONTENTS__ = readme.read() 48 | 49 | 50 | def read(metafile): 51 | """ 52 | Return the contents of the given meta data file assuming UTF-8 encoding. 53 | """ 54 | with codecs.open(str(metafile), encoding="utf-8") as f: 55 | return f.read() 56 | 57 | 58 | def get_meta(meta, metafile): 59 | """ 60 | Extract __*meta*__ from the given metafile. 61 | """ 62 | contents = read(metafile) 63 | meta_match = re.search( 64 | r"^__{meta}__ = ['\"]([^'\"]*)['\"]".format(meta=meta), contents, re.M 65 | ) 66 | if meta_match: 67 | return meta_match.group(1) 68 | raise RuntimeError("Unable to find __{meta}__ string.".format(meta=meta)) 69 | 70 | 71 | setup( 72 | name="maya", 73 | version=get_meta("version", META_FILE), 74 | license=get_meta("license", META_FILE), 75 | description=get_meta("description", META_FILE), 76 | long_description=__README_CONTENTS__, 77 | author=get_meta("author", META_FILE), 78 | author_email=get_meta("author_email", META_FILE), 79 | maintainer=get_meta("author", META_FILE), 80 | maintainer_email=get_meta("author_email", META_FILE), 81 | platforms=["Linux", "Windows", "MAC OS X"], 82 | url=get_meta("url", META_FILE), 83 | download_url=get_meta("download_url", META_FILE), 84 | bugtrack_url=get_meta("bugtrack_url", META_FILE), 85 | packages=PACKAGES, 86 | package_dir={"": "src"}, 87 | include_package_data=True, 88 | install_requires=INSTALL_REQUIRES, 89 | extras_require=EXTRAS_REQUIRES, 90 | keywords=KEYWORDS, 91 | classifiers=CLASSIFIERS, 92 | ) 93 | -------------------------------------------------------------------------------- /src/maya/__init__.py: -------------------------------------------------------------------------------- 1 | __description__ = "Datetimes for Humans™" 2 | __license__ = "MIT" 3 | __version__ = "0.6.0a1" 4 | __author__ = "Timo Furrer" 5 | __author_email__ = "tuxtimo@gmail.com" 6 | __url__ = "http://github.com/timofurrer/maya" 7 | __download_url__ = "https://github.com/timofurrer/maya" 8 | __bugtrack_url__ = "https://github.com/timofurrer/maya/issues" 9 | 10 | from .core import * # noqa 11 | -------------------------------------------------------------------------------- /src/maya/compat.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | """ 3 | maya.compat 4 | ~~~~~~~~~~~~~~~ 5 | This module handles import compatibility issues between Python 2 and 6 | Python 3. 7 | """ 8 | 9 | import sys 10 | 11 | # ------- 12 | # Pythons 13 | # ------- 14 | # Syntax sugar. 15 | _ver = sys.version_info 16 | # : Python 2.x? 17 | is_py2 = _ver[0] == 2 18 | # : Python 3.x? 19 | is_py3 = _ver[0] == 3 20 | # --------- 21 | # Specifics 22 | # --------- 23 | if is_py2: 24 | cmp = cmp # noqa 25 | 26 | elif is_py3: 27 | 28 | def cmp(a, b): 29 | """ 30 | Compare two objects. 31 | Returns a negative number if C{a < b}, zero if they are equal, and a 32 | positive number if C{a > b}. 33 | """ 34 | if a < b: 35 | return -1 36 | 37 | elif a == b: 38 | return 0 39 | 40 | else: 41 | return 1 42 | 43 | 44 | def comparable(klass): 45 | """ 46 | Class decorator that ensures support for the special C{__cmp__} method. 47 | On Python 2 this does nothing. 48 | On Python 3, C{__eq__}, C{__lt__}, etc. methods are added to the class, 49 | relying on C{__cmp__} to implement their comparisons. 50 | """ 51 | # On Python 2, __cmp__ will just work, so no need to add extra methods: 52 | if not is_py3: 53 | return klass 54 | 55 | def __eq__(self, other): 56 | c = self.__cmp__(other) 57 | if c is NotImplemented: 58 | return c 59 | 60 | return c == 0 61 | 62 | def __ne__(self, other): 63 | c = self.__cmp__(other) 64 | if c is NotImplemented: 65 | return c 66 | 67 | return c != 0 68 | 69 | def __lt__(self, other): 70 | c = self.__cmp__(other) 71 | if c is NotImplemented: 72 | return c 73 | 74 | return c < 0 75 | 76 | def __le__(self, other): 77 | c = self.__cmp__(other) 78 | if c is NotImplemented: 79 | return c 80 | 81 | return c <= 0 82 | 83 | def __gt__(self, other): 84 | c = self.__cmp__(other) 85 | if c is NotImplemented: 86 | return c 87 | 88 | return c > 0 89 | 90 | def __ge__(self, other): 91 | c = self.__cmp__(other) 92 | if c is NotImplemented: 93 | return c 94 | 95 | return c >= 0 96 | 97 | klass.__lt__ = __lt__ 98 | klass.__gt__ = __gt__ 99 | klass.__le__ = __le__ 100 | klass.__ge__ = __ge__ 101 | klass.__eq__ = __eq__ 102 | klass.__ne__ = __ne__ 103 | return klass 104 | -------------------------------------------------------------------------------- /src/maya/core.py: -------------------------------------------------------------------------------- 1 | # ___ __ ___ _ _ ___ 2 | # || \/ | ||=|| \\// ||=|| 3 | # || | || || // || || 4 | import email.utils 5 | import time 6 | import functools 7 | from datetime import timedelta, datetime as Datetime 8 | 9 | import re 10 | import pytz 11 | import humanize 12 | import dateparser 13 | import pendulum 14 | import snaptime 15 | from tzlocal import get_localzone 16 | from dateutil.relativedelta import relativedelta 17 | from dateparser.languages.loader import default_loader 18 | 19 | from .compat import cmp, comparable 20 | 21 | 22 | def validate_class_type_arguments(operator): 23 | """ 24 | Decorator to validate all the arguments to function 25 | are of the type of calling class for passed operator 26 | """ 27 | 28 | def inner(function): 29 | def wrapper(self, *args, **kwargs): 30 | for arg in args + tuple(kwargs.values()): 31 | if not isinstance(arg, self.__class__): 32 | raise TypeError( 33 | "unorderable types: {}() {} {}()".format( 34 | type(self).__name__, operator, type(arg).__name__ 35 | ) 36 | ) 37 | 38 | return function(self, *args, **kwargs) 39 | 40 | return wrapper 41 | 42 | return inner 43 | 44 | 45 | def validate_arguments_type_of_function(param_type=None): 46 | """ 47 | Decorator to validate the of arguments in 48 | the calling function are of the `param_type` class. 49 | 50 | if `param_type` is None, uses `param_type` as the class where it is used. 51 | 52 | Note: Use this decorator on the functions of the class. 53 | """ 54 | 55 | def inner(function): 56 | def wrapper(self, *args, **kwargs): 57 | type_ = param_type or type(self) 58 | for arg in args + tuple(kwargs.values()): 59 | if not isinstance(arg, type_): 60 | raise TypeError( 61 | ( 62 | "Invalid Type: {}.{}() accepts only the " 63 | 'arguments of type "<{}>"' 64 | ).format(type(self).__name__, function.__name__, type_.__name__) 65 | ) 66 | 67 | return function(self, *args, **kwargs) 68 | 69 | return wrapper 70 | 71 | return inner 72 | 73 | 74 | class MayaDT(object): 75 | """The Maya Datetime object.""" 76 | 77 | __EPOCH_START = (1970, 1, 1) 78 | 79 | def __init__(self, epoch): 80 | super(MayaDT, self).__init__() 81 | self._epoch = epoch 82 | 83 | def __repr__(self): 84 | return "".format(self._epoch) 85 | 86 | def __str__(self): 87 | return self.rfc2822() 88 | 89 | def __format__(self, *args, **kwargs): 90 | """Return's the datetime's format""" 91 | return format(self.datetime(), *args, **kwargs) 92 | 93 | @validate_class_type_arguments("==") 94 | def __eq__(self, maya_dt): 95 | return int(self._epoch) == int(maya_dt._epoch) 96 | 97 | @validate_class_type_arguments("!=") 98 | def __ne__(self, maya_dt): 99 | return int(self._epoch) != int(maya_dt._epoch) 100 | 101 | @validate_class_type_arguments("<") 102 | def __lt__(self, maya_dt): 103 | return int(self._epoch) < int(maya_dt._epoch) 104 | 105 | @validate_class_type_arguments("<=") 106 | def __le__(self, maya_dt): 107 | return int(self._epoch) <= int(maya_dt._epoch) 108 | 109 | @validate_class_type_arguments(">") 110 | def __gt__(self, maya_dt): 111 | return int(self._epoch) > int(maya_dt._epoch) 112 | 113 | @validate_class_type_arguments(">=") 114 | def __ge__(self, maya_dt): 115 | return int(self._epoch) >= int(maya_dt._epoch) 116 | 117 | def __hash__(self): 118 | return hash(int(self.epoch)) 119 | 120 | def __add__(self, duration): 121 | return self.add(seconds=_seconds_or_timedelta(duration).total_seconds()) 122 | 123 | def __radd__(self, duration): 124 | return self + duration 125 | 126 | def __sub__(self, duration_or_date): 127 | if isinstance(duration_or_date, MayaDT): 128 | return self.subtract_date(dt=duration_or_date) 129 | 130 | else: 131 | return self.subtract( 132 | seconds=_seconds_or_timedelta(duration_or_date).total_seconds() 133 | ) 134 | 135 | def add(self, **kwargs): 136 | """Returns a new MayaDT object with the given offsets.""" 137 | return self.from_datetime(pendulum.instance(self.datetime()).add(**kwargs)) 138 | 139 | def subtract(self, **kwargs): 140 | """Returns a new MayaDT object with the given offsets.""" 141 | return self.from_datetime(pendulum.instance(self.datetime()).subtract(**kwargs)) 142 | 143 | def subtract_date(self, **kwargs): 144 | """Returns a timedelta object with the duration between the dates""" 145 | return timedelta(seconds=self.epoch - kwargs["dt"].epoch) 146 | 147 | def snap(self, instruction): 148 | """ 149 | Returns a new MayaDT object modified by the given instruction. 150 | 151 | Powered by snaptime. See https://github.com/zartstrom/snaptime 152 | for a complete documentation about the snaptime instructions. 153 | """ 154 | return self.from_datetime(snaptime.snap(self.datetime(), instruction)) 155 | 156 | def snap_tz(self, instruction, in_timezone): 157 | """ 158 | Returns a new MayaDT object modified by the given instruction. 159 | The modifications happen in the given timezone. 160 | 161 | Powered by snaptime. See https://github.com/zartstrom/snaptime 162 | for a complete documentation about the snaptime instructions. 163 | """ 164 | dt_tz = self.datetime(to_timezone=in_timezone) 165 | return self.from_datetime(snaptime.snap_tz(dt_tz, instruction, dt_tz.tzinfo)) 166 | 167 | # Timezone Crap 168 | # ------------- 169 | @property 170 | def timezone(self): 171 | """Returns the UTC tzinfo name. It's always UTC. Always.""" 172 | return "UTC" 173 | 174 | @property 175 | def _tz(self): 176 | """Returns the UTC tzinfo object.""" 177 | return pytz.timezone(self.timezone) 178 | 179 | @property 180 | def local_timezone(self): 181 | """Returns the name of the local timezone.""" 182 | if self._local_tz.zone in pytz.all_timezones: 183 | return self._local_tz.zone 184 | 185 | return self.timezone 186 | 187 | @property 188 | def _local_tz(self): 189 | """Returns the local timezone.""" 190 | return get_localzone() 191 | 192 | @staticmethod 193 | @validate_arguments_type_of_function(Datetime) 194 | def __dt_to_epoch(dt): 195 | """Converts a datetime into an epoch.""" 196 | # Assume UTC if no datetime is provided. 197 | if dt.tzinfo is None: 198 | dt = dt.replace(tzinfo=pytz.utc) 199 | epoch_start = Datetime(*MayaDT.__EPOCH_START, tzinfo=pytz.timezone("UTC")) 200 | return (dt - epoch_start).total_seconds() 201 | 202 | # Importers 203 | # --------- 204 | @classmethod 205 | @validate_arguments_type_of_function(Datetime) 206 | def from_datetime(klass, dt): 207 | """Returns MayaDT instance from datetime.""" 208 | return klass(klass.__dt_to_epoch(dt)) 209 | 210 | @classmethod 211 | @validate_arguments_type_of_function(time.struct_time) 212 | def from_struct(klass, struct, timezone=pytz.UTC): 213 | """Returns MayaDT instance from a 9-tuple struct 214 | 215 | It's assumed to be from gmtime(). 216 | """ 217 | struct_time = time.mktime(struct) - utc_offset(struct) 218 | dt = Datetime.fromtimestamp(struct_time, timezone) 219 | return klass(klass.__dt_to_epoch(dt)) 220 | 221 | @classmethod 222 | def from_iso8601(klass, iso8601_string): 223 | """Returns MayaDT instance from iso8601 string.""" 224 | return parse(iso8601_string) 225 | 226 | @classmethod 227 | def from_long_count(klass, long_count_string): 228 | """Returns MayaDT instance from Maya Long Count string.""" 229 | days_since_creation = -1856305 230 | factors = (144000, 7200, 360, 20, 1) 231 | for i, value in enumerate(long_count_string.split('.')): 232 | days_since_creation += int(value) * factors[i] 233 | days_since_creation *= 3600 * 24 234 | return klass(epoch=days_since_creation) 235 | 236 | @staticmethod 237 | def from_rfc2822(rfc2822_string): 238 | """Returns MayaDT instance from rfc2822 string.""" 239 | return parse(rfc2822_string) 240 | 241 | @staticmethod 242 | def from_rfc3339(rfc3339_string): 243 | """Returns MayaDT instance from rfc3339 string.""" 244 | return parse(rfc3339_string) 245 | 246 | # Exporters 247 | # --------- 248 | def datetime(self, to_timezone=None, naive=False): 249 | """Returns a timezone-aware datetime... 250 | Defaulting to UTC (as it should). 251 | 252 | Keyword Arguments: 253 | to_timezone {str} -- timezone to convert to (default: None/UTC) 254 | naive {bool} -- if True, 255 | the tzinfo is simply dropped (default: False) 256 | """ 257 | if to_timezone: 258 | dt = self.datetime().astimezone(pytz.timezone(to_timezone)) 259 | else: 260 | try: 261 | dt = Datetime.utcfromtimestamp(self._epoch) 262 | except: # Fallback for before year 1970 issue 263 | dt = Datetime.utcfromtimestamp(0) + timedelta(microseconds=self._epoch*1000000) 264 | dt.replace(tzinfo=self._tz) 265 | # Strip the timezone info if requested to do so. 266 | if naive: 267 | return dt.replace(tzinfo=None) 268 | 269 | else: 270 | if dt.tzinfo is None: 271 | dt = dt.replace(tzinfo=self._tz) 272 | return dt 273 | 274 | def local_datetime(self): 275 | """Returns a local timezone-aware datetime object 276 | 277 | It's the same as: 278 | mayaDt.datetime(to_timezone=mayaDt.local_timezone) 279 | """ 280 | return self.datetime(to_timezone=self.local_timezone, naive=False) 281 | 282 | def iso8601(self): 283 | """Returns an ISO 8601 representation of the MayaDT.""" 284 | # Get a timezone-naive datetime. 285 | dt = self.datetime(naive=True) 286 | return "{}Z".format(dt.isoformat()) 287 | 288 | def rfc2822(self): 289 | """Returns an RFC 2822 representation of the MayaDT.""" 290 | return email.utils.formatdate(self.epoch, usegmt=True) 291 | 292 | def rfc3339(self): 293 | """Returns an RFC 3339 representation of the MayaDT.""" 294 | return self.datetime().strftime("%Y-%m-%dT%H:%M:%S.%f")[:-5] + "Z" 295 | 296 | def long_count(self): 297 | """Returns a Mayan Long Count representation of the Maya DT.""" 298 | # Creation (0.0.0.0.0) occurred on -3114-08-11 299 | # 1856305 is distance (in days) between Creation and UNIX epoch 300 | days_since_creation = int(1856305 + self._epoch / (3600 * 24)) 301 | caps = (0, 20, 20, 18, 20) 302 | lc_date = [0, 0, 0, 0, days_since_creation] 303 | for i in range(4, 0, -1): 304 | if lc_date[i] >= caps[i]: 305 | lc_date[i - 1] += int(lc_date[i] / caps[i]) 306 | lc_date[i] %= caps[i] 307 | elif lc_date[i] < 0: 308 | lc_date[i - 1] += int(lc_date[i] / caps[i]) 309 | lc_date[i] = 0 310 | return '.'.join(str(i) for i in lc_date) 311 | 312 | # Properties 313 | # ---------- 314 | @property 315 | def year(self): 316 | return self.datetime().year 317 | 318 | @property 319 | def month(self): 320 | return self.datetime().month 321 | 322 | @property 323 | def day(self): 324 | return self.datetime().day 325 | 326 | @property 327 | def date(self): 328 | return self.datetime().date() 329 | 330 | @property 331 | def week(self): 332 | return self.datetime().isocalendar()[1] 333 | 334 | @property 335 | def weekday(self): 336 | """Return the day of the week as an integer. 337 | 338 | Monday is 1 and Sunday is 7. 339 | """ 340 | return self.datetime().isoweekday() 341 | 342 | @property 343 | def hour(self): 344 | return self.datetime().hour 345 | 346 | @property 347 | def minute(self): 348 | return self.datetime().minute 349 | 350 | @property 351 | def second(self): 352 | return self.datetime().second 353 | 354 | @property 355 | def microsecond(self): 356 | return self.datetime().microsecond 357 | 358 | @property 359 | def epoch(self): 360 | return int(self._epoch) 361 | 362 | # Human Slang Extras 363 | # ------------------ 364 | def slang_date(self, locale="en"): 365 | """"Returns human slang representation of date. 366 | 367 | Keyword Arguments: 368 | locale -- locale to translate to, e.g. 'fr' for french. 369 | (default: 'en' - English) 370 | """ 371 | dt = pendulum.instance(self.datetime()) 372 | 373 | try: 374 | return _translate(dt, locale) 375 | except KeyError: 376 | pass 377 | 378 | delta = humanize.time._abs_timedelta( 379 | timedelta(seconds=(self.epoch - now().epoch)) 380 | ) 381 | 382 | format_string = "DD MMM" 383 | if delta.days >= 365: 384 | format_string += " YYYY" 385 | 386 | return dt.format(format_string, locale=locale).title() 387 | 388 | def slang_time(self, locale="en"): 389 | """"Returns human slang representation of time. 390 | 391 | Keyword Arguments: 392 | locale -- locale to translate to, e.g. 'fr' for french. 393 | (default: 'en' - English) 394 | """ 395 | dt = self.datetime() 396 | return pendulum.instance(dt).diff_for_humans(locale=locale) 397 | 398 | 399 | def utc_offset(time_struct=None): 400 | """ 401 | Returns the time offset from UTC accounting for DST 402 | 403 | Keyword Arguments: 404 | time_struct {time.struct_time} -- the struct time for which to 405 | return the UTC offset. 406 | If None, use current local time. 407 | """ 408 | if time_struct: 409 | ts = time_struct 410 | else: 411 | ts = time.localtime() 412 | 413 | if ts[-1]: 414 | offset = time.altzone 415 | else: 416 | offset = time.timezone 417 | return offset 418 | 419 | 420 | def to_utc_offset_naive(dt): 421 | if dt.tzinfo is None: 422 | return dt 423 | 424 | return dt.astimezone(pytz.utc).replace(tzinfo=None) 425 | 426 | 427 | def to_utc_offset_aware(dt): 428 | if dt.tzinfo is not None: 429 | return dt 430 | 431 | return pytz.utc.localize(dt) 432 | 433 | 434 | def to_iso8601(dt): 435 | return to_utc_offset_naive(dt).isoformat() + "Z" 436 | 437 | 438 | def end_of_day_midnight(dt): 439 | if dt.time() == time.min: 440 | return dt 441 | 442 | else: 443 | return dt.replace(hour=0, minute=0, second=0, microsecond=0) + timedelta(days=1) 444 | 445 | 446 | @comparable 447 | class MayaInterval(object): 448 | """ 449 | A MayaInterval represents a range between two datetimes, 450 | inclusive of the start and exclusive of the end. 451 | """ 452 | 453 | def __init__(self, start=None, end=None, duration=None): 454 | try: 455 | # Ensure that proper arguments were passed. 456 | assert any( 457 | ( 458 | (start and end), 459 | (start and duration is not None), 460 | (end and duration is not None), 461 | ) 462 | ) 463 | assert not all((start, end, duration is not None)) 464 | except AssertionError: 465 | raise ValueError("Exactly 2 of start, end, and duration must be specified") 466 | 467 | # Convert duration to timedelta if seconds were provided. 468 | if duration: 469 | duration = _seconds_or_timedelta(duration) 470 | if not start: 471 | start = end - duration 472 | if not end: 473 | end = start + duration 474 | if start > end: 475 | raise ValueError("MayaInterval cannot end before it starts") 476 | 477 | self.start = start 478 | self.end = end 479 | 480 | def __repr__(self): 481 | return "".format(self.start, self.end) 482 | 483 | def iso8601(self): 484 | """Returns an ISO 8601 representation of the MayaInterval.""" 485 | return "{0}/{1}".format(self.start.iso8601(), self.end.iso8601()) 486 | 487 | @classmethod 488 | def parse_iso8601_duration(cls, duration, start=None, end=None): 489 | match = re.match( 490 | r"(?:P(?P\d+)W)|(?:P(?:(?:(?P\d+)Y)?(?:(?P\d+)M)?(?:(?P\d+)D))?(?:T(?:(?P\d+)H)?(?:(?P\d+)M)?(?:(?P\d+)S)?)?)", # noqa 491 | duration, 492 | ) 493 | 494 | time_components = {} 495 | if match: 496 | time_components = match.groupdict(0) 497 | for key, value in time_components.items(): 498 | time_components[key] = int(value) 499 | 500 | duration = relativedelta(**time_components) 501 | 502 | if start: 503 | return parse(start.datetime() + duration) 504 | 505 | if end: 506 | return parse(end.datetime() - duration) 507 | 508 | return None 509 | 510 | @classmethod 511 | def from_iso8601(cls, s): 512 | # # Start and end, such as "2007-03-01T13:00:00Z/2008-05-11T15:30:00Z" 513 | start, end = s.split("/") 514 | try: 515 | start = parse(start) 516 | except pendulum.parsing.exceptions.ParserError: 517 | # start = self._parse_iso8601_duration(start, end=end) 518 | raise NotImplementedError() 519 | 520 | try: 521 | end = parse(end) 522 | except (pendulum.parsing.exceptions.ParserError, TypeError): 523 | end = cls.parse_iso8601_duration(end, start=start) 524 | 525 | return cls(start=start, end=end) 526 | 527 | # # Start and duration, such as "2007-03-01T13:00:00Z/P1Y2M10DT2H30M" 528 | # # Duration and end, such as "P1Y2M10DT2H30M/2008-05-11T15:30:00Z" 529 | 530 | @validate_arguments_type_of_function() 531 | def __and__(self, maya_interval): 532 | return self.intersection(maya_interval) 533 | 534 | @validate_arguments_type_of_function() 535 | def __or__(self, maya_interval): 536 | return self.combine(maya_interval) 537 | 538 | @validate_arguments_type_of_function() 539 | def __eq__(self, maya_interval): 540 | return self.start == maya_interval.start and self.end == maya_interval.end 541 | 542 | def __hash__(self): 543 | return hash((self.start, self.end)) 544 | 545 | def __iter__(self): 546 | yield self.start 547 | yield self.end 548 | 549 | @validate_arguments_type_of_function() 550 | def __cmp__(self, maya_interval): 551 | return cmp(self.start, maya_interval.start) or cmp(self.end, maya_interval.end) 552 | 553 | @property 554 | def duration(self): 555 | return self.timedelta.total_seconds() 556 | 557 | @property 558 | def timedelta(self): 559 | return timedelta(seconds=(self.end.epoch - self.start.epoch)) 560 | 561 | @property 562 | def is_instant(self): 563 | return self.timedelta == timedelta(seconds=0) 564 | 565 | def intersects(self, maya_interval): 566 | return self & maya_interval is not None 567 | 568 | @property 569 | def midpoint(self): 570 | return self.start.add(seconds=(self.duration / 2)) 571 | 572 | @validate_arguments_type_of_function() 573 | def combine(self, maya_interval): 574 | """Returns a combined list of timespans, merged together.""" 575 | interval_list = sorted([self, maya_interval]) 576 | if self & maya_interval or self.is_adjacent(maya_interval): 577 | return [ 578 | MayaInterval( 579 | interval_list[0].start, 580 | max(interval_list[0].end, interval_list[1].end), 581 | ) 582 | ] 583 | 584 | return interval_list 585 | 586 | @validate_arguments_type_of_function() 587 | def subtract(self, maya_interval): 588 | """"Removes the given interval.""" 589 | if not self & maya_interval: 590 | return [self] 591 | 592 | elif maya_interval.contains(self): 593 | return [] 594 | 595 | interval_list = [] 596 | if self.start < maya_interval.start: 597 | interval_list.append(MayaInterval(self.start, maya_interval.start)) 598 | if self.end > maya_interval.end: 599 | interval_list.append(MayaInterval(maya_interval.end, self.end)) 600 | return interval_list 601 | 602 | def split(self, duration, include_remainder=True): 603 | # Convert seconds to timedelta, if appropriate. 604 | duration = _seconds_or_timedelta(duration) 605 | if duration <= timedelta(seconds=0): 606 | raise ValueError("cannot call split with a non-positive timedelta") 607 | 608 | start = self.start 609 | while start < self.end: 610 | if start + duration <= self.end: 611 | yield MayaInterval(start, start + duration) 612 | 613 | elif include_remainder: 614 | yield MayaInterval(start, self.end) 615 | 616 | start += duration 617 | 618 | def quantize(self, duration, snap_out=False, timezone="UTC"): 619 | """Returns a quantized interval.""" 620 | # Convert seconds to timedelta, if appropriate. 621 | duration = _seconds_or_timedelta(duration) 622 | timezone = pytz.timezone(timezone) 623 | if duration <= timedelta(seconds=0): 624 | raise ValueError("cannot quantize by non-positive timedelta") 625 | 626 | epoch = timezone.localize(Datetime(1970, 1, 1)) 627 | seconds = int(duration.total_seconds()) 628 | start_seconds = int((self.start.datetime(naive=False) - epoch).total_seconds()) 629 | end_seconds = int((self.end.datetime(naive=False) - epoch).total_seconds()) 630 | if start_seconds % seconds and not snap_out: 631 | start_seconds += seconds 632 | if end_seconds % seconds and snap_out: 633 | end_seconds += seconds 634 | start_seconds -= start_seconds % seconds 635 | end_seconds -= end_seconds % seconds 636 | if start_seconds > end_seconds: 637 | start_seconds = end_seconds 638 | return MayaInterval( 639 | start=MayaDT.from_datetime(epoch).add(seconds=start_seconds), 640 | end=MayaDT.from_datetime(epoch).add(seconds=end_seconds), 641 | ) 642 | 643 | @validate_arguments_type_of_function() 644 | def intersection(self, maya_interval): 645 | """Returns the intersection between two intervals.""" 646 | start = max(self.start, maya_interval.start) 647 | end = min(self.end, maya_interval.end) 648 | either_instant = self.is_instant or maya_interval.is_instant 649 | instant_overlap = self.start == maya_interval.start or start <= end 650 | if (either_instant and instant_overlap) or (start < end): 651 | return MayaInterval(start, end) 652 | 653 | @validate_arguments_type_of_function() 654 | def contains(self, maya_interval): 655 | return self.start <= maya_interval.start and self.end >= maya_interval.end 656 | 657 | def __contains__(self, maya_dt): 658 | if isinstance(maya_dt, MayaDT): 659 | return self.contains_dt(maya_dt) 660 | 661 | return self.contains(maya_dt) 662 | 663 | def contains_dt(self, dt): 664 | return self.start <= dt < self.end 665 | 666 | @validate_arguments_type_of_function() 667 | def is_adjacent(self, maya_interval): 668 | return self.start == maya_interval.end or self.end == maya_interval.start 669 | 670 | @property 671 | def icalendar(self): 672 | ical_dt_format = "%Y%m%dT%H%M%SZ" 673 | return ( 674 | """ 675 | BEGIN:VCALENDAR 676 | VERSION:2.0 677 | BEGIN:VEVENT 678 | DTSTART:{0} 679 | DTEND:{1} 680 | END:VEVENT 681 | END:VCALENDAR 682 | """.format( 683 | self.start.datetime().strftime(ical_dt_format), 684 | self.end.datetime().strftime(ical_dt_format), 685 | ) 686 | .replace(" ", "") 687 | .strip("\r\n") 688 | .replace("\n", "\r\n") 689 | ) 690 | 691 | @staticmethod 692 | def flatten(interval_list): 693 | return functools.reduce( 694 | lambda reduced, maya_interval: ( 695 | (reduced[:-1] + maya_interval.combine(reduced[-1])) 696 | if reduced 697 | else [maya_interval] 698 | ), 699 | sorted(interval_list), 700 | [], 701 | ) 702 | 703 | @classmethod 704 | def from_datetime(cls, start_dt=None, end_dt=None, duration=None): 705 | start = MayaDT.from_datetime(start_dt) if start_dt else None 706 | end = MayaDT.from_datetime(end_dt) if end_dt else None 707 | return cls(start=start, end=end, duration=duration) 708 | 709 | 710 | def now(): 711 | """Returns a MayaDT instance for this exact moment.""" 712 | epoch = time.time() 713 | return MayaDT(epoch=epoch) 714 | 715 | 716 | def when(string, timezone="UTC", prefer_dates_from="current_period"): 717 | """"Returns a MayaDT instance for the human moment specified. 718 | 719 | Powered by dateparser. Useful for scraping websites. 720 | 721 | Examples: 722 | 'next week', 'now', 'tomorrow', '300 years ago', 'August 14, 2015' 723 | 724 | Keyword Arguments: 725 | string -- string to be parsed 726 | timezone -- timezone referenced from (default: 'UTC') 727 | prefer_dates_from -- what dates are preferred when `string` is ambiguous. 728 | options are 'past', 'future', and 'current_period' 729 | (default: 'current_period'). see: [1] 730 | 731 | Reference: 732 | [1] dateparser.readthedocs.io/en/latest/usage.html#handling-incomplete-dates 733 | """ 734 | settings = { 735 | "TIMEZONE": timezone, 736 | "RETURN_AS_TIMEZONE_AWARE": True, 737 | "TO_TIMEZONE": "UTC", 738 | "PREFER_DATES_FROM": prefer_dates_from, 739 | } 740 | 741 | dt = dateparser.parse(string, settings=settings) 742 | if dt is None: 743 | raise ValueError("invalid datetime input specified.") 744 | 745 | return MayaDT.from_datetime(dt) 746 | 747 | 748 | def parse(string, timezone="UTC", day_first=False, year_first=True, strict=False): 749 | """"Returns a MayaDT instance for the machine-produced moment specified. 750 | 751 | Powered by pendulum. 752 | Accepts most known formats. Useful for working with data. 753 | 754 | Keyword Arguments: 755 | string -- string to be parsed 756 | timezone -- timezone referenced from (default: 'UTC') 757 | day_first -- if true, the first value (e.g. 01/05/2016) 758 | is parsed as day. 759 | if year_first is set to True, this distinguishes 760 | between YDM and YMD. (default: False) 761 | year_first -- if true, the first value (e.g. 2016/05/01) 762 | is parsed as year (default: True) 763 | strict -- if False, allow pendulum to fall back on datetime parsing 764 | if pendulum's own parsing fails 765 | """ 766 | options = {} 767 | options["tz"] = timezone 768 | options["day_first"] = day_first 769 | options["year_first"] = year_first 770 | options["strict"] = strict 771 | 772 | dt = pendulum.parse(str(string), **options) 773 | return MayaDT.from_datetime(dt) 774 | 775 | 776 | def _seconds_or_timedelta(duration): 777 | """Returns `datetime.timedelta` object for the passed duration. 778 | 779 | Keyword Arguments: 780 | duration -- `datetime.timedelta` object or seconds in `int` format. 781 | """ 782 | if isinstance(duration, int): 783 | dt_timedelta = timedelta(seconds=duration) 784 | elif isinstance(duration, timedelta): 785 | dt_timedelta = duration 786 | else: 787 | raise TypeError( 788 | "Expects argument as `datetime.timedelta` object " 789 | "or seconds in `int` format" 790 | ) 791 | 792 | return dt_timedelta 793 | 794 | 795 | def _translate(dt, target_locale): 796 | en = default_loader.get_locale("en") 797 | target = default_loader.get_locale(target_locale) 798 | naturaldate = humanize.naturaldate(dt) 799 | 800 | base = en.translate(naturaldate, settings=dateparser.conf.settings) 801 | 802 | return target.info["relative-type"][base][-1] 803 | 804 | 805 | def intervals(start, end, interval): 806 | """ 807 | Yields MayaDT objects between the start and end MayaDTs given, 808 | at a given interval (seconds or timedelta). 809 | """ 810 | interval = _seconds_or_timedelta(interval) 811 | current_timestamp = start 812 | while current_timestamp.epoch < end.epoch: 813 | yield current_timestamp 814 | 815 | current_timestamp = current_timestamp.add(seconds=interval.total_seconds()) 816 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kennethreitz/maya/9766619d007caf553357db6dba5485fb36ed4edd/tests/__init__.py -------------------------------------------------------------------------------- /tests/conftest.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | 3 | import pytz 4 | from freezegun import freeze_time 5 | import pytest 6 | 7 | 8 | @pytest.fixture( 9 | params=[ 10 | ("2018-03-25T00:00:00", 2), 11 | ("2018-03-25T01:00:00", 2), 12 | ("2018-03-25T02:00:00", 2), 13 | ("2018-03-25T02:30:00", 2), 14 | ("2018-03-25T03:00:00", 2), 15 | ("2018-03-25T04:00:00", 2), 16 | ("2018-10-28T00:00:00", 2), 17 | ("2018-10-28T01:00:00", 2), 18 | ("2018-10-28T02:00:00", 2), 19 | ("2018-10-28T02:30:00", 2), 20 | ("2018-10-28T03:00:00", 2), 21 | ("2018-10-28T04:00:00", 2), 22 | ], 23 | ids=lambda x: x[0] + "_off_" + str(x[1]), 24 | ) 25 | def frozen_now(request): 26 | now_string, tz_offset = request.param 27 | with freeze_time(now_string, tz_offset=tz_offset): 28 | yield 29 | 30 | 31 | @pytest.fixture(params=[ 32 | datetime.datetime(2020, 8, 10, 22, 2, 0, tzinfo=pytz.timezone('UTC')), 33 | datetime.datetime(2020, 8, 10, 23, 2, 0, tzinfo=pytz.timezone('UTC')), 34 | datetime.datetime(2020, 8, 11, 0, 2, 0, tzinfo=pytz.timezone('UTC')), 35 | datetime.datetime(2020, 8, 11, 1, 2, 0, tzinfo=pytz.timezone('UTC')), 36 | datetime.datetime(2020, 8, 11, 2, 2, 0, tzinfo=pytz.timezone('UTC')), 37 | datetime.datetime(2020, 8, 11, 3, 2, 0, tzinfo=pytz.timezone('UTC')), 38 | datetime.datetime(2020, 8, 11, 4, 2, 0, tzinfo=pytz.timezone('UTC')), 39 | datetime.datetime(2020, 8, 11, 5, 2, 0, tzinfo=pytz.timezone('UTC')), 40 | datetime.datetime(2020, 8, 11, 6, 2, 0, tzinfo=pytz.timezone('UTC')), 41 | datetime.datetime(2020, 8, 11, 7, 2, 0, tzinfo=pytz.timezone('UTC')), 42 | datetime.datetime(2020, 8, 11, 8, 2, 0, tzinfo=pytz.timezone('UTC')), 43 | datetime.datetime(2020, 8, 11, 9, 2, 0, tzinfo=pytz.timezone('UTC')), 44 | datetime.datetime(2020, 8, 11, 10, 2, 0, tzinfo=pytz.timezone('UTC')), 45 | datetime.datetime(2020, 8, 11, 11, 2, 0, tzinfo=pytz.timezone('UTC')), 46 | datetime.datetime(2020, 8, 11, 12, 2, 0, tzinfo=pytz.timezone('UTC')), 47 | datetime.datetime(2020, 8, 11, 13, 2, 0, tzinfo=pytz.timezone('UTC')), 48 | datetime.datetime(2020, 8, 11, 14, 2, 0, tzinfo=pytz.timezone('UTC')), 49 | datetime.datetime(2020, 8, 11, 15, 2, 0, tzinfo=pytz.timezone('UTC')), 50 | datetime.datetime(2020, 8, 11, 16, 2, 0, tzinfo=pytz.timezone('UTC')), 51 | datetime.datetime(2020, 8, 11, 17, 2, 0, tzinfo=pytz.timezone('UTC')), 52 | datetime.datetime(2020, 8, 11, 18, 2, 0, tzinfo=pytz.timezone('UTC')), 53 | datetime.datetime(2020, 8, 11, 19, 2, 0, tzinfo=pytz.timezone('UTC')), 54 | datetime.datetime(2020, 8, 11, 20, 2, 0, tzinfo=pytz.timezone('UTC')), 55 | datetime.datetime(2020, 8, 11, 21, 2, 0, tzinfo=pytz.timezone('UTC')), 56 | ], ids=str) 57 | def frozen_2020_08_11_in_paris(request): 58 | """ 59 | fixture setting datetime.now() to every hour of the 11th of august 2020 in Paris 60 | (summer time, GMT+2) 61 | """ 62 | with freeze_time(request.param): 63 | yield 64 | 65 | 66 | @pytest.fixture(params=[ 67 | datetime.datetime(2020, 2, 10, 23, 2, 0, tzinfo=pytz.timezone('UTC')), 68 | datetime.datetime(2020, 2, 11, 0, 2, 0, tzinfo=pytz.timezone('UTC')), 69 | datetime.datetime(2020, 2, 11, 1, 2, 0, tzinfo=pytz.timezone('UTC')), 70 | datetime.datetime(2020, 2, 11, 2, 2, 0, tzinfo=pytz.timezone('UTC')), 71 | datetime.datetime(2020, 2, 11, 3, 2, 0, tzinfo=pytz.timezone('UTC')), 72 | datetime.datetime(2020, 2, 11, 4, 2, 0, tzinfo=pytz.timezone('UTC')), 73 | datetime.datetime(2020, 2, 11, 5, 2, 0, tzinfo=pytz.timezone('UTC')), 74 | datetime.datetime(2020, 2, 11, 6, 2, 0, tzinfo=pytz.timezone('UTC')), 75 | datetime.datetime(2020, 2, 11, 7, 2, 0, tzinfo=pytz.timezone('UTC')), 76 | datetime.datetime(2020, 2, 11, 8, 2, 0, tzinfo=pytz.timezone('UTC')), 77 | datetime.datetime(2020, 2, 11, 9, 2, 0, tzinfo=pytz.timezone('UTC')), 78 | datetime.datetime(2020, 2, 11, 10, 2, 0, tzinfo=pytz.timezone('UTC')), 79 | datetime.datetime(2020, 2, 11, 11, 2, 0, tzinfo=pytz.timezone('UTC')), 80 | datetime.datetime(2020, 2, 11, 12, 2, 0, tzinfo=pytz.timezone('UTC')), 81 | datetime.datetime(2020, 2, 11, 13, 2, 0, tzinfo=pytz.timezone('UTC')), 82 | datetime.datetime(2020, 2, 11, 14, 2, 0, tzinfo=pytz.timezone('UTC')), 83 | datetime.datetime(2020, 2, 11, 15, 2, 0, tzinfo=pytz.timezone('UTC')), 84 | datetime.datetime(2020, 2, 11, 16, 2, 0, tzinfo=pytz.timezone('UTC')), 85 | datetime.datetime(2020, 2, 11, 17, 2, 0, tzinfo=pytz.timezone('UTC')), 86 | datetime.datetime(2020, 2, 11, 18, 2, 0, tzinfo=pytz.timezone('UTC')), 87 | datetime.datetime(2020, 2, 11, 19, 2, 0, tzinfo=pytz.timezone('UTC')), 88 | datetime.datetime(2020, 2, 11, 20, 2, 0, tzinfo=pytz.timezone('UTC')), 89 | datetime.datetime(2020, 2, 11, 21, 2, 0, tzinfo=pytz.timezone('UTC')), 90 | datetime.datetime(2020, 2, 11, 22, 2, 0, tzinfo=pytz.timezone('UTC')), 91 | ], ids=str) 92 | def frozen_2020_02_11_in_paris(request): 93 | """ 94 | fixture setting datetime.now() to every hour of the 11th of february 2020 in Paris 95 | (winter time, GMT+1) 96 | """ 97 | with freeze_time(request.param): 98 | yield 99 | -------------------------------------------------------------------------------- /tests/test_maya.py: -------------------------------------------------------------------------------- 1 | import copy 2 | import time 3 | import calendar 4 | from datetime import timedelta, datetime as Datetime 5 | 6 | import pytz 7 | import pytest 8 | 9 | import maya 10 | from maya.core import _seconds_or_timedelta # import private function 11 | 12 | 13 | @pytest.mark.parametrize( 14 | "string,expected", [("February 21, 1994", "Mon, 21 Feb 1994 00:00:00 GMT")] 15 | ) 16 | def test_rfc2822(string, expected): 17 | r = maya.parse(string).rfc2822() 18 | d = maya.MayaDT.from_rfc2822(r) 19 | assert r == expected 20 | assert r == d.rfc2822() 21 | 22 | 23 | @pytest.mark.parametrize( 24 | "string,expected", [("February 21, 1994", "1994-02-21T00:00:00Z")] 25 | ) 26 | def test_iso8601(string, expected): 27 | r = maya.parse(string).iso8601() 28 | d = maya.MayaDT.from_iso8601(r) 29 | assert r == expected 30 | assert r == d.iso8601() 31 | 32 | 33 | @pytest.mark.parametrize( 34 | "string,expected", 35 | [ 36 | ('January 1, 1970', "12.17.16.7.5"), 37 | ('December 21, 2012', "13.0.0.0.0"), 38 | ('March 4, 1900', "12.14.5.10.0"), 39 | ], 40 | ) 41 | def test_long_count(string, expected): 42 | r = maya.parse(string).long_count() 43 | d = maya.MayaDT.from_long_count(r) 44 | assert r == expected 45 | assert r == d.long_count() 46 | 47 | 48 | @pytest.mark.parametrize( 49 | "string,expected", 50 | [ 51 | ("20161001T1430.4+05:30", "2016-10-01T09:00:00.400000Z"), 52 | ("2016T14", "2016-01-01T14:00:00Z"), 53 | ("2016-10T14", "2016-10-01T14:00:00Z"), 54 | ("2012W05", "2012-01-30T00:00:00Z"), 55 | ("2012W055", "2012-02-03T00:00:00Z"), 56 | ("2012007", "2012-01-07T00:00:00Z"), 57 | ("2016-W07T09", "2016-02-15T09:00:00Z"), 58 | ], 59 | ) 60 | def test_parse_iso8601(string, expected): 61 | d = maya.MayaDT.from_iso8601(string) 62 | assert expected == d.iso8601() 63 | 64 | 65 | @pytest.mark.usefixtures("frozen_now") 66 | def test_struct(): 67 | now = round(time.time()) 68 | ts = time.gmtime(now) 69 | m = maya.MayaDT.from_struct(ts) 70 | dt = Datetime.fromtimestamp(now, pytz.UTC) 71 | assert m._epoch is not None 72 | assert m.datetime() == dt 73 | ts = time.localtime(now) 74 | m = maya.MayaDT.from_struct(ts) 75 | dt = Datetime.fromtimestamp(time.mktime(ts) - maya.core.utc_offset(ts), pytz.UTC) 76 | assert m._epoch is not None 77 | assert m.datetime() == dt 78 | 79 | 80 | def test_issue_104(): 81 | e = 1507756331 82 | t = Datetime.utcfromtimestamp(e) 83 | t = maya.MayaDT.from_datetime(t) 84 | assert str(t) == "Wed, 11 Oct 2017 21:12:11 GMT" 85 | t = time.gmtime(e) 86 | t = maya.MayaDT.from_struct(t) 87 | assert str(t) == "Wed, 11 Oct 2017 21:12:11 GMT" 88 | 89 | def test_before_1970(): 90 | d1 = maya.when("1899-17-11 08:09:10") 91 | assert d1.year == 1899 92 | assert d1.month == 11 93 | assert d1.day == 17 94 | assert d1.week == 46 95 | assert d1.weekday == 5 96 | assert d1.hour == 8 97 | assert d1.minute == 9 98 | assert d1.second == 10 99 | assert d1.microsecond == 0 100 | # Test properties for maya.parse() 101 | d2 = maya.parse("February 29, 1904 13:12:34") 102 | assert d2.year == 1904 103 | assert d2.month == 2 104 | assert d2.day == 29 105 | assert d2.week == 9 106 | assert d2.weekday == 1 107 | assert d2.hour == 13 108 | assert d2.minute == 12 109 | assert d2.second == 34 110 | assert d2.microsecond == 0 111 | 112 | def test_human_when(): 113 | r1 = maya.when("yesterday") 114 | r2 = maya.when("today") 115 | assert (r2.day - r1.day) in (1, -30, -29, -28, -27) 116 | 117 | 118 | @pytest.mark.usefixtures("frozen_2020_08_11_in_paris") 119 | def test_human_when_today_with_timezone_summer_time(): 120 | d = maya.when("today", timezone='Europe/Paris') 121 | assert str(d.datetime(to_timezone="Europe/Paris").date()) == '2020-08-11' 122 | 123 | 124 | @pytest.mark.usefixtures("frozen_2020_02_11_in_paris") 125 | def test_human_when_today_with_timezone_winter_time(): 126 | d = maya.when("today", timezone='Europe/Paris') 127 | assert str(d.datetime(to_timezone="Europe/Paris").date()) == '2020-02-11' 128 | 129 | 130 | @pytest.mark.usefixtures("frozen_2020_08_11_in_paris") 131 | def test_human_when_yesterday_with_timezone_summer_time(): 132 | d = maya.when("yesterday", timezone='Europe/Paris') 133 | assert str(d.datetime(to_timezone="Europe/Paris").date()) == '2020-08-10' 134 | 135 | 136 | @pytest.mark.usefixtures("frozen_2020_02_11_in_paris") 137 | def test_human_when_yesterday_with_timezone_winter_time(): 138 | d = maya.when("yesterday", timezone='Europe/Paris') 139 | assert str(d.datetime(to_timezone="Europe/Paris").date()) == '2020-02-10' 140 | 141 | 142 | @pytest.mark.usefixtures("frozen_2020_08_11_in_paris") 143 | def test_human_when_midnight_with_timezone_summer_time(): 144 | d = maya.when("midnight", timezone='Europe/Paris') 145 | assert str(d.datetime(to_timezone="Europe/Paris")) == '2020-08-11 00:00:00+02:00' 146 | 147 | 148 | @pytest.mark.usefixtures("frozen_2020_02_11_in_paris") 149 | def test_human_when_midnight_with_timezone_winter_time(): 150 | d = maya.when("midnight", timezone='Europe/Paris') 151 | assert str(d.datetime(to_timezone="Europe/Paris")) == '2020-02-11 00:00:00+01:00' 152 | 153 | 154 | def test_machine_parse(): 155 | r1 = maya.parse("August 14, 2015") 156 | assert r1.day == 14 157 | r2 = maya.parse("August 15, 2015") 158 | assert r2.day == 15 159 | 160 | 161 | @pytest.mark.usefixtures("frozen_now") 162 | def test_dt_tz_translation(): 163 | d1 = maya.now().datetime() 164 | d2 = maya.now().datetime(to_timezone="EST") 165 | assert (d1.hour - d2.hour) % 24 == 5 166 | 167 | 168 | @pytest.mark.usefixtures("frozen_now") 169 | def test_dt_tz_naive(): 170 | d1 = maya.now().datetime(naive=True) 171 | assert d1.tzinfo is None 172 | d2 = maya.now().datetime(to_timezone="EST", naive=True) 173 | assert d2.tzinfo is None 174 | assert (d1.hour - d2.hour) % 24 == 5 175 | 176 | 177 | def test_random_date(): 178 | # Test properties for maya.when() 179 | d1 = maya.when("11-17-11 08:09:10") 180 | assert d1.year == 2011 181 | assert d1.month == 11 182 | assert d1.day == 17 183 | assert d1.week == 46 184 | assert d1.weekday == 4 185 | assert d1.hour == 8 186 | assert d1.minute == 9 187 | assert d1.second == 10 188 | assert d1.microsecond == 0 189 | # Test properties for maya.parse() 190 | d2 = maya.parse("February 29, 1992 13:12:34") 191 | assert d2.year == 1992 192 | assert d2.month == 2 193 | assert d2.day == 29 194 | assert d2.week == 9 195 | assert d2.weekday == 6 196 | assert d2.hour == 13 197 | assert d2.minute == 12 198 | assert d2.second == 34 199 | assert d2.microsecond == 0 200 | 201 | 202 | def test_print_date(capsys): 203 | d = maya.when("11-17-11") 204 | print(d) 205 | out, err = capsys.readouterr() 206 | assert out == "Thu, 17 Nov 2011 00:00:00 GMT\n" 207 | assert repr(d) == "" 208 | 209 | 210 | def test_invalid_date(): 211 | with pytest.raises(ValueError): 212 | maya.when("another day") 213 | 214 | 215 | def test_slang_date(): 216 | d = maya.when("tomorrow") 217 | assert d.slang_date() == "tomorrow" 218 | 219 | 220 | def test_slang_date_locale(): 221 | d = maya.when("tomorrow") 222 | assert d.slang_date(locale="fr") == "demain" 223 | 224 | 225 | def test_slang_time(): 226 | d = maya.when("1 hour ago") 227 | assert d.slang_time() == "1 hour ago" 228 | 229 | 230 | def test_slang_time_locale(): 231 | d = maya.when("1 hour ago") 232 | assert d.slang_time(locale="de") == "vor 1 Stunde" 233 | 234 | 235 | @pytest.mark.parametrize( 236 | "string,kwds,expected", 237 | [ 238 | ("February 21, 1994", {}, "1994-02-21 00:00:00+00:00"), 239 | ("01/05/2016", {}, "2016-01-05 00:00:00+00:00"), 240 | ("01/05/2016", dict(day_first=True), "2016-05-01 00:00:00+00:00"), 241 | ( 242 | "2016/05/01", 243 | dict(year_first=True, day_first=False), 244 | "2016-05-01 00:00:00+00:00", 245 | ), 246 | ( 247 | "2016/01/05", 248 | dict(year_first=True, day_first=True), 249 | "2016-05-01 00:00:00+00:00", 250 | ), 251 | ("01/05/2016", dict(timezone="UTC"), "2016-01-05 00:00:00+00:00"), 252 | ("01/05/2016", dict(timezone="US/Central"), "2016-01-05 06:00:00+00:00"), 253 | ], 254 | ) 255 | def test_parse(string, kwds, expected): 256 | d = maya.parse(string, **kwds) 257 | assert format(d) == expected 258 | 259 | 260 | @pytest.mark.usefixtures("frozen_now") 261 | def test_when_past(): 262 | two_days_away = maya.now().add(days=2) 263 | 264 | past_date = maya.when(two_days_away.slang_date(), prefer_dates_from="past") 265 | 266 | assert past_date < maya.now() 267 | 268 | 269 | @pytest.mark.usefixtures("frozen_now") 270 | def test_when_future(): 271 | two_days_away = maya.now().add(days=2) 272 | 273 | future_date = maya.when(two_days_away.slang_date(), prefer_dates_from="future") 274 | 275 | assert future_date > maya.now() 276 | 277 | 278 | @pytest.mark.usefixtures("frozen_now") 279 | def test_when_past_day_name(): 280 | two_days_away = maya.now().add(days=2) 281 | 282 | past_date = maya.when( 283 | calendar.day_name[two_days_away.weekday], prefer_dates_from="past" 284 | ) 285 | 286 | assert past_date < maya.now() 287 | 288 | 289 | @pytest.mark.usefixtures("frozen_now") 290 | def test_when_future_day_name(): 291 | two_days_away = maya.now().add(days=2) 292 | 293 | future_date = maya.when( 294 | calendar.day_name[two_days_away.weekday], prefer_dates_from="future" 295 | ) 296 | 297 | assert future_date > maya.now() 298 | 299 | 300 | def test_datetime_to_timezone(): 301 | dt = maya.when("2016-01-01").datetime(to_timezone="US/Eastern") 302 | assert dt.tzinfo.zone == "US/Eastern" 303 | 304 | 305 | def test_rfc3339_epoch(): 306 | mdt = maya.when("2016-01-01") 307 | out = mdt.rfc3339() 308 | mdt2 = maya.MayaDT.from_rfc3339(out) 309 | assert mdt.epoch == mdt2.epoch 310 | 311 | 312 | def test_rfc3339_format(): 313 | rfc3339 = maya.MayaDT.rfc3339(maya.when("2016-01-01T12:03:03Z")) 314 | # it's important that the string has got a "max 1-digit millis" fragment 315 | # as per https://tools.ietf.org/html/rfc3339#section-5.6 316 | assert rfc3339 == "2016-01-01T12:03:03.0Z" 317 | 318 | 319 | @pytest.mark.usefixtures("frozen_now") 320 | def test_comparison_operations(): 321 | now = maya.now() 322 | now_copy = copy.deepcopy(now) 323 | tomorrow = maya.when("tomorrow") 324 | assert (now == now_copy) is True 325 | assert (now == tomorrow) is False 326 | assert (now != now_copy) is False 327 | assert (now != tomorrow) is True 328 | assert (now < now_copy) is False 329 | assert (now < tomorrow) is True 330 | assert (now <= now_copy) is True 331 | assert (now <= tomorrow) is True 332 | assert (now > now_copy) is False 333 | assert (now > tomorrow) is False 334 | assert (now >= now_copy) is True 335 | assert (now >= tomorrow) is False 336 | # Check Exceptions 337 | with pytest.raises(TypeError): 338 | now == 1 339 | with pytest.raises(TypeError): 340 | now != 1 341 | with pytest.raises(TypeError): 342 | now < 1 343 | with pytest.raises(TypeError): 344 | now <= 1 345 | with pytest.raises(TypeError): 346 | now > 1 347 | with pytest.raises(TypeError): 348 | now >= 1 349 | 350 | 351 | def test_seconds_or_timedelta(): 352 | # test for value in seconds 353 | assert _seconds_or_timedelta(1234) == timedelta(0, 1234) 354 | # test for value as `datetime.timedelta` 355 | assert _seconds_or_timedelta(timedelta(0, 1234)) == timedelta(0, 1234) 356 | # test for invalid value 357 | with pytest.raises(TypeError): 358 | _seconds_or_timedelta("invalid interval") 359 | 360 | 361 | @pytest.mark.usefixtures("frozen_now") 362 | def test_intervals(): 363 | now = maya.now() 364 | tomorrow = now.add(days=1) 365 | assert len(list(maya.intervals(now, tomorrow, 60 * 60))) == 24 366 | 367 | 368 | @pytest.mark.usefixtures("frozen_now") 369 | def test_dunder_add(): 370 | now = maya.now() 371 | assert now + 1 == now.add(seconds=1) 372 | assert now + timedelta(seconds=1) == now.add(seconds=1) 373 | 374 | 375 | @pytest.mark.usefixtures("frozen_now") 376 | def test_dunder_radd(): 377 | now = maya.now() 378 | assert now.add(seconds=1) == now + 1 379 | assert now.add(seconds=1) == now + timedelta(seconds=1) 380 | 381 | 382 | @pytest.mark.usefixtures("frozen_now") 383 | def test_dunder_sub(): 384 | now = maya.now() 385 | assert now - 1 == now.subtract(seconds=1) 386 | assert now - timedelta(seconds=1) == now.subtract(seconds=1) 387 | 388 | 389 | @pytest.mark.usefixtures("frozen_now") 390 | def test_mayaDT_sub(): 391 | now = maya.now() 392 | then = now.add(days=1) 393 | assert then - now == timedelta(seconds=24 * 60 * 60) 394 | assert now - then == timedelta(seconds=-24 * 60 * 60) 395 | 396 | 397 | def test_core_local_timezone(monkeypatch): 398 | @property 399 | def mock_local_tz(self): 400 | class StaticTzInfo(object): 401 | zone = "local" 402 | 403 | def __repr__(self): 404 | return "" 405 | 406 | return StaticTzInfo() 407 | 408 | monkeypatch.setattr(maya.MayaDT, "_local_tz", mock_local_tz) 409 | mdt = maya.MayaDT(0) 410 | assert mdt.local_timezone == "UTC" 411 | 412 | 413 | def test_getting_datetime_for_local_timezone(monkeypatch): 414 | @property 415 | def mock_local_tz(self): 416 | class StaticTzInfo(object): 417 | zone = "Europe/Zurich" 418 | 419 | def __repr__(self): 420 | return "" 421 | 422 | return StaticTzInfo() 423 | 424 | monkeypatch.setattr(maya.MayaDT, "_local_tz", mock_local_tz) 425 | 426 | d = maya.parse("1994-02-21T12:00:00+05:30") 427 | 428 | dt = pytz.timezone("Europe/Zurich").localize(Datetime(1994, 2, 21, 7, 30)) 429 | 430 | assert d.local_datetime() == dt 431 | 432 | 433 | @pytest.mark.parametrize( 434 | "when_str,snap_str,expected_when", 435 | [("Mon, 21 Feb 1994 21:21:42 GMT", "@d", "Mon, 21 Feb 1994 00:00:00 GMT")], 436 | ) 437 | def test_snaptime(when_str, snap_str, expected_when): 438 | # given 439 | dt = maya.when(when_str) 440 | # when 441 | dt = dt.snap(snap_str) 442 | # then 443 | assert dt == maya.when(expected_when) 444 | 445 | 446 | @pytest.mark.parametrize( 447 | "when_str,snap_str,timezone,expected_when", 448 | [ 449 | ( 450 | "Mon, 21 Feb 1994 21:21:42 GMT", 451 | "@d", 452 | "Australia/Perth", 453 | "Mon, 21 Feb 1994 16:00:00 GMT", 454 | ) 455 | ], 456 | ) 457 | def test_snaptime_tz(when_str, snap_str, timezone, expected_when): 458 | # given 459 | dt = maya.when(when_str) 460 | # when 461 | dt = dt.snap_tz(snap_str, timezone) 462 | # then 463 | assert dt == maya.when(expected_when) 464 | -------------------------------------------------------------------------------- /tests/test_maya_interval.py: -------------------------------------------------------------------------------- 1 | import random 2 | from datetime import datetime, timedelta 3 | 4 | import pytest 5 | import pytz 6 | 7 | import maya 8 | from maya.compat import cmp 9 | 10 | Los_Angeles = pytz.timezone("America/Los_Angeles") 11 | New_York = pytz.timezone("America/New_York") 12 | Melbourne = pytz.timezone("Australia/Melbourne") 13 | 14 | 15 | def test_interval_requires_2_of_start_end_duration(): 16 | start = maya.now() 17 | end = start.add(hours=1) 18 | with pytest.raises(ValueError): 19 | maya.MayaInterval(start=start) 20 | with pytest.raises(ValueError): 21 | maya.MayaInterval(end=end) 22 | with pytest.raises(ValueError): 23 | maya.MayaInterval(duration=60) 24 | with pytest.raises(ValueError): 25 | maya.MayaInterval(start=start, end=end, duration=60) 26 | maya.MayaInterval(start=start, end=end) 27 | maya.MayaInterval(start=start, duration=60) 28 | maya.MayaInterval(end=end, duration=60) 29 | 30 | 31 | def test_interval_requires_end_time_after_or_on_start_time(): 32 | with pytest.raises(ValueError): 33 | maya.MayaInterval(start=maya.now(), duration=0) 34 | maya.MayaInterval(start=maya.now(), duration=-1) 35 | 36 | 37 | def test_interval_init_start_end(): 38 | start = maya.now() 39 | end = start.add(hours=1) 40 | interval = maya.MayaInterval(start=start, end=end) 41 | assert interval.start == start 42 | assert interval.end == end 43 | 44 | 45 | def test_interval_init_start_duration(): 46 | start = maya.now() 47 | duration = 1 48 | interval = maya.MayaInterval(start=start, duration=duration) 49 | assert interval.start == start 50 | assert interval.end == start.add(seconds=duration) 51 | 52 | 53 | def test_interval_init_end_duration(): 54 | end = maya.now() 55 | duration = 1 56 | interval = maya.MayaInterval(end=end, duration=duration) 57 | assert interval.end == end 58 | assert interval.start == end.subtract(seconds=duration) 59 | 60 | 61 | @pytest.mark.parametrize( 62 | "start_doy1,end_doy1,start_doy2,end_doy2,intersection_doys", 63 | ( 64 | (0, 2, 1, 3, (1, 2)), 65 | (0, 2, 3, 4, None), 66 | (0, 2, 2, 3, None), 67 | (0, 1, 0, 1, (0, 1)), 68 | (1, 1, 1, 3, (1, 1)), 69 | (1, 1, 1, 1, (1, 1)), 70 | (1, 1, 2, 3, None), 71 | (2, 2, 1, 3, (2, 2)), 72 | (1, 3, 1, 1, (1, 1)), 73 | (2, 3, 1, 1, None), 74 | (1, 3, 2, 2, (2, 2)), 75 | ), 76 | ids=( 77 | "overlapping", 78 | "non-overlapping", 79 | "adjacent", 80 | "equal", 81 | "instant overlapping start only", 82 | "instant equal", 83 | "instant disjoint", 84 | "instant overlapping", 85 | "instant overlapping start only (left)", 86 | "instant disjoint (left)", 87 | "instant overlapping (left)", 88 | ), 89 | ) 90 | def test_interval_intersection( 91 | start_doy1, end_doy1, start_doy2, end_doy2, intersection_doys 92 | ): 93 | base = maya.MayaDT.from_datetime(datetime(2016, 1, 1)) 94 | interval1 = maya.MayaInterval(base.add(days=start_doy1), base.add(days=end_doy1)) 95 | interval2 = maya.MayaInterval(base.add(days=start_doy2), base.add(days=end_doy2)) 96 | if intersection_doys: 97 | start_doy_intersection, end_doy_intersection = intersection_doys 98 | assert interval1 & interval2 == maya.MayaInterval( 99 | base.add(days=start_doy_intersection), base.add(days=end_doy_intersection) 100 | ) 101 | else: 102 | assert (interval1 & interval2) is None 103 | # check invalid argument 104 | with pytest.raises(TypeError): 105 | interval1 & "invalid type" 106 | 107 | 108 | def test_interval_intersects(): 109 | base = maya.MayaDT.from_datetime(datetime(2016, 1, 1)) 110 | interval = maya.MayaInterval(base, base.add(days=1)) 111 | assert interval.intersects(interval) 112 | assert not interval.intersects( 113 | maya.MayaInterval(base.add(days=2), base.add(days=3)) 114 | ) 115 | # check invalid argument 116 | with pytest.raises(TypeError): 117 | interval.intersects("invalid type") 118 | 119 | 120 | def test_and_operator(): 121 | base = maya.MayaDT.from_datetime(datetime(2016, 1, 1)) 122 | interval1 = maya.MayaInterval(base, base.add(days=2)) 123 | interval2 = maya.MayaInterval(base.add(days=1), base.add(days=3)) 124 | assert ( 125 | interval1 & interval2 126 | == interval2 & interval1 # noqa 127 | == interval1.intersection(interval2) # noqa 128 | ) 129 | # check invalid argument 130 | with pytest.raises(TypeError): 131 | interval1.intersection("invalid type") 132 | 133 | 134 | def test_interval_eq_operator(): 135 | start = maya.now() 136 | end = start.add(hours=1) 137 | interval = maya.MayaInterval(start=start, end=end) 138 | assert interval == maya.MayaInterval(start=start, end=end) 139 | assert interval != maya.MayaInterval(start=start, end=end.add(days=1)) 140 | # check invalid argument 141 | with pytest.raises(TypeError): 142 | interval == "invalid type" 143 | with pytest.raises(TypeError): 144 | interval != "invalid type" 145 | 146 | 147 | def test_interval_timedelta(): 148 | start = maya.now() 149 | delta = timedelta(hours=1) 150 | interval = maya.MayaInterval(start=start, duration=delta) 151 | assert interval.timedelta == delta 152 | 153 | 154 | def test_interval_duration(): 155 | start = maya.now() 156 | delta = timedelta(hours=1) 157 | interval = maya.MayaInterval(start=start, duration=delta) 158 | assert interval.duration == delta.total_seconds() 159 | 160 | 161 | @pytest.mark.parametrize( 162 | "start_doy1,end_doy1,start_doy2,end_doy2,expected", 163 | ( 164 | (0, 2, 1, 3, False), 165 | (0, 2, 3, 4, False), 166 | (0, 2, 2, 3, False), 167 | (0, 1, 0, 1, True), 168 | (0, 3, 1, 2, True), 169 | ), 170 | ids=("overlapping", "non-overlapping", "adjacent", "equal", "subset"), 171 | ) 172 | def test_interval_contains(start_doy1, end_doy1, start_doy2, end_doy2, expected): 173 | base = maya.MayaDT.from_datetime(datetime(2016, 1, 1)) 174 | interval1 = maya.MayaInterval(base.add(days=start_doy1), base.add(days=end_doy1)) 175 | interval2 = maya.MayaInterval(base.add(days=start_doy2), base.add(days=end_doy2)) 176 | assert interval1.contains(interval2) is expected 177 | assert (interval2 in interval1) is expected 178 | # check invalid argument 179 | with pytest.raises(TypeError): 180 | interval1.contains("invalid type") 181 | 182 | 183 | @pytest.mark.parametrize( 184 | "start_doy,end_doy,dt_doy,expected", 185 | ( 186 | (2, 4, 1, False), 187 | (2, 4, 2, True), 188 | (2, 4, 3, True), 189 | (2, 4, 4, False), 190 | (2, 4, 5, False), 191 | ), 192 | ids=("before-start", "on-start", "during", "on-end", "after-end"), 193 | ) 194 | def test_interval_in_operator_maya_dt(start_doy, end_doy, dt_doy, expected): 195 | base = maya.MayaDT.from_datetime(datetime(2016, 1, 1)) 196 | interval = maya.MayaInterval( 197 | start=base.add(days=start_doy), end=base.add(days=end_doy) 198 | ) 199 | dt = base.add(days=dt_doy) 200 | assert (dt in interval) is expected 201 | # check invalid argument 202 | with pytest.raises(TypeError): 203 | "invalid type" in interval 204 | 205 | 206 | def test_interval_hash(): 207 | start = maya.now() 208 | end = start.add(hours=1) 209 | interval = maya.MayaInterval(start=start, end=end) 210 | assert hash(interval) == hash(maya.MayaInterval(start=start, end=end)) 211 | assert hash(interval) != hash(maya.MayaInterval(start=start, end=end.add(days=1))) 212 | 213 | 214 | def test_interval_iter(): 215 | start = maya.now() 216 | end = start.add(days=1) 217 | assert tuple(maya.MayaInterval(start=start, end=end)) == (start, end) 218 | 219 | 220 | @pytest.mark.parametrize( 221 | "start1,end1,start2,end2,expected", 222 | [(1, 2, 1, 2, 0), (1, 3, 2, 4, -1), (2, 4, 1, 3, 1), (1, 2, 1, 3, -1)], 223 | ids=("equal", "less-than", "greater-than", "use-end-time-if-start-time-identical"), 224 | ) 225 | def test_interval_cmp(start1, end1, start2, end2, expected): 226 | base = maya.now() 227 | interval1 = maya.MayaInterval(start=base.add(days=start1), end=base.add(days=end1)) 228 | interval2 = maya.MayaInterval(start=base.add(days=start2), end=base.add(days=end2)) 229 | assert cmp(interval1, interval2) == expected 230 | # check invalid argument 231 | with pytest.raises(TypeError): 232 | cmp(interval1, "invalid type") 233 | 234 | 235 | @pytest.mark.parametrize( 236 | "start1,end1,start2,end2,expected", 237 | [ 238 | (1, 2, 2, 3, [(1, 3)]), 239 | (1, 3, 2, 4, [(1, 4)]), 240 | (1, 2, 3, 4, [(1, 2), (3, 4)]), 241 | (1, 5, 2, 3, [(1, 5)]), 242 | ], 243 | ids=("adjacent", "overlapping", "non-overlapping", "contains"), 244 | ) 245 | def test_interval_combine(start1, end1, start2, end2, expected): 246 | base = maya.now() 247 | interval1 = maya.MayaInterval(start=base.add(days=start1), end=base.add(days=end1)) 248 | interval2 = maya.MayaInterval(start=base.add(days=start2), end=base.add(days=end2)) 249 | expected_intervals = [ 250 | maya.MayaInterval(start=base.add(days=start), end=base.add(days=end)) 251 | for start, end in expected 252 | ] 253 | assert interval1.combine(interval2) == expected_intervals 254 | assert interval2.combine(interval1) == expected_intervals 255 | # check invalid argument 256 | with pytest.raises(TypeError): 257 | interval2.combine("invalid type") 258 | 259 | 260 | @pytest.mark.parametrize( 261 | "start1,end1,start2,end2,expected", 262 | [ 263 | (1, 2, 3, 4, [(1, 2)]), 264 | (1, 2, 2, 4, [(1, 2)]), 265 | (2, 3, 1, 4, []), 266 | (1, 4, 2, 3, [(1, 2), (3, 4)]), 267 | (1, 4, 0, 2, [(2, 4)]), 268 | (1, 4, 3, 5, [(1, 3)]), 269 | (1, 4, 1, 2, [(2, 4)]), 270 | (1, 4, 3, 4, [(1, 3)]), 271 | ], 272 | ids=( 273 | "non-overlapping", 274 | "adjacent", 275 | "contains", 276 | "splits", 277 | "overlaps-left", 278 | "overlaps-right", 279 | "overlaps-left-identical-start", 280 | "overlaps-right-identical-end", 281 | ), 282 | ) 283 | def test_interval_subtract(start1, end1, start2, end2, expected): 284 | base = maya.now() 285 | interval1 = maya.MayaInterval(start=base.add(days=start1), end=base.add(days=end1)) 286 | interval2 = maya.MayaInterval(start=base.add(days=start2), end=base.add(days=end2)) 287 | expected_intervals = [ 288 | maya.MayaInterval(start=base.add(days=start), end=base.add(days=end)) 289 | for start, end in expected 290 | ] 291 | assert interval1.subtract(interval2) == expected_intervals 292 | # check invalid argument 293 | with pytest.raises(TypeError): 294 | interval1.subtract("invalid type") 295 | 296 | 297 | @pytest.mark.parametrize( 298 | "start1,end1,start2,end2,expected", 299 | [(1, 2, 2, 3, True), (2, 3, 1, 2, True), (1, 3, 2, 3, False), (2, 3, 4, 5, False)], 300 | ids=("adjacent-right", "adjacent-left", "overlapping", "non-overlapping"), 301 | ) 302 | def test_interval_is_adjacent(start1, end1, start2, end2, expected): 303 | base = maya.now() 304 | interval1 = maya.MayaInterval(start=base.add(days=start1), end=base.add(days=end1)) 305 | interval2 = maya.MayaInterval(start=base.add(days=start2), end=base.add(days=end2)) 306 | assert interval1.is_adjacent(interval2) == expected 307 | # check invalid argument 308 | with pytest.raises(TypeError): 309 | interval1.is_adjacent("invalid type") 310 | 311 | 312 | @pytest.mark.parametrize( 313 | "start,end,delta,include_remainder,expected", 314 | [ 315 | (0, 10, 5, False, [(0, 5), (5, 10)]), 316 | (0, 10, 5, True, [(0, 5), (5, 10)]), 317 | (0, 10, 3, False, [(0, 3), (3, 6), (6, 9)]), 318 | (0, 10, 3, True, [(0, 3), (3, 6), (6, 9), (9, 10)]), 319 | (0, 2, 5, False, []), 320 | (0, 2, 5, True, [(0, 2)]), 321 | ], 322 | ids=( 323 | "even-split", 324 | "even-split-include-partial", 325 | "uneven-split-do-not-include-partial", 326 | "uneven-split-include-partial", 327 | "delta-larger-than-timepsan-do-not-include-partial", 328 | "delta-larger-than-timepsan-include-partial", 329 | ), 330 | ) 331 | def test_interval_split(start, end, delta, include_remainder, expected): 332 | base = maya.now() 333 | interval = maya.MayaInterval(start=base.add(days=start), end=base.add(days=end)) 334 | delta = timedelta(days=delta) 335 | expected_intervals = [ 336 | maya.MayaInterval(start=base.add(days=s), end=base.add(days=e)) 337 | for s, e in expected 338 | ] 339 | assert expected_intervals == list( 340 | interval.split(delta, include_remainder=include_remainder) 341 | ) 342 | 343 | 344 | def test_interval_split_non_positive_delta(): 345 | start = maya.now() 346 | end = start.add(days=1) 347 | interval = maya.MayaInterval(start=start, end=end) 348 | with pytest.raises(ValueError): 349 | list(interval.split(timedelta(seconds=0))) 350 | with pytest.raises(ValueError): 351 | list(interval.split(timedelta(seconds=-10))) 352 | 353 | 354 | @pytest.mark.parametrize( 355 | "start,end,minutes,timezone,snap_out,expected_start,expected_end", 356 | [ 357 | ((5, 12), (8, 48), 30, None, False, (5, 30), (8, 30)), 358 | ((5, 12), (8, 48), 30, None, True, (5, 0), (9, 0)), 359 | ((5, 15), (9, 0), 15, None, False, (5, 15), (9, 0)), 360 | ((5, 15), (9, 0), 15, None, True, (5, 15), (9, 0)), 361 | ((6, 50), (9, 15), 60, "America/New_York", False, (7, 0), (9, 0)), 362 | ((6, 50), (9, 15), 60, "America/New_York", True, (6, 0), (10, 0)), 363 | ((6, 20), (6, 50), 60, None, False, (6, 0), (6, 0)), 364 | ((6, 20), (6, 50), 60, None, True, (6, 0), (7, 0)), 365 | ((6, 20), (6, 50), 60, "America/Chicago", False, (6, 0), (6, 0)), 366 | ((6, 20), (6, 50), 60, "America/Chicago", True, (6, 0), (7, 0)), 367 | ], 368 | ids=( 369 | "normal", 370 | "normal-snap_out", 371 | "already-quantized", 372 | "already-quantized-snap_out", 373 | "with-timezone", 374 | "with-timezone-snap_out", 375 | "too-small", 376 | "too-small-snap_out", 377 | "too-small-with-timezone", 378 | "too-small-with-timezone-snap_out", 379 | ), 380 | ) 381 | def test_quantize( 382 | start, end, minutes, timezone, snap_out, expected_start, expected_end 383 | ): 384 | base = maya.MayaDT.from_datetime(datetime(2017, 1, 1)) 385 | interval = maya.MayaInterval( 386 | start=base.add(hours=start[0], minutes=start[1]), 387 | end=base.add(hours=end[0], minutes=end[1]), 388 | ) 389 | kwargs = {"timezone": timezone} if timezone is not None else {} 390 | quantized_interval = interval.quantize( 391 | timedelta(minutes=minutes), snap_out=snap_out, **kwargs 392 | ) 393 | assert quantized_interval == maya.MayaInterval( 394 | start=base.add(hours=expected_start[0], minutes=expected_start[1]), 395 | end=base.add(hours=expected_end[0], minutes=expected_end[1]), 396 | ) 397 | 398 | 399 | def test_quantize_invalid_delta(): 400 | start = maya.now() 401 | end = start.add(days=1) 402 | interval = maya.MayaInterval(start=start, end=end) 403 | with pytest.raises(ValueError): 404 | interval.quantize(timedelta(minutes=0)) 405 | with pytest.raises(ValueError): 406 | interval.quantize(timedelta(minutes=-1)) 407 | 408 | 409 | def test_interval_flatten_non_overlapping(): 410 | step = 2 411 | max_hour = 20 412 | base = maya.now() 413 | intervals = [ 414 | maya.MayaInterval( 415 | start=base.add(hours=hour), duration=timedelta(hours=step - 1) 416 | ) 417 | for hour in range(0, max_hour, step) 418 | ] 419 | random.shuffle(intervals) 420 | assert maya.MayaInterval.flatten(intervals) == sorted(intervals) 421 | 422 | 423 | def test_interval_flatten_adjacent(): 424 | step = 2 425 | max_hour = 20 426 | base = maya.when("jan/1/2011") 427 | intervals = [ 428 | maya.MayaInterval(start=base.add(hours=hour), duration=timedelta(hours=step)) 429 | for hour in range(0, max_hour, step) 430 | ] 431 | random.shuffle(intervals) 432 | assert maya.MayaInterval.flatten(intervals) == [ 433 | maya.MayaInterval(start=base, duration=timedelta(hours=max_hour)) 434 | ] 435 | 436 | 437 | def test_interval_flatten_intersecting(): 438 | step = 2 439 | max_hour = 20 440 | base = maya.now() 441 | intervals = [ 442 | maya.MayaInterval( 443 | start=base.add(hours=hour), duration=timedelta(hours=step, minutes=30) 444 | ) 445 | for hour in range(0, max_hour, step) 446 | ] 447 | random.shuffle(intervals) 448 | assert maya.MayaInterval.flatten(intervals) == [ 449 | maya.MayaInterval(start=base, duration=timedelta(hours=max_hour, minutes=30)) 450 | ] 451 | 452 | 453 | def test_interval_flatten_containing(): 454 | step = 2 455 | max_hour = 20 456 | base = maya.now() 457 | containing_interval = maya.MayaInterval( 458 | start=base, end=base.add(hours=max_hour + step) 459 | ) 460 | intervals = [ 461 | maya.MayaInterval( 462 | start=base.add(hours=hour), duration=timedelta(hours=step - 1) 463 | ) 464 | for hour in range(2, max_hour, step) 465 | ] 466 | intervals.append(containing_interval) 467 | random.shuffle(intervals) 468 | assert maya.MayaInterval.flatten(intervals) == [containing_interval] 469 | 470 | 471 | def test_interval_from_datetime(): 472 | start = maya.now() 473 | duration = timedelta(hours=1) 474 | end = start + duration 475 | interval = maya.MayaInterval.from_datetime( 476 | start_dt=start.datetime(naive=False), end_dt=end.datetime(naive=False) 477 | ) 478 | assert interval.start == start 479 | assert interval.end == end 480 | interval2 = maya.MayaInterval.from_datetime( 481 | start_dt=start.datetime(naive=False), duration=duration 482 | ) 483 | assert interval2.start == start 484 | assert interval2.end == end 485 | interval3 = maya.MayaInterval.from_datetime( 486 | end_dt=end.datetime(naive=False), duration=duration 487 | ) 488 | assert interval3.start == start 489 | assert interval3.end == end 490 | 491 | 492 | def test_interval_iso8601(): 493 | start = maya.when("11-17-11 08:09:10") 494 | interval = maya.MayaInterval(start=start, duration=1) 495 | assert interval.iso8601() == "2011-11-17T08:09:10Z/2011-11-17T08:09:11Z" 496 | 497 | 498 | def test_interval_from_iso8601(): 499 | interval = maya.MayaInterval.from_iso8601( 500 | "2018-03-18T14:27:18Z/2018-04-01T04:15:27Z" 501 | ) 502 | s = maya.when("2018-03-18T14:27:18Z") 503 | e = maya.when("2018-04-01T04:15:27Z") 504 | 505 | assert interval.start == s 506 | assert interval.end == e 507 | 508 | 509 | def test_interval_from_iso8601_duration(): 510 | interval = maya.MayaInterval.from_iso8601("2018-03-18T14:27:18Z/P13DT13H48M9S") 511 | s = maya.when("2018-03-18T14:27:18Z") 512 | e = maya.when("2018-04-01T04:15:27Z") 513 | 514 | assert interval.start == s 515 | assert interval.end == e 516 | 517 | interval = maya.MayaInterval.from_iso8601("2018-03-05T14:27:18Z/P2W") 518 | s = maya.when("2018-03-05T14:27:18Z") 519 | e = maya.when("2018-03-19T14:27:18Z") 520 | 521 | assert interval.start == s 522 | assert interval.end == e 523 | 524 | 525 | @pytest.mark.parametrize( 526 | "start_string,end_string,interval,expected_count", 527 | [ 528 | ("2019-01-03 11:40:00Z", "2019-01-03 11:40:20Z", 2, 10), 529 | ("2019-01-03 11:40:00Z", "2019-01-03 11:40:30Z", timedelta(seconds=2), 15), 530 | ("2019-01-03 11:40:00Z", "2019-01-03 11:45:00Z", 2 * 60, 3), 531 | ("2019-01-03 11:40:00Z", "2019-01-03 11:51:00Z", timedelta(minutes=1), 11), 532 | ("2019-01-03 11:40:00Z", "2019-01-03 21:40:00Z", 3 * 60 * 60, 4), 533 | ("2019-01-03 11:40:00Z", "2019-01-03 13:41:00Z", timedelta(hours=1), 3), 534 | ("2019-01-03 11:40:00Z", "2019-01-09 11:40:00Z", 3 * 60 * 60 * 24, 2), 535 | ("2019-01-03 11:40:00Z", "2019-01-05 12:00:00Z", timedelta(days=2), 2), 536 | ], 537 | ids=( 538 | "seconds", 539 | "seconds-timedelta", 540 | "minutes", 541 | "minutes-timedelta", 542 | "hours", 543 | "hours-timedelta", 544 | "days", 545 | "days-timedelta", 546 | ), 547 | ) 548 | def test_intervals(start_string, end_string, interval, expected_count): 549 | start = maya.parse(start_string) 550 | end = maya.parse(end_string) 551 | assert len(list(maya.intervals(start, end, interval))) == expected_count 552 | 553 | 554 | def test_issue_168_regression(): 555 | start = maya.now() 556 | end = start.add(weeks=1) 557 | gen = maya.intervals(start=start, end=end, interval=60 * 60 * 24) 558 | # Since the bug causes the generator to never end, first sanity 559 | # check that two results are not the same. 560 | assert next(gen) != next(gen) 561 | assert len(list(maya.intervals(start=start, end=end, interval=60 * 60 * 24))) == 7 562 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [tox] 2 | envlist = lint,manifest,py27,py35,py36,py37,docs,coverage-report 3 | 4 | 5 | [testenv] 6 | # Prevent random setuptools/pip breakages like 7 | # https://github.com/pypa/setuptools/issues/1042 from breaking our builds. 8 | setenv = 9 | VIRTUALENV_NO_DOWNLOAD=1 10 | extras = {env:TOX_AP_TEST_EXTRAS:tests} 11 | commands = coverage run --parallel -m pytest {posargs} 12 | 13 | 14 | [testenv:coverage-report] 15 | basepython = python3.7 16 | skip_install = true 17 | deps = coverage 18 | commands = 19 | coverage combine 20 | coverage report 21 | 22 | 23 | [testenv:lint] 24 | basepython = python3.7 25 | skip_install = true 26 | deps = pre-commit 27 | passenv = HOMEPATH # needed on Windows 28 | commands = pre-commit run --all-files 29 | 30 | 31 | [testenv:docs] 32 | basepython = python3.7 33 | extras = docs 34 | commands = 35 | sphinx-build -W -b html -d {envtmpdir}/doctrees docs/source docs/_build/html 36 | ;sphinx-build -W -b doctest -d {envtmpdir}/doctrees docs/source docs/_build/html 37 | 38 | 39 | [testenv:manifest] 40 | basepython = python3.7 41 | deps = check-manifest 42 | skip_install = true 43 | commands = check-manifest 44 | --------------------------------------------------------------------------------