├── .flake8 ├── .github └── workflows │ ├── publish.yml │ └── tests.yml ├── .gitignore ├── .pre-commit-config.yaml ├── AUTHORS ├── CONTRIBUTING.md ├── README.md ├── bitmapist ├── __init__.py ├── cohort │ ├── __init__.py │ └── tmpl │ │ ├── form_data.mako │ │ ├── table_data.mako │ │ └── table_data_csv.mako └── py.typed ├── poetry.lock ├── pyproject.toml ├── static ├── bitmapist.png ├── bitmapist.svg ├── bitmapist_logo.sketch └── cohort_screenshot.png └── test ├── __init__.py ├── conftest.py ├── test_bitmapist.py ├── test_cohort.py ├── test_delta_methods.py ├── test_equality.py ├── test_from_date.py ├── test_period_start_end.py └── test_unique_events.py /.flake8: -------------------------------------------------------------------------------- 1 | [flake8] 2 | # Like Black. 3 | max-line-length = 88 4 | 5 | extend-ignore = 6 | # Whitespace before ':': Incompatible with Black 7 | E203, 8 | # Line break occurred before a binary operator: Incompatible with Black, ref.: https://black.readthedocs.io/en/stable/the_black_code_style.html 9 | W503, 10 | # Line too long: Already covered by Black 11 | E501, 12 | # is too complex: declared bankrupcy. 13 | C901, 14 | # we want to use lambda sometimes 15 | E731 16 | -------------------------------------------------------------------------------- /.github/workflows/publish.yml: -------------------------------------------------------------------------------- 1 | name: Publish package to PyPI 2 | 3 | on: 4 | push: 5 | tags: 6 | - 'v*' 7 | workflow_dispatch: 8 | 9 | jobs: 10 | build-test: 11 | runs-on: ubuntu-latest 12 | timeout-minutes: 60 13 | steps: 14 | - uses: actions/checkout@v4 15 | 16 | - name: Set up Python 17 | uses: actions/setup-python@v4 18 | with: 19 | python-version: "3.9" 20 | 21 | - name: Install dependencies 22 | run: | 23 | python -m pip install poetry 24 | 25 | - name: Build and publish to PyPI 26 | env: 27 | POETRY_PYPI_TOKEN_PYPI: ${{ secrets.PYPI_API_TOKEN }} 28 | run: | 29 | poetry publish --build 30 | -------------------------------------------------------------------------------- /.github/workflows/tests.yml: -------------------------------------------------------------------------------- 1 | name: Run test suite 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | pull_request: 8 | 9 | jobs: 10 | test: 11 | runs-on: ubuntu-latest 12 | timeout-minutes: 60 13 | name: "Python ${{ matrix.python-version }}" 14 | strategy: 15 | matrix: 16 | python-version: ['3.9', '3.10', '3.11', '3.12'] 17 | steps: 18 | - name: Checkout Repo 19 | uses: actions/checkout@v4 20 | 21 | - name: Set up Python 22 | uses: actions/setup-python@v4 23 | with: 24 | python-version: ${{ matrix.python-version }} 25 | 26 | - name: Install dependencies 27 | run: | 28 | python -m pip install poetry 29 | poetry install 30 | 31 | - name: Install redis 32 | run: | 33 | sudo apt-get install redis -y 34 | 35 | - name: Run tests 36 | run: poetry run pytest -vv 37 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .*.swp 2 | *.pyc 3 | *.egg-info 4 | /dist 5 | .tox 6 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | repos: 2 | - repo: https://github.com/astral-sh/ruff-pre-commit 3 | rev: v0.4.1 4 | hooks: 5 | # Run the linter 6 | - id: ruff 7 | args: ["--fix"] 8 | # Run the formatter 9 | - id: ruff-format 10 | 11 | - repo: https://github.com/pre-commit/mirrors-mypy 12 | rev: v0.971 13 | hooks: 14 | - id: mypy 15 | additional_dependencies: 16 | - types-python-dateutil 17 | - types-redis 18 | -------------------------------------------------------------------------------- /AUTHORS: -------------------------------------------------------------------------------- 1 | This is the list of developers and contributors to the bitmapist project. 2 | Thanks for helping us making this project better :-) 3 | 4 | - Amir Salihefendic 5 | - Roman Imankulov 6 | - Alexey Subbotin 7 | - Sam Kimbrel 8 | - Egor Yurtaev 9 | - David Cramer 10 | - Dimitris Giannitsaros 11 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing 2 | 3 | Thanks for helping us make Bitmapist work better. 4 | 5 | Please feel free to [report any issue](https://github.com/Doist/bitmapist/issues/new/choose) you may have. 6 | 7 | **Working on your first Pull Request?** You can learn how from this _free_ 8 | series [How to Contribute to an Open Source Project on GitHub][egghead] 9 | 10 | If you want to contribute with code, please open a Pull Request. A few things to keep in mind: 11 | 12 | - The BSD project is released under the [BSD license](./LICENSE) 13 | - Please use [pre-commit](https://pre-commit.com/) to ensure some formatting rules and basic consistency checks are applied before each Git commit 14 | - Please add tests for your changes! 15 | - Please document any changes using the [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) convention 16 | - Changes in a PR should be documented under an `[Unreleased]` version 17 | - We'll bump the version number and update the changelog in a separate PR when publishing 18 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ![bitmapist](https://raw.githubusercontent.com/Doist/bitmapist/master/static/bitmapist.png "bitmapist") 2 | 3 | 4 | [![Build Status](https://travis-ci.org/Doist/bitmapist.svg?branch=master)](https://travis-ci.org/Doist/bitmapist) 5 | 6 | **NEW!** Try out our new standalone [bitmapist-server](https://github.com/Doist/bitmapist-server), which improves memory efficiency 443 times and makes your setup much cheaper to run (and more scaleable). It's fully compatiable with bitmapist that runs on Redis. 7 | 8 | # bitmapist: a powerful analytics library for Redis 9 | 10 | This Python library makes it possible to implement real-time, highly scalable analytics that can answer following questions: 11 | 12 | * Has user 123 been online today? This week? This month? 13 | * Has user 123 performed action "X"? 14 | * How many users have been active this month? This hour? 15 | * How many unique users have performed action "X" this week? 16 | * How many % of users that were active last week are still active? 17 | * How many % of users that were active last month are still active this month? 18 | * What users performed action "X"? 19 | 20 | This library is very easy to use and enables you to create your own reports easily. 21 | 22 | Using Redis bitmaps you can store events for millions of users in a very little amount of memory (megabytes). 23 | You should be careful about using huge ids as this could require larger amounts of memory. Ids should be in range [0, 2^32). 24 | 25 | Additionally bitmapist can generate cohort graphs that can do following: 26 | * Cohort over user retention 27 | * How many % of users that were active last [days, weeks, months] are still active? 28 | * How many % of users that performed action X also performed action Y (and this over time) 29 | * And a lot of other things! 30 | 31 | If you want to read more about bitmaps please read following: 32 | 33 | * http://blog.getspool.com/2011/11/29/fast-easy-realtime-metrics-using-redis-bitmaps/ 34 | * http://redis.io/commands/setbit 35 | * http://en.wikipedia.org/wiki/Bit_array 36 | * http://www.slideshare.net/crashlytics/crashlytics-on-redis-analytics 37 | 38 | 39 | 40 | # Installation 41 | 42 | Can be installed very easily via: 43 | 44 | $ pip install bitmapist 45 | 46 | 47 | # Ports 48 | 49 | * PHP port: https://github.com/jeremyFreeAgent/Bitter 50 | 51 | 52 | # Examples 53 | 54 | Setting things up: 55 | 56 | ```python 57 | from datetime import datetime, timedelta, timezone 58 | from bitmapist import setup_redis, delete_all_events, mark_event,\ 59 | MonthEvents, WeekEvents, DayEvents, HourEvents,\ 60 | BitOpAnd, BitOpOr 61 | 62 | now = datetime.now(tz=timezone.utc) 63 | last_month = now - timedelta(days=30) 64 | ``` 65 | 66 | Mark user 123 as active and has played a song: 67 | 68 | ```python 69 | mark_event('active', 123) 70 | mark_event('song:played', 123) 71 | ``` 72 | 73 | Answer if user 123 has been active this month: 74 | 75 | ```python 76 | assert 123 in MonthEvents('active', now.year, now.month) 77 | assert 123 in MonthEvents('song:played', now.year, now.month) 78 | assert MonthEvents('active', now.year, now.month).has_events_marked() == True 79 | ``` 80 | 81 | 82 | How many users have been active this week?: 83 | 84 | ```python 85 | print(len(WeekEvents('active', now.year, now.isocalendar()[1]))) 86 | ``` 87 | 88 | Iterate over all users active this week: 89 | 90 | ```python 91 | for uid in WeekEvents('active'): 92 | print(uid) 93 | ``` 94 | 95 | 96 | If you're interested in "current events", you can omit extra `now.whatever` 97 | arguments. Events will be populated with current time automatically. 98 | 99 | For example, these two calls are equivalent: 100 | 101 | ```python 102 | 103 | MonthEvents('active') == MonthEvents('active', now.year, now.month) 104 | 105 | ``` 106 | 107 | Additionally, for the sake of uniformity, you can create an event from 108 | any datetime object with a `from_date` static method. 109 | 110 | ```python 111 | 112 | MonthEvents('active').from_date(now) == MonthEvents('active', now.year, now.month) 113 | 114 | ``` 115 | 116 | Get the list of these users (user ids): 117 | 118 | ```python 119 | print(list(WeekEvents('active', now.year, now.isocalendar()[1]))) 120 | ``` 121 | 122 | There are special methods `prev` and `next` returning "sibling" events and 123 | allowing you to walk through events in time without any sophisticated 124 | iterators. A `delta` method allows you to "jump" forward or backward for 125 | more than one step. Uniform API allows you to use all types of base events 126 | (from hour to year) with the same code. 127 | 128 | ```python 129 | 130 | current_month = MonthEvents() 131 | prev_month = current_month.prev() 132 | next_month = current_month.next() 133 | year_ago = current_month.delta(-12) 134 | 135 | ``` 136 | 137 | Every event object has `period_start` and `period_end` methods to find a 138 | time span of the event. This can be useful for caching values when the caching 139 | of "events in future" is not desirable: 140 | 141 | ```python 142 | 143 | ev = MonthEvent('active', dt) 144 | if ev.period_end() < now: 145 | cache.set('active_users_<...>', len(ev)) 146 | 147 | ``` 148 | 149 | 150 | As something new tracking hourly is disabled (to save memory!) To enable it as default do:: 151 | 152 | ```python 153 | import bitmapist 154 | bitmapist.TRACK_HOURLY = True 155 | ``` 156 | 157 | Additionally you can supply an extra argument to `mark_event` to bypass the default value:: 158 | 159 | ```python 160 | mark_event('active', 123, track_hourly=False) 161 | ``` 162 | 163 | 164 | ### Unique events 165 | 166 | Sometimes the date of the event makes little or no sense, for example, 167 | to filter out your premium accounts, or in A/B testing. There is a 168 | `UniqueEvents` model for this purpose. The model creates only one 169 | Redis key and doesn't depend on the date. 170 | 171 | You can combine unique events with other types of events. 172 | 173 | A/B testing example: 174 | 175 | ```python 176 | 177 | active_today = DailyEvents('active') 178 | a = UniqueEvents('signup_form:classic') 179 | b = UniqueEvents('signup_form:new') 180 | 181 | print("Active users, signed up with classic form", len(active & a)) 182 | print("Active users, signed up with new form", len(active & b)) 183 | ``` 184 | 185 | Generic filter example 186 | 187 | ```python 188 | 189 | def premium_up(uid): 190 | # called when user promoted to premium 191 | ... 192 | mark_unique('premium', uid) 193 | 194 | 195 | def premium_down(uid): 196 | # called when user loses the premium status 197 | ... 198 | unmark_unique('premium', uid) 199 | 200 | active_today = DailyEvents('active') 201 | premium = UniqueEvents('premium') 202 | 203 | # Add extra Karma for all premium users active today, 204 | # just because today is a special day 205 | for uid in premium & active_today: 206 | add_extra_karma(uid) 207 | ``` 208 | 209 | To get the best of two worlds you can mark unique event and regular 210 | bitmapist events at the same time. 211 | 212 | 213 | ```python 214 | def premium_up(uid): 215 | # called when user promoted to premium 216 | ... 217 | mark_event('premium', uid, track_unique=True) 218 | 219 | ``` 220 | 221 | 222 | ### Perform bit operations 223 | 224 | How many users that have been active last month are still active this month? 225 | 226 | ```python 227 | active_2_months = BitOpAnd( 228 | MonthEvents('active', last_month.year, last_month.month), 229 | MonthEvents('active', now.year, now.month) 230 | ) 231 | print(len(active_2_months)) 232 | 233 | # Is 123 active for 2 months? 234 | assert 123 in active_2_months 235 | ``` 236 | 237 | Alternatively, you can use standard Python syntax for bitwise operations. 238 | 239 | 240 | ```python 241 | last_month_event = MonthEvents('active', last_month.year, last_month.month) 242 | this_month_event = MonthEvents('active', now.year, now.month) 243 | active_two_months = last_month_event & this_month_event 244 | ``` 245 | Operators `&`, `|`, `^` and `~` supported. 246 | 247 | Work with nested bit operations (imagine what you can do with this ;-))! 248 | 249 | ```python 250 | active_2_months = BitOpAnd( 251 | BitOpAnd( 252 | MonthEvents('active', last_month.year, last_month.month), 253 | MonthEvents('active', now.year, now.month) 254 | ), 255 | MonthEvents('active', now.year, now.month) 256 | ) 257 | print(len(active_2_months)) 258 | assert 123 in active_2_months 259 | 260 | # Delete the temporary AND operation 261 | active_2_months.delete() 262 | ``` 263 | 264 | 265 | ### Deleting 266 | 267 | If you want to permanently remove marked events for any time period you can use the `delete()` method: 268 | ```python 269 | last_month_event = MonthEvents('active', last_month.year, last_month.month) 270 | last_month_event.delete() 271 | ``` 272 | 273 | If you want to remove all bitmapist events use: 274 | ```python 275 | bitmapist.delete_all_events() 276 | ``` 277 | 278 | When using Bit Operations (ie `BitOpAnd`) you can (and probably should) delete the results unless you want them cached. There are different ways to go about this: 279 | ```python 280 | active_2_months = BitOpAnd( 281 | MonthEvents('active', last_month.year, last_month.month), 282 | MonthEvents('active', now.year, now.month) 283 | ) 284 | # Delete the temporary AND operation 285 | active_2_months.delete() 286 | 287 | # delete all bit operations created in runtime up to this point 288 | bitmapist.delete_runtime_bitop_keys() 289 | 290 | # delete all bit operations (slow if you have many millions of keys in Redis) 291 | bitmapist.delete_temporary_bitop_keys() 292 | ``` 293 | 294 | 295 | # bitmapist cohort 296 | 297 | With bitmapist cohort you can get a form and a table rendering of the data you keep in bitmapist. If this sounds confusing [please look at Mixpanel](https://mixpanel.com/retention/). 298 | 299 | Here's a simple example of how to generate a form and a rendering of the data you have inside bitmapist: 300 | ```python 301 | from bitmapist import cohort 302 | 303 | html_form = cohort.render_html_form( 304 | action_url='/_Cohort', 305 | selections1=[ ('Are Active', 'user:active'), ], 306 | selections2=[ ('Task completed', 'task:complete'), ] 307 | ) 308 | print(html_form) 309 | 310 | dates_data = cohort.get_dates_data(select1='user:active', 311 | select2='task:complete', 312 | time_group='days') 313 | 314 | html_data = cohort.render_html_data(dates_data, 315 | time_group='days') 316 | 317 | print(html_data) 318 | 319 | # All the arguments should come from the FORM element (html_form) 320 | # but to make things more clear I have filled them in directly 321 | ``` 322 | 323 | This will render something similar to this: 324 | 325 | ![bitmapist cohort screenshot](https://raw.githubusercontent.com/Doist/bitmapist/master/static/cohort_screenshot.png "bitmapist cohort screenshot") 326 | 327 | 328 | ## Contributing 329 | 330 | Please see our guide [here](./CONTRIBUTING.md) 331 | 332 | ## Local Development 333 | 334 | We use Poetry for dependency management & packaging. Please see [here for setup instructions](https://python-poetry.org/docs/#installation). 335 | 336 | Once you have Poetry installed, you can run the following to install the dependencies in a virtual environment: 337 | 338 | ```bash 339 | poetry install 340 | ``` 341 | 342 | ## Testing 343 | 344 | To run our tests will need to ensure a local redis server is installed. 345 | 346 | We use pytest to run unittests which you can run in a poetry shell with 347 | 348 | ```bash 349 | poetry run pytest 350 | ``` 351 | 352 | ## Releasing new versions 353 | 354 | - Bump version in `pyproject.toml` 355 | - Update the CHANGELOG 356 | - Commit the changes with a commit message "Version X.X.X" 357 | - Tag the current commit with `vX.X.X` 358 | - Create a new release on GitHub named `vX.X.X` 359 | - GitHub Actions will publish the new version to PIP for you 360 | 361 | ## Legal 362 | 363 | Copyright: 2012 by Doist Ltd. 364 | 365 | License: BSD 366 | -------------------------------------------------------------------------------- /bitmapist/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | bitmapist 3 | ~~~~~~~~~ 4 | Implements a powerful analytics library on top of Redis's support for bitmaps and bitmap operations. 5 | 6 | This library makes it possible to implement real-time, highly scalable analytics that can answer following questions: 7 | 8 | * Has user 123 been online today? This week? This month? This year? 9 | * Has user 123 performed action "X"? 10 | * How many users have been active have this month? This hour? 11 | * How many unique users have performed action "X" this week? 12 | * How many % of users that were active last week are still active? 13 | * How many % of users that were active last month are still active this month? 14 | 15 | This library is very easy to use and enables you to create your own reports easily. 16 | 17 | Using Redis bitmaps you can store events for millions of users in a very little amount of memory (megabytes). 18 | You should be careful about using huge ids (e.g. 2^32 or bigger) as this could require larger amounts of memory. 19 | 20 | If you want to read more about bitmaps please read following: 21 | * http://blog.getspool.com/2011/11/29/fast-easy-realtime-metrics-using-redis-bitmaps/ 22 | * http://redis.io/commands/setbit 23 | * http://en.wikipedia.org/wiki/Bit_array 24 | * http://www.slideshare.net/crashlytics/crashlytics-on-redis-analytics 25 | 26 | Requires Redis 2.6+ and newest version of redis-py. 27 | 28 | Examples 29 | ======== 30 | 31 | Setting things up:: 32 | 33 | from datetime import datetime, timedelta 34 | from bitmapist import mark_event, MonthEvents 35 | 36 | now = datetime.now(tz=timezone.utc) 37 | last_month = now - timedelta(days=30) 38 | 39 | Mark user 123 as active:: 40 | 41 | mark_event('active', 123) 42 | 43 | Answer if user 123 has been active this month:: 44 | 45 | assert 123 in MonthEvents('active', now.year, now.month) 46 | 47 | How many users have been active this week?:: 48 | 49 | print len(WeekEvents('active', now.year, now.isocalendar()[1])) 50 | 51 | Perform bit operations. Which users that have been active last month are still active this month?:: 52 | 53 | active_2_months = BitOpAnd( 54 | MonthEvents('active', last_month.year, last_month.month), 55 | MonthEvents('active', now.year, now.month) 56 | ) 57 | 58 | Nest bit operations!:: 59 | 60 | active_2_months = BitOpAnd( 61 | BitOpAnd( 62 | MonthEvents('active', last_month.year, last_month.month), 63 | MonthEvents('active', now.year, now.month) 64 | ), 65 | MonthEvents('active', now.year, now.month) 66 | ) 67 | 68 | As something new tracking hourly is disabled (to save memory!) To enable it as default do:: 69 | 70 | import bitmapist 71 | bitmapist.TRACK_HOURLY = True 72 | 73 | Additionally you can supply an extra argument to mark_event to bypass the default value:: 74 | 75 | mark_event('active', 123, track_hourly=False) 76 | 77 | :copyright: 2012 by Doist Ltd. 78 | :developer: Amir Salihefendic ( http://amix.dk ) 79 | :license: BSD 80 | """ 81 | 82 | from __future__ import annotations 83 | 84 | import calendar 85 | import threading 86 | from collections import defaultdict 87 | from datetime import date, datetime, timedelta, timezone 88 | from typing import TYPE_CHECKING, Any, Optional, Union 89 | 90 | import redis 91 | 92 | if TYPE_CHECKING: 93 | from redis.client import Pipeline, Redis 94 | 95 | local_thread = threading.local() 96 | 97 | # --- Systems related 98 | 99 | SYSTEMS = {"default": redis.StrictRedis(host="localhost", port=6379)} 100 | 101 | # Should hourly be tracked as default? 102 | # Note that this can have huge implications in amounts 103 | # of memory that Redis uses (especially with huge integers) 104 | TRACK_HOURLY = False 105 | 106 | # Should unique events be tracked as default? 107 | TRACK_UNIQUE = False 108 | 109 | 110 | def setup_redis(name: str, host: str, port: int, **kw: Any) -> None: 111 | """ 112 | Setup a redis system. 113 | 114 | :param :name The name of the system 115 | :param :host The host of the redis installation 116 | :param :port The port of the redis installation 117 | :param :**kw Any additional keyword arguments will be passed to `redis.StrictRedis`. 118 | 119 | Example:: 120 | 121 | setup_redis("stats_redis", "localhost", 6380) 122 | 123 | mark_event("active", 1, system="stats_redis") 124 | """ 125 | redis_client = kw.pop("redis_client", redis.StrictRedis) 126 | SYSTEMS[name] = redis_client(host=host, port=port, **kw) 127 | 128 | 129 | def get_redis(system: str = "default") -> Union[Redis, Pipeline]: 130 | """ 131 | Get a redis-py client instance with entry `system`. 132 | 133 | :param :system The name of the system, redis.StrictRedis or redis.Pipeline 134 | instance, extra systems can be setup via `setup_redis` 135 | """ 136 | if isinstance(system, redis.StrictRedis): 137 | return system 138 | return SYSTEMS[system] 139 | 140 | 141 | # --- Events marking and deleting 142 | 143 | 144 | def mark_event( 145 | event_name: str, 146 | uuid: int, 147 | system: str = "default", 148 | now: Optional[datetime] = None, 149 | track_hourly: Optional[bool] = None, 150 | track_unique: Optional[bool] = None, 151 | use_pipeline: bool = True, 152 | ) -> None: 153 | """ 154 | Marks an event for hours, days, weeks and months. 155 | 156 | :param :event_name The name of the event, could be "active" or "new_signups" 157 | :param :uuid An unique id, typically user id. The id should not be huge, 158 | read Redis documentation why (bitmaps) 159 | :param :system The Redis system to use (string, Redis instance, or Pipeline 160 | instance). 161 | :param :now Which date should be used as a reference point, default is 162 | `datetime.now(tz=timezone.utc)` 163 | :param :track_hourly Should hourly stats be tracked, defaults to 164 | bitmapist.TRACK_HOURLY 165 | :param :track_unique Should unique stats be tracked, defaults to 166 | bitmapist.TRACK_UNIQUE 167 | :param :use_pipeline Boolean flag indicating if the command should use 168 | pipelines or not. You may want to avoid using pipeline within the 169 | command if you provide the pipeline object in `system` argument and 170 | want to manage the pipe execution yourself. 171 | 172 | Examples:: 173 | 174 | # Mark id 1 as active 175 | mark_event("active", 1) 176 | 177 | # Mark task completed for id 252 178 | mark_event("tasks:completed", 252) 179 | """ 180 | _mark( 181 | event_name, uuid, system, now, track_hourly, track_unique, use_pipeline, value=1 182 | ) 183 | 184 | 185 | def unmark_event( 186 | event_name: str, 187 | uuid: int, 188 | system: str = "default", 189 | now: Optional[datetime] = None, 190 | track_hourly: Optional[bool] = None, 191 | track_unique: Optional[bool] = None, 192 | use_pipeline: bool = True, 193 | ) -> None: 194 | _mark( 195 | event_name, uuid, system, now, track_hourly, track_unique, use_pipeline, value=0 196 | ) 197 | 198 | 199 | def _mark( 200 | event_name, 201 | uuid: int, 202 | system="default", 203 | now: Optional[datetime] = None, 204 | track_hourly=None, 205 | track_unique=None, 206 | use_pipeline: bool = True, 207 | value: int = 1, 208 | ): 209 | if track_hourly is None: 210 | track_hourly = TRACK_HOURLY 211 | if track_unique is None: 212 | track_unique = TRACK_UNIQUE 213 | if now is None: 214 | now = datetime.now(tz=timezone.utc) 215 | 216 | obj_classes: list[ 217 | type[MonthEvents] 218 | | type[WeekEvents] 219 | | type[DayEvents] 220 | | type[HourEvents] 221 | | type[UniqueEvents] 222 | ] = [ 223 | MonthEvents, 224 | WeekEvents, 225 | DayEvents, 226 | ] 227 | if track_hourly: 228 | obj_classes.append(HourEvents) 229 | if track_unique: 230 | obj_classes.append(UniqueEvents) 231 | 232 | client = get_redis(system) 233 | if use_pipeline: 234 | client = client.pipeline() 235 | 236 | for obj_class in obj_classes: 237 | client.setbit(obj_class.from_date(event_name, now).redis_key, uuid, value) 238 | 239 | if use_pipeline: 240 | client.execute() # type: ignore[attr-defined] # type dependent on conditional 241 | 242 | 243 | def mark_unique(event_name: str, uuid: int, system: str = "default") -> None: 244 | """ 245 | Mark unique event 246 | 247 | Unique event (aka "user flag") is an event which doesn't depend on date. 248 | Can be used for storing user properties, A/B testing, extra filtering, etc. 249 | 250 | :param :event_name The name of the event, could be "active" or "new_signups" 251 | :param :uuid An unique id, typically user id. The id should not be huge, 252 | read Redis documentation why (bitmaps) 253 | :param :system The Redis system to use (string, Redis instance, or Pipeline 254 | 255 | Examples:: 256 | 257 | # Mark id 42 as premium 258 | mark_unique("premium", 42) 259 | """ 260 | _mark_unique(event_name, uuid, system, value=1) 261 | 262 | 263 | def unmark_unique(event_name: str, uuid: int, system: str = "default") -> None: 264 | """ 265 | Unmark unique event 266 | 267 | Unique event (aka "user flag") is an event which doesn't depend on date. 268 | Can be used for storing user properties, A/B testing, extra filtering, etc. 269 | 270 | :param :event_name The name of the event, could be "active" or "new_signups" 271 | :param :uuid An unique id, typically user id. The id should not be huge, 272 | read Redis documentation why (bitmaps) 273 | :param :system The Redis system to use (string, Redis instance, or Pipeline 274 | 275 | Examples:: 276 | 277 | # Mark id 42 as not premium anymore 278 | unmark_unique("premium", 42) 279 | """ 280 | _mark_unique(event_name, uuid, system, value=0) 281 | 282 | 283 | def _mark_unique(event_name, uuid: int, system="default", value: int = 1): 284 | get_redis(system).setbit(UniqueEvents(event_name).redis_key, uuid, value) 285 | 286 | 287 | def get_event_names( 288 | system: str = "default", prefix: str = "", batch: int = 10000 289 | ) -> list[str]: 290 | """ 291 | Return the list of all event names, with no particular order. Optional 292 | `prefix` value is used to filter only subset of keys 293 | """ 294 | cli = get_redis(system) 295 | expr = "trackist_%s*" % prefix 296 | ret = set() 297 | for result in cli.scan_iter(match=expr, count=batch): 298 | result = result.decode() 299 | chunks = result.split("_") 300 | event_name = "_".join(chunks[1:-1]) 301 | if not event_name.startswith("bitop_"): 302 | ret.add(event_name) 303 | return list(ret) 304 | 305 | 306 | def delete_all_events(system: str = "default") -> None: 307 | """Delete all events from the database.""" 308 | cli = get_redis(system) 309 | keys = cli.keys("trackist_*") 310 | if keys: 311 | cli.delete(*keys) 312 | 313 | 314 | def delete_temporary_bitop_keys(system: str = "default") -> None: 315 | """Delete all temporary keys that are used when using bit operations.""" 316 | cli = get_redis(system) 317 | keys = cli.keys("trackist_bitop_*") 318 | if keys: 319 | cli.delete(*keys) 320 | 321 | 322 | def delete_runtime_bitop_keys() -> None: 323 | """Delete all BitOp keys that were created.""" 324 | bitop_keys = _bitop_keys() 325 | for system in bitop_keys: 326 | if len(bitop_keys[system]) > 0: 327 | cli = get_redis(system) 328 | cli.delete(*bitop_keys[system]) 329 | bitop_keys.clear() 330 | 331 | 332 | # --- Events 333 | 334 | 335 | class MixinIter: 336 | """ 337 | Extends with an obj.get_uuids() returning the iterator of uuids in a key 338 | (unpacks the key) 339 | """ 340 | 341 | def get_uuids(self): 342 | cli = get_redis(self.system) 343 | val = cli.get(self.redis_key) 344 | if val is None: 345 | return 346 | 347 | val = bytes(val) 348 | 349 | for char_num, char in enumerate(val): 350 | # shortcut 351 | if char == 0: 352 | continue 353 | # find set bits, generate smth like [1, 0, ...] 354 | bits = [(char >> i) & 1 for i in range(7, -1, -1)] 355 | # list of positions with ones 356 | set_bits = [pos for pos, val in enumerate(bits) if val] 357 | # yield everything we need 358 | for bit in set_bits: 359 | yield char_num * 8 + bit 360 | 361 | def __iter__(self): 362 | yield from self.get_uuids() 363 | 364 | 365 | class MixinBitOperations: 366 | def __invert__(self): 367 | return BitOpNot(self) 368 | 369 | def __or__(self, other): 370 | return BitOpOr(self, other) 371 | 372 | def __and__(self, other): 373 | return BitOpAnd(self, other) 374 | 375 | def __xor__(self, other): 376 | return BitOpXor(self, other) 377 | 378 | 379 | class MixinEventsMisc: 380 | """ 381 | Extends with an obj.has_events_marked() 382 | that returns `True` if there are any events marked, 383 | otherwise `False` is returned. 384 | 385 | Extends also with a obj.delete() 386 | (useful for deleting temporary calculations). 387 | """ 388 | 389 | def has_events_marked(self): 390 | cli = get_redis(self.system) 391 | return bool(cli.exists(self.redis_key)) 392 | 393 | def delete(self): 394 | cli = get_redis(self.system) 395 | cli.delete(self.redis_key) 396 | 397 | def __eq__(self, other): 398 | other_key = getattr(other, "redis_key", None) 399 | if other_key is None: 400 | return NotImplemented 401 | return self.redis_key == other_key 402 | 403 | 404 | class MixinCounts: 405 | """ 406 | Extends with an obj.get_count() that uses BITCOUNT to 407 | count all the events. Supports also __len__ 408 | """ 409 | 410 | def get_count(self): 411 | cli = get_redis(self.system) 412 | count = cli.bitcount(self.redis_key) 413 | return count 414 | 415 | def __len__(self) -> int: 416 | return self.get_count() 417 | 418 | 419 | class MixinContains: 420 | """ 421 | Makes it possible to see if an uuid has been marked. 422 | 423 | Example:: 424 | 425 | user_active_today = 123 in DayEvents("active", 2012, 10, 23) 426 | """ 427 | 428 | def __contains__(self, uuid): 429 | cli = get_redis(self.system) 430 | if cli.getbit(self.redis_key, uuid): 431 | return True 432 | return False 433 | 434 | 435 | class UniqueEvents( 436 | MixinIter, MixinCounts, MixinContains, MixinEventsMisc, MixinBitOperations 437 | ): 438 | @classmethod 439 | def from_date(cls, event_name, dt=None, system="default"): 440 | return cls(event_name, system=system) 441 | 442 | def __init__(self, event_name, system="default"): 443 | self.event_name = event_name 444 | self.system = system 445 | self.redis_key = _prefix_key(event_name, "u") 446 | 447 | def next(self): 448 | return self 449 | 450 | def prev(self): 451 | return self 452 | 453 | 454 | class GenericPeriodEvents( 455 | MixinIter, MixinCounts, MixinContains, MixinEventsMisc, MixinBitOperations 456 | ): 457 | def next(self): 458 | """Next object in a datetime line""" 459 | return self.delta(value=1) 460 | 461 | def prev(self): 462 | """Prev object in a datetime line""" 463 | return self.delta(value=-1) 464 | 465 | 466 | class YearEvents(GenericPeriodEvents): 467 | """ 468 | Events for a year. 469 | 470 | Example:: 471 | 472 | YearEvents("active", 2012) 473 | """ 474 | 475 | @classmethod 476 | def from_date( 477 | cls, event_name, dt: Optional[date | datetime] = None, system="default" 478 | ): 479 | dt = dt or datetime.now(tz=timezone.utc) 480 | return cls(event_name, dt.year, system=system) 481 | 482 | def __init__(self, event_name, year=None, system="default"): 483 | now = datetime.now(tz=timezone.utc) 484 | self.event_name = event_name 485 | self.year = not_none(year, now.year) 486 | self.system = system 487 | 488 | months = [MonthEvents(event_name, self.year, m, system) for m in range(1, 13)] 489 | or_op = BitOpOr(system, *months) 490 | self.redis_key = or_op.redis_key 491 | 492 | def delta(self, value): 493 | return self.__class__(self.event_name, self.year + value, self.system) 494 | 495 | def period_start(self): 496 | return datetime(self.year, 1, 1, tzinfo=timezone.utc) 497 | 498 | def period_end(self): 499 | return datetime(self.year, 12, 31, 23, 59, 59, 999999, tzinfo=timezone.utc) 500 | 501 | 502 | class MonthEvents(GenericPeriodEvents): 503 | """ 504 | Events for a month. 505 | 506 | Example:: 507 | 508 | MonthEvents("active", 2012, 10) 509 | """ 510 | 511 | @classmethod 512 | def from_date( 513 | cls, event_name, dt: Optional[date | datetime] = None, system="default" 514 | ): 515 | dt = dt or datetime.now(tz=timezone.utc) 516 | return cls(event_name, dt.year, dt.month, system=system) 517 | 518 | def __init__(self, event_name, year=None, month=None, system="default"): 519 | now = datetime.now(tz=timezone.utc) 520 | self.event_name = event_name 521 | self.year = not_none(year, now.year) 522 | self.month = not_none(month, now.month) 523 | self.system = system 524 | self.redis_key = _prefix_key(event_name, f"{self.year}-{self.month}") 525 | 526 | def delta(self, value): 527 | year, month = add_month(self.year, self.month, value) 528 | return self.__class__(self.event_name, year, month, self.system) 529 | 530 | def period_start(self): 531 | return datetime(self.year, self.month, 1, tzinfo=timezone.utc) 532 | 533 | def period_end(self): 534 | _, day = calendar.monthrange(self.year, self.month) 535 | return datetime( 536 | self.year, self.month, day, 23, 59, 59, 999999, tzinfo=timezone.utc 537 | ) 538 | 539 | 540 | class WeekEvents(GenericPeriodEvents): 541 | """ 542 | Events for a week. 543 | 544 | Example:: 545 | 546 | WeekEvents("active", 2012, 48) 547 | """ 548 | 549 | @classmethod 550 | def from_date( 551 | cls, 552 | event_name: str, 553 | dt: Optional[date | datetime] = None, 554 | system: str = "default", 555 | ): 556 | dt = dt or datetime.now(tz=timezone.utc) 557 | dt_year, dt_week, _ = dt.isocalendar() 558 | return cls(event_name, dt_year, dt_week, system=system) 559 | 560 | def __init__(self, event_name: str, year=None, week=None, system="default"): 561 | now = datetime.now(tz=timezone.utc) 562 | now_year, now_week, _ = now.isocalendar() 563 | self.event_name = event_name 564 | self.year = not_none(year, now_year) 565 | self.week = not_none(week, now_week) 566 | self.system = system 567 | self.redis_key = _prefix_key(event_name, f"W{self.year}-{self.week}") 568 | 569 | def delta(self, value): 570 | dt = iso_to_gregorian(self.year, self.week + value, 1) 571 | year, week, _ = dt.isocalendar() 572 | return self.__class__(self.event_name, year, week, self.system) 573 | 574 | def period_start(self): 575 | s = iso_to_gregorian(self.year, self.week, 1) # mon 576 | return datetime(s.year, s.month, s.day, tzinfo=timezone.utc) 577 | 578 | def period_end(self): 579 | e = iso_to_gregorian(self.year, self.week, 7) # mon 580 | return datetime(e.year, e.month, e.day, 23, 59, 59, 999999, tzinfo=timezone.utc) 581 | 582 | 583 | class DayEvents(GenericPeriodEvents): 584 | """ 585 | Events for a day. 586 | 587 | Example:: 588 | 589 | DayEvents("active", 2012, 10, 23) 590 | """ 591 | 592 | @classmethod 593 | def from_date( 594 | cls, event_name: str, dt: Optional[date | datetime] = None, system="default" 595 | ): 596 | dt = dt or datetime.now(tz=timezone.utc) 597 | return cls(event_name, dt.year, dt.month, dt.day, system=system) 598 | 599 | def __init__(self, event_name, year=None, month=None, day=None, system="default"): 600 | now = datetime.now(tz=timezone.utc) 601 | self.event_name = event_name 602 | self.year = not_none(year, now.year) 603 | self.month = not_none(month, now.month) 604 | self.day = not_none(day, now.day) 605 | self.system = system 606 | self.redis_key = _prefix_key(event_name, f"{self.year}-{self.month}-{self.day}") 607 | 608 | def delta(self, value): 609 | dt = date(self.year, self.month, self.day) + timedelta(days=value) 610 | return self.__class__(self.event_name, dt.year, dt.month, dt.day, self.system) 611 | 612 | def period_start(self) -> datetime: 613 | return datetime(self.year, self.month, self.day, tzinfo=timezone.utc) 614 | 615 | def period_end(self) -> datetime: 616 | return datetime( 617 | self.year, self.month, self.day, 23, 59, 59, 999999, tzinfo=timezone.utc 618 | ) 619 | 620 | 621 | class HourEvents(GenericPeriodEvents): 622 | """ 623 | Events for a hour. 624 | 625 | Example:: 626 | 627 | HourEvents("active", 2012, 10, 23, 13) 628 | """ 629 | 630 | @classmethod 631 | def from_date( 632 | cls, event_name: str, dt: Optional[datetime] = None, system="default" 633 | ): 634 | dt = dt or datetime.now(tz=timezone.utc) 635 | return cls(event_name, dt.year, dt.month, dt.day, dt.hour, system=system) 636 | 637 | def __init__( 638 | self, 639 | event_name: str, 640 | year: Optional[int] = None, 641 | month: Optional[int] = None, 642 | day: Optional[int] = None, 643 | hour: Optional[int] = None, 644 | system: str = "default", 645 | ): 646 | now = datetime.now(tz=timezone.utc) 647 | self.event_name = event_name 648 | self.year = not_none(year, now.year) 649 | self.month = not_none(month, now.month) 650 | self.day = not_none(day, now.day) 651 | self.hour = not_none(hour, now.hour) 652 | self.system = system 653 | self.redis_key = _prefix_key( 654 | event_name, f"{self.year}-{self.month}-{self.day}-{self.hour}" 655 | ) 656 | 657 | def delta(self, value): 658 | dt = datetime( 659 | self.year, self.month, self.day, self.hour, tzinfo=timezone.utc 660 | ) + timedelta(hours=value) 661 | return self.__class__( 662 | self.event_name, dt.year, dt.month, dt.day, dt.hour, self.system 663 | ) 664 | 665 | def period_start(self) -> datetime: 666 | return datetime(self.year, self.month, self.day, self.hour, tzinfo=timezone.utc) 667 | 668 | def period_end(self) -> datetime: 669 | return datetime( 670 | self.year, 671 | self.month, 672 | self.day, 673 | self.hour, 674 | 59, 675 | 59, 676 | 999999, 677 | tzinfo=timezone.utc, 678 | ) 679 | 680 | 681 | # --- Bit operations 682 | 683 | 684 | class BitOperation( 685 | MixinIter, MixinContains, MixinCounts, MixinEventsMisc, MixinBitOperations 686 | ): 687 | """ 688 | Base class for bit operations (AND, OR, XOR). 689 | 690 | Please note that each bit operation creates a new key prefixed with `trackist_bitop_`. 691 | These temporary keys can be deleted with `delete_temporary_bitop_keys` or 692 | `delete_runtime_bitop_keys`. 693 | 694 | You can even nest bit operations. 695 | 696 | Example:: 697 | 698 | active_2_months = BitOpAnd( 699 | MonthEvents("active", last_month.year, last_month.month), 700 | MonthEvents("active", now.year, now.month), 701 | ) 702 | 703 | active_2_months = BitOpAnd( 704 | BitOpAnd( 705 | MonthEvents("active", last_month.year, last_month.month), 706 | MonthEvents("active", now.year, now.month), 707 | ), 708 | MonthEvents("active", now.year, now.month), 709 | ) 710 | 711 | """ 712 | 713 | def __init__(self, op_name: str, system_or_event, *events): 714 | # Smartly resolve system_or_event, makes it possible to build a cleaner API 715 | if hasattr(system_or_event, "redis_key"): 716 | events = (system_or_event, *events) 717 | system = self.system = "default" 718 | else: 719 | system = self.system = system_or_event 720 | 721 | event_redis_keys = [ev.redis_key for ev in events] 722 | 723 | self.redis_key = "trackist_bitop_{}_{}".format( 724 | op_name, "-".join(event_redis_keys) 725 | ) 726 | _bitop_keys()[system].add(self.redis_key) 727 | 728 | cli = get_redis(system) 729 | cli.bitop(op_name, self.redis_key, *event_redis_keys) 730 | 731 | 732 | class BitOpAnd(BitOperation): 733 | def __init__(self, system_or_event, *events): 734 | BitOperation.__init__(self, "AND", system_or_event, *events) 735 | 736 | 737 | class BitOpOr(BitOperation): 738 | def __init__(self, system_or_event, *events): 739 | BitOperation.__init__(self, "OR", system_or_event, *events) 740 | 741 | 742 | class BitOpXor(BitOperation): 743 | def __init__(self, system_or_event, *events): 744 | BitOperation.__init__(self, "XOR", system_or_event, *events) 745 | 746 | 747 | class BitOpNot(BitOperation): 748 | def __init__(self, system_or_event, *events): 749 | BitOperation.__init__(self, "NOT", system_or_event, *events) 750 | 751 | 752 | # --- Private 753 | 754 | 755 | def _prefix_key(event_name: str, date: str) -> str: 756 | return f"trackist_{event_name}_{date}" 757 | 758 | 759 | # --- Helper functions 760 | 761 | 762 | def add_month(year: int, month: int, delta: int) -> tuple[int, int]: 763 | """ 764 | Helper function which adds `delta` months to current `(year, month)` tuple 765 | and returns a new valid tuple `(year, month)` 766 | """ 767 | year, month = divmod(year * 12 + month + delta, 12) 768 | if month == 0: 769 | month = 12 770 | year = year - 1 771 | return year, month 772 | 773 | 774 | def not_none(*keys): 775 | """Helper function returning first value which is not None""" 776 | for key in keys: 777 | if key is not None: 778 | return key 779 | return None 780 | 781 | 782 | def iso_year_start(iso_year: int) -> date: 783 | """The gregorian calendar date of the first day of the given ISO year""" 784 | fourth_jan = date(iso_year, 1, 4) 785 | delta = timedelta(fourth_jan.isoweekday() - 1) 786 | return fourth_jan - delta 787 | 788 | 789 | def iso_to_gregorian(iso_year: int, iso_week: int, iso_day: int) -> date: 790 | """Gregorian calendar date for the given ISO year, week and day""" 791 | year_start = iso_year_start(iso_year) 792 | return year_start + timedelta(days=iso_day - 1, weeks=iso_week - 1) 793 | 794 | 795 | def _bitop_keys(): 796 | """Hold created BitOp keys (per thread)""" 797 | v = getattr(local_thread, "bitop_keys", None) 798 | if v is None: 799 | v = defaultdict(set) 800 | setattr(local_thread, "bitop_keys", v) 801 | return v 802 | -------------------------------------------------------------------------------- /bitmapist/cohort/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | bitmapist.cohort 3 | ~~~~~~~~~~~~~~~~ 4 | Implements cohort analytics on top of the data stored in bitmapist. 5 | 6 | This library makes it possible to implement real-time, highly scalable analytics that can answer following questions: 7 | 8 | * Generate a cohort table over real-time data stored in bitmapist 9 | * How many % of users that were active last [days, weeks, months] are still active? 10 | * How many % of users that performed action X also performed action Y (and this over time) 11 | 12 | A screenshot of the library in action: 13 | https://d2dq6e731uoz0t.cloudfront.net/d5b299fafecc15eb3ea9f7f12f70a061/as/cohort.png 14 | 15 | If you want to read more about cohort please read following: 16 | * http://en.wikipedia.org/wiki/Cohort_(statistics) 17 | * https://mixpanel.com/docs/learn-the-features/retention-overview [ I was inspired by this, but didn't want to pay the steep price ] 18 | 19 | 20 | Examples 21 | ======== 22 | 23 | Mark user 123 as active and mark some other events:: 24 | 25 | from bitmapist import mark_event 26 | 27 | mark_event('active', 123) 28 | mark_event('song:add', 123) 29 | mark_event('song:play', 123) 30 | 31 | Generate the form that makes it easy to query the bitmapist database:: 32 | 33 | html_form = bitmapist_cohort.render_html_form( 34 | action_url='/_Cohort', 35 | selections1=[ ('Are Active', 'active'), ], 36 | selections2=[ ('Played song', 'song:play'), ], 37 | time_group='days', 38 | select1='active', 39 | select2='song:play' 40 | ) 41 | 42 | # action_url is the action URL of the
element 43 | # selections1, selections2 specifies the events that the user can select in the form 44 | # time_group can be `days`, `weeks` or `months` 45 | # select1, select2 specifies the current selected events in the 46 | 47 | Get the data and render it via HTML:: 48 | 49 | dates_data = bitmapist_cohort.get_dates_data(select1='active', 50 | select2='song:play', 51 | time_group='days', 52 | system='default') 53 | 54 | html_data = bitmapist_cohort.render_html_data(dates_data, 55 | time_group='days') 56 | 57 | # All the arguments should come from the FORM element (html_form) 58 | # but to make things more clear I have filled them in directly 59 | 60 | :copyright: 2012 by Doist Ltd. 61 | :developer: Amir Salihefendic ( http://amix.dk ) 62 | :license: BSD 63 | """ 64 | 65 | from datetime import date, datetime, timedelta, timezone 66 | from os import path 67 | from typing import Any, Callable, Literal, Optional, Union 68 | 69 | from dateutil.relativedelta import relativedelta 70 | from mako.lookup import TemplateLookup 71 | 72 | from bitmapist import ( 73 | BitOpAnd, 74 | DayEvents, 75 | GenericPeriodEvents, 76 | MonthEvents, 77 | WeekEvents, 78 | YearEvents, 79 | delete_runtime_bitop_keys, 80 | ) 81 | 82 | # --- HTML rendering 83 | 84 | 85 | def render_html_form( 86 | action_url, 87 | selections1, 88 | selections1b=None, 89 | selections2=None, 90 | selections2b=None, 91 | time_group: Literal["days", "weeks", "months", "years"] = "days", 92 | select1=None, 93 | select1b=None, 94 | select2=None, 95 | select2b=None, 96 | as_percent: bool = True, 97 | num_results: int = 31, 98 | num_of_rows: int = 12, 99 | start_date: Optional[str] = None, 100 | ): 101 | """ 102 | Render a HTML form that can be used to query the data in bitmapist. 103 | 104 | :param :action_url The action URL of the element. The form will always to a GET request. 105 | :param :selections1 A list of selections that the user can filter by, example `[ ('Are Active', 'active'), ]` 106 | :param :selections1b Extra selections, used with the first selection, example `[ ('in US', 'country:US'), ]` 107 | :param :selections2 A list of selections that the user can filter by, example `[ ('Played song', 'song:play'), ]` 108 | :param :selections2b Extra selections, used with the second selection, example `[ ('Playlist', 'playlist:new'), ]` 109 | :param :time_group What data should be clustered by, can be `days`, `weeks`, `months`, `years` 110 | :param :select1 What is the current selected filter (first) 111 | :param :select1b What is the current selected filter (extra, optional) 112 | :param :select2 What is the current selected filter (second) 113 | :param :select2b What is the current selected filter (extra, optional) 114 | 115 | """ 116 | # mandatory 117 | selections2 = selections2 or selections1[:] 118 | 119 | # optional 120 | selections1b_c = selections1b[:] if selections1b else selections1[:] 121 | selections1b_c.insert(0, ("------", "")) 122 | 123 | selections2b_c = selections2b[:] if selections2b else selections2[:] 124 | selections2b_c.insert(0, ("------", "")) 125 | 126 | return ( 127 | get_lookup() 128 | .get_template("form_data.mako") 129 | .render( 130 | selections1=selections1, 131 | selections1b=selections1b_c, 132 | selections2=selections2, 133 | selections2b=selections2b_c, 134 | time_group=time_group, 135 | select1=select1, 136 | select1b=select1b, 137 | select2=select2, 138 | select2b=select2b, 139 | action_url=action_url, 140 | as_percent=as_percent, 141 | num_results=int(num_results), 142 | num_of_rows=int(num_of_rows), 143 | start_date=start_date, 144 | ) 145 | ) 146 | 147 | 148 | def render_html_data( 149 | dates_data, 150 | as_percent: bool = True, 151 | time_group: Literal["days", "weeks", "months", "years"] = "days", 152 | num_results: int = 31, 153 | num_of_rows: int = 12, 154 | start_date: Optional[str] = None, 155 | ): 156 | """ 157 | Render's data as HTML, inside a TABLE element. 158 | 159 | :param :dates_data The data that's returned by `get_dates_data` 160 | :param :as_percent Should the data be shown as percents or as counts. Defaults to `True` 161 | :param :time_group What is the data grouped by? Can be `days`, `weeks`, `months`, `years` 162 | """ 163 | return ( 164 | get_lookup() 165 | .get_template("table_data.mako") 166 | .render( 167 | dates_data=dates_data, 168 | as_percent=as_percent, 169 | time_group=time_group, 170 | num_results=num_results, 171 | num_of_rows=num_of_rows, 172 | start_date=start_date, 173 | ) 174 | ) 175 | 176 | 177 | def render_csv_data( 178 | dates_data, 179 | as_percent: bool = True, 180 | time_group: Literal["days", "weeks", "months", "years"] = "days", 181 | num_results: int = 31, 182 | num_of_rows: int = 12, 183 | start_date: Optional[str] = None, 184 | ): 185 | """Render's data as CSV.""" 186 | return ( 187 | get_lookup() 188 | .get_template("table_data_csv.mako") 189 | .render( 190 | dates_data=dates_data, 191 | as_percent=as_percent, 192 | time_group=time_group, 193 | num_results=num_results, 194 | num_of_rows=num_of_rows, 195 | start_date=start_date, 196 | ) 197 | ) 198 | 199 | 200 | # --- Data rendering 201 | 202 | 203 | def get_dates_data( 204 | select1, 205 | select2, 206 | select1b=None, 207 | select2b=None, 208 | time_group: Literal["days", "weeks", "months", "years"] = "days", 209 | system="default", 210 | as_percent: bool = True, 211 | num_results: int = 31, 212 | num_of_rows: int = 12, 213 | start_date: Optional[str] = None, 214 | ): 215 | """ 216 | Fetch the data from bitmapist. 217 | 218 | :param :select1 First filter (could be `active`) 219 | :param :select1b Second filter (could be `country:US`, optional) 220 | :param :select2 Second filter (could be `song:played`) 221 | :param :select2b Second filter (could be `playlist:created`, optional) 222 | :param :time_group What is the data grouped by? Can be `days`, `weeks`, `months`, `years` 223 | :param :system What bitmapist should be used? 224 | :param :as_percent If `True` then percents as calculated and shown. Defaults to `True` 225 | :return A list of day data, formatted like `[[datetime, count], ...]` 226 | """ 227 | num_results = int(num_results) 228 | num_of_rows = int(num_of_rows) 229 | 230 | if start_date: 231 | now = datetime.strptime(start_date, "%Y-%m-%d").replace(tzinfo=timezone.utc) 232 | now = now + timedelta(days=num_results - 1) 233 | else: 234 | now = datetime.now(tz=timezone.utc) 235 | 236 | # Days 237 | if time_group == "days": 238 | fn_get_events = _day_events_fn 239 | 240 | date_range = num_results 241 | now = now - timedelta(days=num_results - 1) 242 | 243 | def timedelta_inc(delta: int) -> Union[relativedelta, timedelta]: 244 | return timedelta(days=delta) 245 | 246 | # Weeks 247 | elif time_group == "weeks": 248 | fn_get_events = _weeks_events_fn 249 | 250 | date_range = num_results 251 | now = now - relativedelta(weeks=num_results - 1) 252 | 253 | def timedelta_inc(delta: int) -> Union[relativedelta, timedelta]: 254 | return relativedelta(weeks=delta) 255 | 256 | # Months 257 | elif time_group == "months": 258 | fn_get_events = _month_events_fn 259 | 260 | date_range = num_results 261 | now = now - relativedelta(months=num_results - 1) 262 | now -= timedelta(days=now.day - 1) 263 | 264 | def timedelta_inc(delta: int) -> Union[relativedelta, timedelta]: 265 | return relativedelta(months=delta) 266 | 267 | # Years 268 | elif time_group == "years": 269 | fn_get_events = _year_events_fn 270 | 271 | num_results = 3 272 | 273 | date_range = num_results 274 | now = now - relativedelta(years=num_results - 1) 275 | 276 | def timedelta_inc(delta: int) -> Union[relativedelta, timedelta]: 277 | return relativedelta(years=delta) 278 | 279 | dates = [] 280 | 281 | for _i in range(date_range): 282 | result: list[Any] = [now] 283 | 284 | # events for select1 (+select1b) 285 | select1_events = fn_get_events(select1, now, system) 286 | if select1b: 287 | select1b_events = fn_get_events(select1b, now, system) 288 | select1_events = BitOpAnd(system, select1_events, select1b_events) 289 | 290 | select1_count = len(select1_events) 291 | result.append(select1_count) 292 | 293 | # Move in time 294 | for t_delta in range(num_of_rows + 1): 295 | if select1_count == 0: 296 | result.append("") 297 | continue 298 | 299 | delta_now = now + timedelta_inc(t_delta) 300 | 301 | # events for select2 (+select2b) 302 | select2_events = fn_get_events(select2, delta_now, system) 303 | if select2b: 304 | select2b_events = fn_get_events(select2b, delta_now, system) 305 | select2_events = BitOpAnd(system, select2_events, select2b_events) 306 | 307 | if not select2_events.has_events_marked(): 308 | result.append("") 309 | continue 310 | 311 | both_events = BitOpAnd(system, select1_events, select2_events) 312 | both_count = len(both_events) 313 | 314 | # Append to result 315 | if both_count == 0: 316 | result.append(0.0) 317 | elif as_percent: 318 | result.append((float(both_count) / float(select1_count)) * 100) 319 | else: 320 | result.append(both_count) 321 | 322 | dates.append(result) 323 | now = now + timedelta_inc(1) 324 | 325 | # clean up results of BitOps 326 | delete_runtime_bitop_keys() 327 | 328 | return dates 329 | 330 | 331 | # --- Custom handlers 332 | 333 | CUSTOM_HANDLERS: dict[str, Callable[..., Any]] = {} 334 | 335 | 336 | def set_custom_handler(event_name: str, callback) -> None: 337 | """ 338 | Set a custom handler for `event_name`. 339 | This makes it possible to construct event names that are complex 340 | (for example looking at active & (web | ios)). 341 | 342 | The signature of `callback` is callback(key, cls, cls_args) 343 | Where cls is DayEvents, WeekEvents, MonthEvents or YearEvents and 344 | cls_args is the list of arguments to pass to `cls` constructor. 345 | 346 | For example, the code for a custom handler for all active accounts 347 | using web or ios, could look like:: 348 | 349 | def active_web_ios(key, cls, cls_args): 350 | return cls("active", *cls_args) & ( 351 | cls("web", *cls_args) | cls("ios", *cls_args) 352 | ) 353 | 354 | 355 | set_custom_handler("active_web_ios", active_web_ios) 356 | 357 | And then use something like:: 358 | 359 | bitmapist_cohort.render_html_form( 360 | selections1=[ 361 | ..., 362 | ('Active on web or iOS', 'active_web_ios') 363 | ], 364 | ... 365 | ) 366 | """ 367 | CUSTOM_HANDLERS[event_name] = callback 368 | 369 | 370 | # --- Private 371 | 372 | 373 | def _dispatch(key: str, cls: type[GenericPeriodEvents], cls_args): 374 | if key in CUSTOM_HANDLERS: 375 | return CUSTOM_HANDLERS[key](key, cls, cls_args) 376 | return cls(key, *cls_args) 377 | 378 | 379 | def _day_events_fn(key: str, date: date, system: str): 380 | cls = DayEvents 381 | cls_args = (date.year, date.month, date.day, system) 382 | return _dispatch(key, cls, cls_args) 383 | 384 | 385 | def _weeks_events_fn(key: str, date: date, system: str): 386 | cls = WeekEvents 387 | cls_args = (date.year, date.isocalendar()[1], system) 388 | return _dispatch(key, cls, cls_args) 389 | 390 | 391 | def _month_events_fn(key: str, date: date, system: str): 392 | cls = MonthEvents 393 | cls_args = (date.year, date.month, system) 394 | return _dispatch(key, cls, cls_args) 395 | 396 | 397 | def _year_events_fn(key: str, date: date, system: str): 398 | cls = YearEvents 399 | cls_args = (date.year, system) 400 | return _dispatch(key, cls, cls_args) 401 | 402 | 403 | _LOOKUP: Optional[TemplateLookup] = None 404 | 405 | 406 | def get_lookup() -> TemplateLookup: 407 | global _LOOKUP 408 | 409 | if not _LOOKUP: 410 | file_path = path.dirname(path.abspath(__file__)) 411 | _LOOKUP = TemplateLookup( 412 | directories=[path.join(file_path, "tmpl")], encoding_errors="replace" 413 | ) 414 | 415 | return _LOOKUP 416 | 417 | 418 | __all__ = ["render_html_form", "render_html_data", "get_dates_data"] 419 | -------------------------------------------------------------------------------- /bitmapist/cohort/tmpl/form_data.mako: -------------------------------------------------------------------------------- 1 | 11 | 12 | 13 |
14 |
15 | Show me people who 16 | ${ render_options('select1', selections1, select1) } 17 | AND 18 | ${ render_options('select1b', selections1b, select1b) } 19 | and then came back to 20 | ${ render_options('select2', selections2, select2) } 21 | AND 22 | ${ render_options('select2b', selections2b, select2b) } 23 |
24 | 25 |
26 | 27 |
28 |
29 | Group By: 30 | 36 |
37 | 38 |
39 | As percent: 40 | 44 |
45 | 46 |
47 | Number of results: 48 | 60 |
61 | 62 |
63 | Number of rows: 64 | 71 |
72 | 73 |
74 | Start date: 75 | 76 |
77 |
78 | 79 |
80 |
81 | 82 |
83 |
84 |
85 | 86 | <%def name="render_options(select_name, selections, current_selection)"> 87 | 98 | 99 | -------------------------------------------------------------------------------- /bitmapist/cohort/tmpl/table_data.mako: -------------------------------------------------------------------------------- 1 | 38 | 39 | 40 | 41 | 42 | %for i in range(0, num_of_rows+1): 43 | 44 | %endfor 45 | 46 | 47 | %for row_data in dates_data: 48 | 49 | 65 | 66 | %for i in range(2, num_of_rows+3): 67 | <% 68 | data_entry = row_data[i] 69 | %> 70 | 71 | ## '' denotes no entry 72 | %if data_entry != '': 73 | %if as_percent: 74 | <% 75 | color = 'hsla(200, 100%%, 0%%, %s);' % (round(float(data_entry/100)+0.5, 1)) 76 | %> 77 | 80 | %else: 81 | 84 | %endif 85 | %else: 86 | 87 | %endif 88 | %endfor 89 | 90 | %endfor 91 | 92 | 93 | 94 | %for i in range(2, num_of_rows+3): 95 | <% 96 | counts = 0 97 | total = 0.0 98 | for row_data in dates_data: 99 | data_entry = row_data[i] 100 | 101 | # '' denotes no entry 102 | if data_entry != '': 103 | counts += 1 104 | total += data_entry 105 | 106 | if counts > 0: 107 | average = total / counts 108 | else: 109 | average = 0 110 | %> 111 | 112 | 119 | %endfor 120 | 121 |
${ i }
50 |
51 | %if time_group == 'months': 52 | ${ row_data[0].strftime('%d %b') } 53 | %elif time_group == 'weeks': 54 | ${ row_data[0].strftime('Week %U, %d %b') } 55 | %elif time_group == 'years': 56 | ${ row_data[0].strftime('%Y') } 57 | %else: 58 | ${ row_data[0].strftime('%d %b, %Y') } 59 | %endif 60 |
61 | 62 | ## Total count 63 |
${ row_data[1] }
64 |
78 | ${ round(data_entry, 2) }% 79 | 82 | ${ int(data_entry) } 83 |
113 | %if as_percent: 114 | ${ round(average, 2) }% 115 | %else: 116 | ${ int(average) } 117 | %endif 118 |
122 | 123 |
124 | Export as CSV 125 |
126 | -------------------------------------------------------------------------------- /bitmapist/cohort/tmpl/table_data_csv.mako: -------------------------------------------------------------------------------- 1 | Date,Total,${ ",".join(str(i) for i in range(0, num_of_rows+1)) } 2 | %for row_data in dates_data: 3 | <% 4 | if time_group == 'months': 5 | date = row_data[0].strftime('%d %b') 6 | elif time_group == 'weeks': 7 | date = row_data[0].strftime('Week %U %d %b') 8 | elif time_group == 'years': 9 | date = row_data[0].strftime('%Y') 10 | else: 11 | date = row_data[0].strftime('%d %b %Y') 12 | 13 | total_count = row_data[1] 14 | 15 | day_results = [] 16 | for i in range(2, num_of_rows+3): 17 | prct = row_data[i] 18 | if prct == '': 19 | day_results.append('') 20 | else: 21 | if as_percent: 22 | day_results.append(str(round(prct, 2))) 23 | else: 24 | day_results.append(str(int(prct))) 25 | %>\ 26 | ${ date },${ total_count },${ ','.join(day_results) } 27 | %endfor 28 | -------------------------------------------------------------------------------- /bitmapist/py.typed: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Doist/bitmapist/daea44564d3f9b7779826cd1b82c633dd7c064a1/bitmapist/py.typed -------------------------------------------------------------------------------- /poetry.lock: -------------------------------------------------------------------------------- 1 | # This file is automatically @generated by Poetry 2.1.1 and should not be changed by hand. 2 | 3 | [[package]] 4 | name = "async-timeout" 5 | version = "4.0.3" 6 | description = "Timeout context manager for asyncio programs" 7 | optional = false 8 | python-versions = ">=3.7" 9 | groups = ["main"] 10 | markers = "python_full_version < \"3.11.3\"" 11 | files = [ 12 | {file = "async-timeout-4.0.3.tar.gz", hash = "sha256:4640d96be84d82d02ed59ea2b7105a0f7b33abe8703703cd0ab0bf87c427522f"}, 13 | {file = "async_timeout-4.0.3-py3-none-any.whl", hash = "sha256:7405140ff1230c310e51dc27b3145b9092d659ce68ff733fb0cefe3ee42be028"}, 14 | ] 15 | 16 | [[package]] 17 | name = "cachetools" 18 | version = "5.3.2" 19 | description = "Extensible memoizing collections and decorators" 20 | optional = false 21 | python-versions = ">=3.7" 22 | groups = ["dev"] 23 | files = [ 24 | {file = "cachetools-5.3.2-py3-none-any.whl", hash = "sha256:861f35a13a451f94e301ce2bec7cac63e881232ccce7ed67fab9b5df4d3beaa1"}, 25 | {file = "cachetools-5.3.2.tar.gz", hash = "sha256:086ee420196f7b2ab9ca2db2520aca326318b68fe5ba8bc4d49cca91add450f2"}, 26 | ] 27 | 28 | [[package]] 29 | name = "cfgv" 30 | version = "3.4.0" 31 | description = "Validate configuration and produce human readable error messages." 32 | optional = false 33 | python-versions = ">=3.8" 34 | groups = ["dev"] 35 | files = [ 36 | {file = "cfgv-3.4.0-py2.py3-none-any.whl", hash = "sha256:b7265b1f29fd3316bfcd2b330d63d024f2bfd8bcb8b0272f8e19a504856c48f9"}, 37 | {file = "cfgv-3.4.0.tar.gz", hash = "sha256:e52591d4c5f5dead8e0f673fb16db7949d2cfb3f7da4582893288f0ded8fe560"}, 38 | ] 39 | 40 | [[package]] 41 | name = "chardet" 42 | version = "5.2.0" 43 | description = "Universal encoding detector for Python 3" 44 | optional = false 45 | python-versions = ">=3.7" 46 | groups = ["dev"] 47 | files = [ 48 | {file = "chardet-5.2.0-py3-none-any.whl", hash = "sha256:e1cf59446890a00105fe7b7912492ea04b6e6f06d4b742b2c788469e34c82970"}, 49 | {file = "chardet-5.2.0.tar.gz", hash = "sha256:1b3b6ff479a8c414bc3fa2c0852995695c4a026dcd6d0633b2dd092ca39c1cf7"}, 50 | ] 51 | 52 | [[package]] 53 | name = "colorama" 54 | version = "0.4.6" 55 | description = "Cross-platform colored terminal text." 56 | optional = false 57 | python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7" 58 | groups = ["dev"] 59 | files = [ 60 | {file = "colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6"}, 61 | {file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"}, 62 | ] 63 | 64 | [[package]] 65 | name = "distlib" 66 | version = "0.3.8" 67 | description = "Distribution utilities" 68 | optional = false 69 | python-versions = "*" 70 | groups = ["dev"] 71 | files = [ 72 | {file = "distlib-0.3.8-py2.py3-none-any.whl", hash = "sha256:034db59a0b96f8ca18035f36290806a9a6e6bd9d1ff91e45a7f172eb17e51784"}, 73 | {file = "distlib-0.3.8.tar.gz", hash = "sha256:1530ea13e350031b6312d8580ddb6b27a104275a31106523b8f123787f494f64"}, 74 | ] 75 | 76 | [[package]] 77 | name = "exceptiongroup" 78 | version = "1.2.0" 79 | description = "Backport of PEP 654 (exception groups)" 80 | optional = false 81 | python-versions = ">=3.7" 82 | groups = ["dev"] 83 | markers = "python_version < \"3.11\"" 84 | files = [ 85 | {file = "exceptiongroup-1.2.0-py3-none-any.whl", hash = "sha256:4bfd3996ac73b41e9b9628b04e079f193850720ea5945fc96a08633c66912f14"}, 86 | {file = "exceptiongroup-1.2.0.tar.gz", hash = "sha256:91f5c769735f051a4290d52edd0858999b57e5876e9f85937691bd4c9fa3ed68"}, 87 | ] 88 | 89 | [package.extras] 90 | test = ["pytest (>=6)"] 91 | 92 | [[package]] 93 | name = "filelock" 94 | version = "3.13.1" 95 | description = "A platform independent file lock." 96 | optional = false 97 | python-versions = ">=3.8" 98 | groups = ["dev"] 99 | files = [ 100 | {file = "filelock-3.13.1-py3-none-any.whl", hash = "sha256:57dbda9b35157b05fb3e58ee91448612eb674172fab98ee235ccb0b5bee19a1c"}, 101 | {file = "filelock-3.13.1.tar.gz", hash = "sha256:521f5f56c50f8426f5e03ad3b281b490a87ef15bc6c526f168290f0c7148d44e"}, 102 | ] 103 | 104 | [package.extras] 105 | docs = ["furo (>=2023.9.10)", "sphinx (>=7.2.6)", "sphinx-autodoc-typehints (>=1.24)"] 106 | testing = ["covdefaults (>=2.3)", "coverage (>=7.3.2)", "diff-cover (>=8)", "pytest (>=7.4.3)", "pytest-cov (>=4.1)", "pytest-mock (>=3.12)", "pytest-timeout (>=2.2)"] 107 | typing = ["typing-extensions (>=4.8) ; python_version < \"3.11\""] 108 | 109 | [[package]] 110 | name = "future" 111 | version = "0.18.3" 112 | description = "Clean single-source support for Python 3 and 2" 113 | optional = false 114 | python-versions = ">=2.6, !=3.0.*, !=3.1.*, !=3.2.*" 115 | groups = ["main"] 116 | files = [ 117 | {file = "future-0.18.3.tar.gz", hash = "sha256:34a17436ed1e96697a86f9de3d15a3b0be01d8bc8de9c1dffd59fb8234ed5307"}, 118 | ] 119 | 120 | [[package]] 121 | name = "identify" 122 | version = "2.5.33" 123 | description = "File identification library for Python" 124 | optional = false 125 | python-versions = ">=3.8" 126 | groups = ["dev"] 127 | files = [ 128 | {file = "identify-2.5.33-py2.py3-none-any.whl", hash = "sha256:d40ce5fcd762817627670da8a7d8d8e65f24342d14539c59488dc603bf662e34"}, 129 | {file = "identify-2.5.33.tar.gz", hash = "sha256:161558f9fe4559e1557e1bff323e8631f6a0e4837f7497767c1782832f16b62d"}, 130 | ] 131 | 132 | [package.extras] 133 | license = ["ukkonen"] 134 | 135 | [[package]] 136 | name = "iniconfig" 137 | version = "2.0.0" 138 | description = "brain-dead simple config-ini parsing" 139 | optional = false 140 | python-versions = ">=3.7" 141 | groups = ["dev"] 142 | files = [ 143 | {file = "iniconfig-2.0.0-py3-none-any.whl", hash = "sha256:b6a85871a79d2e3b22d2d1b94ac2824226a63c6b741c88f7ae975f18b6778374"}, 144 | {file = "iniconfig-2.0.0.tar.gz", hash = "sha256:2d91e135bf72d31a410b17c16da610a82cb55f6b0477d1a902134b24a455b8b3"}, 145 | ] 146 | 147 | [[package]] 148 | name = "mako" 149 | version = "1.3.0" 150 | description = "A super-fast templating language that borrows the best ideas from the existing templating languages." 151 | optional = false 152 | python-versions = ">=3.8" 153 | groups = ["main"] 154 | files = [ 155 | {file = "Mako-1.3.0-py3-none-any.whl", hash = "sha256:57d4e997349f1a92035aa25c17ace371a4213f2ca42f99bee9a602500cfd54d9"}, 156 | {file = "Mako-1.3.0.tar.gz", hash = "sha256:e3a9d388fd00e87043edbe8792f45880ac0114e9c4adc69f6e9bfb2c55e3b11b"}, 157 | ] 158 | 159 | [package.dependencies] 160 | MarkupSafe = ">=0.9.2" 161 | 162 | [package.extras] 163 | babel = ["Babel"] 164 | lingua = ["lingua"] 165 | testing = ["pytest"] 166 | 167 | [[package]] 168 | name = "markupsafe" 169 | version = "2.1.3" 170 | description = "Safely add untrusted strings to HTML/XML markup." 171 | optional = false 172 | python-versions = ">=3.7" 173 | groups = ["main"] 174 | files = [ 175 | {file = "MarkupSafe-2.1.3-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:cd0f502fe016460680cd20aaa5a76d241d6f35a1c3350c474bac1273803893fa"}, 176 | {file = "MarkupSafe-2.1.3-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:e09031c87a1e51556fdcb46e5bd4f59dfb743061cf93c4d6831bf894f125eb57"}, 177 | {file = "MarkupSafe-2.1.3-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:68e78619a61ecf91e76aa3e6e8e33fc4894a2bebe93410754bd28fce0a8a4f9f"}, 178 | {file = "MarkupSafe-2.1.3-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:65c1a9bcdadc6c28eecee2c119465aebff8f7a584dd719facdd9e825ec61ab52"}, 179 | {file = "MarkupSafe-2.1.3-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:525808b8019e36eb524b8c68acdd63a37e75714eac50e988180b169d64480a00"}, 180 | {file = "MarkupSafe-2.1.3-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:962f82a3086483f5e5f64dbad880d31038b698494799b097bc59c2edf392fce6"}, 181 | {file = "MarkupSafe-2.1.3-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:aa7bd130efab1c280bed0f45501b7c8795f9fdbeb02e965371bbef3523627779"}, 182 | {file = "MarkupSafe-2.1.3-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:c9c804664ebe8f83a211cace637506669e7890fec1b4195b505c214e50dd4eb7"}, 183 | {file = "MarkupSafe-2.1.3-cp310-cp310-win32.whl", hash = "sha256:10bbfe99883db80bdbaff2dcf681dfc6533a614f700da1287707e8a5d78a8431"}, 184 | {file = "MarkupSafe-2.1.3-cp310-cp310-win_amd64.whl", hash = "sha256:1577735524cdad32f9f694208aa75e422adba74f1baee7551620e43a3141f559"}, 185 | {file = "MarkupSafe-2.1.3-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:ad9e82fb8f09ade1c3e1b996a6337afac2b8b9e365f926f5a61aacc71adc5b3c"}, 186 | {file = "MarkupSafe-2.1.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:3c0fae6c3be832a0a0473ac912810b2877c8cb9d76ca48de1ed31e1c68386575"}, 187 | {file = "MarkupSafe-2.1.3-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b076b6226fb84157e3f7c971a47ff3a679d837cf338547532ab866c57930dbee"}, 188 | {file = "MarkupSafe-2.1.3-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bfce63a9e7834b12b87c64d6b155fdd9b3b96191b6bd334bf37db7ff1fe457f2"}, 189 | {file = "MarkupSafe-2.1.3-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:338ae27d6b8745585f87218a3f23f1512dbf52c26c28e322dbe54bcede54ccb9"}, 190 | {file = "MarkupSafe-2.1.3-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:e4dd52d80b8c83fdce44e12478ad2e85c64ea965e75d66dbeafb0a3e77308fcc"}, 191 | {file = "MarkupSafe-2.1.3-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:df0be2b576a7abbf737b1575f048c23fb1d769f267ec4358296f31c2479db8f9"}, 192 | {file = "MarkupSafe-2.1.3-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:5bbe06f8eeafd38e5d0a4894ffec89378b6c6a625ff57e3028921f8ff59318ac"}, 193 | {file = "MarkupSafe-2.1.3-cp311-cp311-win32.whl", hash = "sha256:dd15ff04ffd7e05ffcb7fe79f1b98041b8ea30ae9234aed2a9168b5797c3effb"}, 194 | {file = "MarkupSafe-2.1.3-cp311-cp311-win_amd64.whl", hash = "sha256:134da1eca9ec0ae528110ccc9e48041e0828d79f24121a1a146161103c76e686"}, 195 | {file = "MarkupSafe-2.1.3-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:f698de3fd0c4e6972b92290a45bd9b1536bffe8c6759c62471efaa8acb4c37bc"}, 196 | {file = "MarkupSafe-2.1.3-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:aa57bd9cf8ae831a362185ee444e15a93ecb2e344c8e52e4d721ea3ab6ef1823"}, 197 | {file = "MarkupSafe-2.1.3-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ffcc3f7c66b5f5b7931a5aa68fc9cecc51e685ef90282f4a82f0f5e9b704ad11"}, 198 | {file = "MarkupSafe-2.1.3-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:47d4f1c5f80fc62fdd7777d0d40a2e9dda0a05883ab11374334f6c4de38adffd"}, 199 | {file = "MarkupSafe-2.1.3-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1f67c7038d560d92149c060157d623c542173016c4babc0c1913cca0564b9939"}, 200 | {file = "MarkupSafe-2.1.3-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:9aad3c1755095ce347e26488214ef77e0485a3c34a50c5a5e2471dff60b9dd9c"}, 201 | {file = "MarkupSafe-2.1.3-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:14ff806850827afd6b07a5f32bd917fb7f45b046ba40c57abdb636674a8b559c"}, 202 | {file = "MarkupSafe-2.1.3-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:8f9293864fe09b8149f0cc42ce56e3f0e54de883a9de90cd427f191c346eb2e1"}, 203 | {file = "MarkupSafe-2.1.3-cp312-cp312-win32.whl", hash = "sha256:715d3562f79d540f251b99ebd6d8baa547118974341db04f5ad06d5ea3eb8007"}, 204 | {file = "MarkupSafe-2.1.3-cp312-cp312-win_amd64.whl", hash = "sha256:1b8dd8c3fd14349433c79fa8abeb573a55fc0fdd769133baac1f5e07abf54aeb"}, 205 | {file = "MarkupSafe-2.1.3-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:8e254ae696c88d98da6555f5ace2279cf7cd5b3f52be2b5cf97feafe883b58d2"}, 206 | {file = "MarkupSafe-2.1.3-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:cb0932dc158471523c9637e807d9bfb93e06a95cbf010f1a38b98623b929ef2b"}, 207 | {file = "MarkupSafe-2.1.3-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9402b03f1a1b4dc4c19845e5c749e3ab82d5078d16a2a4c2cd2df62d57bb0707"}, 208 | {file = "MarkupSafe-2.1.3-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ca379055a47383d02a5400cb0d110cef0a776fc644cda797db0c5696cfd7e18e"}, 209 | {file = "MarkupSafe-2.1.3-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:b7ff0f54cb4ff66dd38bebd335a38e2c22c41a8ee45aa608efc890ac3e3931bc"}, 210 | {file = "MarkupSafe-2.1.3-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:c011a4149cfbcf9f03994ec2edffcb8b1dc2d2aede7ca243746df97a5d41ce48"}, 211 | {file = "MarkupSafe-2.1.3-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:56d9f2ecac662ca1611d183feb03a3fa4406469dafe241673d521dd5ae92a155"}, 212 | {file = "MarkupSafe-2.1.3-cp37-cp37m-win32.whl", hash = "sha256:8758846a7e80910096950b67071243da3e5a20ed2546e6392603c096778d48e0"}, 213 | {file = "MarkupSafe-2.1.3-cp37-cp37m-win_amd64.whl", hash = "sha256:787003c0ddb00500e49a10f2844fac87aa6ce977b90b0feaaf9de23c22508b24"}, 214 | {file = "MarkupSafe-2.1.3-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:2ef12179d3a291be237280175b542c07a36e7f60718296278d8593d21ca937d4"}, 215 | {file = "MarkupSafe-2.1.3-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:2c1b19b3aaacc6e57b7e25710ff571c24d6c3613a45e905b1fde04d691b98ee0"}, 216 | {file = "MarkupSafe-2.1.3-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8afafd99945ead6e075b973fefa56379c5b5c53fd8937dad92c662da5d8fd5ee"}, 217 | {file = "MarkupSafe-2.1.3-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8c41976a29d078bb235fea9b2ecd3da465df42a562910f9022f1a03107bd02be"}, 218 | {file = "MarkupSafe-2.1.3-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d080e0a5eb2529460b30190fcfcc4199bd7f827663f858a226a81bc27beaa97e"}, 219 | {file = "MarkupSafe-2.1.3-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:69c0f17e9f5a7afdf2cc9fb2d1ce6aabdb3bafb7f38017c0b77862bcec2bbad8"}, 220 | {file = "MarkupSafe-2.1.3-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:504b320cd4b7eff6f968eddf81127112db685e81f7e36e75f9f84f0df46041c3"}, 221 | {file = "MarkupSafe-2.1.3-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:42de32b22b6b804f42c5d98be4f7e5e977ecdd9ee9b660fda1a3edf03b11792d"}, 222 | {file = "MarkupSafe-2.1.3-cp38-cp38-win32.whl", hash = "sha256:ceb01949af7121f9fc39f7d27f91be8546f3fb112c608bc4029aef0bab86a2a5"}, 223 | {file = "MarkupSafe-2.1.3-cp38-cp38-win_amd64.whl", hash = "sha256:1b40069d487e7edb2676d3fbdb2b0829ffa2cd63a2ec26c4938b2d34391b4ecc"}, 224 | {file = "MarkupSafe-2.1.3-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:8023faf4e01efadfa183e863fefde0046de576c6f14659e8782065bcece22198"}, 225 | {file = "MarkupSafe-2.1.3-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:6b2b56950d93e41f33b4223ead100ea0fe11f8e6ee5f641eb753ce4b77a7042b"}, 226 | {file = "MarkupSafe-2.1.3-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9dcdfd0eaf283af041973bff14a2e143b8bd64e069f4c383416ecd79a81aab58"}, 227 | {file = "MarkupSafe-2.1.3-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:05fb21170423db021895e1ea1e1f3ab3adb85d1c2333cbc2310f2a26bc77272e"}, 228 | {file = "MarkupSafe-2.1.3-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:282c2cb35b5b673bbcadb33a585408104df04f14b2d9b01d4c345a3b92861c2c"}, 229 | {file = "MarkupSafe-2.1.3-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:ab4a0df41e7c16a1392727727e7998a467472d0ad65f3ad5e6e765015df08636"}, 230 | {file = "MarkupSafe-2.1.3-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:7ef3cb2ebbf91e330e3bb937efada0edd9003683db6b57bb108c4001f37a02ea"}, 231 | {file = "MarkupSafe-2.1.3-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:0a4e4a1aff6c7ac4cd55792abf96c915634c2b97e3cc1c7129578aa68ebd754e"}, 232 | {file = "MarkupSafe-2.1.3-cp39-cp39-win32.whl", hash = "sha256:fec21693218efe39aa7f8599346e90c705afa52c5b31ae019b2e57e8f6542bb2"}, 233 | {file = "MarkupSafe-2.1.3-cp39-cp39-win_amd64.whl", hash = "sha256:3fd4abcb888d15a94f32b75d8fd18ee162ca0c064f35b11134be77050296d6ba"}, 234 | {file = "MarkupSafe-2.1.3.tar.gz", hash = "sha256:af598ed32d6ae86f1b747b82783958b1a4ab8f617b06fe68795c7f026abbdcad"}, 235 | ] 236 | 237 | [[package]] 238 | name = "mypy" 239 | version = "1.10.0" 240 | description = "Optional static typing for Python" 241 | optional = false 242 | python-versions = ">=3.8" 243 | groups = ["dev"] 244 | files = [ 245 | {file = "mypy-1.10.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:da1cbf08fb3b851ab3b9523a884c232774008267b1f83371ace57f412fe308c2"}, 246 | {file = "mypy-1.10.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:12b6bfc1b1a66095ab413160a6e520e1dc076a28f3e22f7fb25ba3b000b4ef99"}, 247 | {file = "mypy-1.10.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9e36fb078cce9904c7989b9693e41cb9711e0600139ce3970c6ef814b6ebc2b2"}, 248 | {file = "mypy-1.10.0-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:2b0695d605ddcd3eb2f736cd8b4e388288c21e7de85001e9f85df9187f2b50f9"}, 249 | {file = "mypy-1.10.0-cp310-cp310-win_amd64.whl", hash = "sha256:cd777b780312ddb135bceb9bc8722a73ec95e042f911cc279e2ec3c667076051"}, 250 | {file = "mypy-1.10.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:3be66771aa5c97602f382230165b856c231d1277c511c9a8dd058be4784472e1"}, 251 | {file = "mypy-1.10.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:8b2cbaca148d0754a54d44121b5825ae71868c7592a53b7292eeb0f3fdae95ee"}, 252 | {file = "mypy-1.10.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1ec404a7cbe9fc0e92cb0e67f55ce0c025014e26d33e54d9e506a0f2d07fe5de"}, 253 | {file = "mypy-1.10.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:e22e1527dc3d4aa94311d246b59e47f6455b8729f4968765ac1eacf9a4760bc7"}, 254 | {file = "mypy-1.10.0-cp311-cp311-win_amd64.whl", hash = "sha256:a87dbfa85971e8d59c9cc1fcf534efe664d8949e4c0b6b44e8ca548e746a8d53"}, 255 | {file = "mypy-1.10.0-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:a781f6ad4bab20eef8b65174a57e5203f4be627b46291f4589879bf4e257b97b"}, 256 | {file = "mypy-1.10.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:b808e12113505b97d9023b0b5e0c0705a90571c6feefc6f215c1df9381256e30"}, 257 | {file = "mypy-1.10.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8f55583b12156c399dce2df7d16f8a5095291354f1e839c252ec6c0611e86e2e"}, 258 | {file = "mypy-1.10.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:4cf18f9d0efa1b16478c4c129eabec36148032575391095f73cae2e722fcf9d5"}, 259 | {file = "mypy-1.10.0-cp312-cp312-win_amd64.whl", hash = "sha256:bc6ac273b23c6b82da3bb25f4136c4fd42665f17f2cd850771cb600bdd2ebeda"}, 260 | {file = "mypy-1.10.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:9fd50226364cd2737351c79807775136b0abe084433b55b2e29181a4c3c878c0"}, 261 | {file = "mypy-1.10.0-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:f90cff89eea89273727d8783fef5d4a934be2fdca11b47def50cf5d311aff727"}, 262 | {file = "mypy-1.10.0-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fcfc70599efde5c67862a07a1aaf50e55bce629ace26bb19dc17cece5dd31ca4"}, 263 | {file = "mypy-1.10.0-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:075cbf81f3e134eadaf247de187bd604748171d6b79736fa9b6c9685b4083061"}, 264 | {file = "mypy-1.10.0-cp38-cp38-win_amd64.whl", hash = "sha256:3f298531bca95ff615b6e9f2fc0333aae27fa48052903a0ac90215021cdcfa4f"}, 265 | {file = "mypy-1.10.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:fa7ef5244615a2523b56c034becde4e9e3f9b034854c93639adb667ec9ec2976"}, 266 | {file = "mypy-1.10.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:3236a4c8f535a0631f85f5fcdffba71c7feeef76a6002fcba7c1a8e57c8be1ec"}, 267 | {file = "mypy-1.10.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4a2b5cdbb5dd35aa08ea9114436e0d79aceb2f38e32c21684dcf8e24e1e92821"}, 268 | {file = "mypy-1.10.0-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:92f93b21c0fe73dc00abf91022234c79d793318b8a96faac147cd579c1671746"}, 269 | {file = "mypy-1.10.0-cp39-cp39-win_amd64.whl", hash = "sha256:28d0e038361b45f099cc086d9dd99c15ff14d0188f44ac883010e172ce86c38a"}, 270 | {file = "mypy-1.10.0-py3-none-any.whl", hash = "sha256:f8c083976eb530019175aabadb60921e73b4f45736760826aa1689dda8208aee"}, 271 | {file = "mypy-1.10.0.tar.gz", hash = "sha256:3d087fcbec056c4ee34974da493a826ce316947485cef3901f511848e687c131"}, 272 | ] 273 | 274 | [package.dependencies] 275 | mypy-extensions = ">=1.0.0" 276 | tomli = {version = ">=1.1.0", markers = "python_version < \"3.11\""} 277 | typing-extensions = ">=4.1.0" 278 | 279 | [package.extras] 280 | dmypy = ["psutil (>=4.0)"] 281 | install-types = ["pip"] 282 | mypyc = ["setuptools (>=50)"] 283 | reports = ["lxml"] 284 | 285 | [[package]] 286 | name = "mypy-extensions" 287 | version = "1.0.0" 288 | description = "Type system extensions for programs checked with the mypy type checker." 289 | optional = false 290 | python-versions = ">=3.5" 291 | groups = ["dev"] 292 | files = [ 293 | {file = "mypy_extensions-1.0.0-py3-none-any.whl", hash = "sha256:4392f6c0eb8a5668a69e23d168ffa70f0be9ccfd32b5cc2d26a34ae5b844552d"}, 294 | {file = "mypy_extensions-1.0.0.tar.gz", hash = "sha256:75dbf8955dc00442a438fc4d0666508a9a97b6bd41aa2f0ffe9d2f2725af0782"}, 295 | ] 296 | 297 | [[package]] 298 | name = "nodeenv" 299 | version = "1.8.0" 300 | description = "Node.js virtual environment builder" 301 | optional = false 302 | python-versions = ">=2.7,!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*" 303 | groups = ["dev"] 304 | files = [ 305 | {file = "nodeenv-1.8.0-py2.py3-none-any.whl", hash = "sha256:df865724bb3c3adc86b3876fa209771517b0cfe596beff01a92700e0e8be4cec"}, 306 | {file = "nodeenv-1.8.0.tar.gz", hash = "sha256:d51e0c37e64fbf47d017feac3145cdbb58836d7eee8c6f6d3b6880c5456227d2"}, 307 | ] 308 | 309 | [package.dependencies] 310 | setuptools = "*" 311 | 312 | [[package]] 313 | name = "packaging" 314 | version = "23.2" 315 | description = "Core utilities for Python packages" 316 | optional = false 317 | python-versions = ">=3.7" 318 | groups = ["dev"] 319 | files = [ 320 | {file = "packaging-23.2-py3-none-any.whl", hash = "sha256:8c491190033a9af7e1d931d0b5dacc2ef47509b34dd0de67ed209b5203fc88c7"}, 321 | {file = "packaging-23.2.tar.gz", hash = "sha256:048fb0e9405036518eaaf48a55953c750c11e1a1b68e0dd1a9d62ed0c092cfc5"}, 322 | ] 323 | 324 | [[package]] 325 | name = "platformdirs" 326 | version = "4.1.0" 327 | description = "A small Python package for determining appropriate platform-specific dirs, e.g. a \"user data dir\"." 328 | optional = false 329 | python-versions = ">=3.8" 330 | groups = ["dev"] 331 | files = [ 332 | {file = "platformdirs-4.1.0-py3-none-any.whl", hash = "sha256:11c8f37bcca40db96d8144522d925583bdb7a31f7b0e37e3ed4318400a8e2380"}, 333 | {file = "platformdirs-4.1.0.tar.gz", hash = "sha256:906d548203468492d432bcb294d4bc2fff751bf84971fbb2c10918cc206ee420"}, 334 | ] 335 | 336 | [package.extras] 337 | docs = ["furo (>=2023.7.26)", "proselint (>=0.13)", "sphinx (>=7.1.1)", "sphinx-autodoc-typehints (>=1.24)"] 338 | test = ["appdirs (==1.4.4)", "covdefaults (>=2.3)", "pytest (>=7.4)", "pytest-cov (>=4.1)", "pytest-mock (>=3.11.1)"] 339 | 340 | [[package]] 341 | name = "pluggy" 342 | version = "1.3.0" 343 | description = "plugin and hook calling mechanisms for python" 344 | optional = false 345 | python-versions = ">=3.8" 346 | groups = ["dev"] 347 | files = [ 348 | {file = "pluggy-1.3.0-py3-none-any.whl", hash = "sha256:d89c696a773f8bd377d18e5ecda92b7a3793cbe66c87060a6fb58c7b6e1061f7"}, 349 | {file = "pluggy-1.3.0.tar.gz", hash = "sha256:cf61ae8f126ac6f7c451172cf30e3e43d3ca77615509771b3a984a0730651e12"}, 350 | ] 351 | 352 | [package.extras] 353 | dev = ["pre-commit", "tox"] 354 | testing = ["pytest", "pytest-benchmark"] 355 | 356 | [[package]] 357 | name = "pre-commit" 358 | version = "3.6.0" 359 | description = "A framework for managing and maintaining multi-language pre-commit hooks." 360 | optional = false 361 | python-versions = ">=3.9" 362 | groups = ["dev"] 363 | files = [ 364 | {file = "pre_commit-3.6.0-py2.py3-none-any.whl", hash = "sha256:c255039ef399049a5544b6ce13d135caba8f2c28c3b4033277a788f434308376"}, 365 | {file = "pre_commit-3.6.0.tar.gz", hash = "sha256:d30bad9abf165f7785c15a21a1f46da7d0677cb00ee7ff4c579fd38922efe15d"}, 366 | ] 367 | 368 | [package.dependencies] 369 | cfgv = ">=2.0.0" 370 | identify = ">=1.0.0" 371 | nodeenv = ">=0.11.1" 372 | pyyaml = ">=5.1" 373 | virtualenv = ">=20.10.0" 374 | 375 | [[package]] 376 | name = "pyproject-api" 377 | version = "1.6.1" 378 | description = "API to interact with the python pyproject.toml based projects" 379 | optional = false 380 | python-versions = ">=3.8" 381 | groups = ["dev"] 382 | files = [ 383 | {file = "pyproject_api-1.6.1-py3-none-any.whl", hash = "sha256:4c0116d60476b0786c88692cf4e325a9814965e2469c5998b830bba16b183675"}, 384 | {file = "pyproject_api-1.6.1.tar.gz", hash = "sha256:1817dc018adc0d1ff9ca1ed8c60e1623d5aaca40814b953af14a9cf9a5cae538"}, 385 | ] 386 | 387 | [package.dependencies] 388 | packaging = ">=23.1" 389 | tomli = {version = ">=2.0.1", markers = "python_version < \"3.11\""} 390 | 391 | [package.extras] 392 | docs = ["furo (>=2023.8.19)", "sphinx (<7.2)", "sphinx-autodoc-typehints (>=1.24)"] 393 | testing = ["covdefaults (>=2.3)", "pytest (>=7.4)", "pytest-cov (>=4.1)", "pytest-mock (>=3.11.1)", "setuptools (>=68.1.2)", "wheel (>=0.41.2)"] 394 | 395 | [[package]] 396 | name = "pytest" 397 | version = "7.4.4" 398 | description = "pytest: simple powerful testing with Python" 399 | optional = false 400 | python-versions = ">=3.7" 401 | groups = ["dev"] 402 | files = [ 403 | {file = "pytest-7.4.4-py3-none-any.whl", hash = "sha256:b090cdf5ed60bf4c45261be03239c2c1c22df034fbffe691abe93cd80cea01d8"}, 404 | {file = "pytest-7.4.4.tar.gz", hash = "sha256:2cf0005922c6ace4a3e2ec8b4080eb0d9753fdc93107415332f50ce9e7994280"}, 405 | ] 406 | 407 | [package.dependencies] 408 | colorama = {version = "*", markers = "sys_platform == \"win32\""} 409 | exceptiongroup = {version = ">=1.0.0rc8", markers = "python_version < \"3.11\""} 410 | iniconfig = "*" 411 | packaging = "*" 412 | pluggy = ">=0.12,<2.0" 413 | tomli = {version = ">=1.0.0", markers = "python_version < \"3.11\""} 414 | 415 | [package.extras] 416 | testing = ["argcomplete", "attrs (>=19.2.0)", "hypothesis (>=3.56)", "mock", "nose", "pygments (>=2.7.2)", "requests", "setuptools", "xmlschema"] 417 | 418 | [[package]] 419 | name = "pytest-runner" 420 | version = "6.0.1" 421 | description = "Invoke py.test as distutils command with dependency resolution" 422 | optional = false 423 | python-versions = ">=3.7" 424 | groups = ["dev"] 425 | files = [ 426 | {file = "pytest-runner-6.0.1.tar.gz", hash = "sha256:70d4739585a7008f37bf4933c013fdb327b8878a5a69fcbb3316c88882f0f49b"}, 427 | {file = "pytest_runner-6.0.1-py3-none-any.whl", hash = "sha256:ea326ed6f6613992746062362efab70212089a4209c08d67177b3df1c52cd9f2"}, 428 | ] 429 | 430 | [package.extras] 431 | docs = ["jaraco.packaging (>=9)", "jaraco.tidelift (>=1.4)", "rst.linker (>=1.9)", "sphinx"] 432 | testing = ["pytest (>=6)", "pytest-black (>=0.3.7) ; platform_python_implementation != \"PyPy\"", "pytest-checkdocs (>=2.4)", "pytest-cov", "pytest-enabler (>=1.0.1)", "pytest-flake8", "pytest-mypy (>=0.9.1) ; platform_python_implementation != \"PyPy\"", "pytest-virtualenv", "types-setuptools"] 433 | 434 | [[package]] 435 | name = "python-dateutil" 436 | version = "2.8.2" 437 | description = "Extensions to the standard Python datetime module" 438 | optional = false 439 | python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,>=2.7" 440 | groups = ["main"] 441 | files = [ 442 | {file = "python-dateutil-2.8.2.tar.gz", hash = "sha256:0123cacc1627ae19ddf3c27a5de5bd67ee4586fbdd6440d9748f8abb483d3e86"}, 443 | {file = "python_dateutil-2.8.2-py2.py3-none-any.whl", hash = "sha256:961d03dc3453ebbc59dbdea9e4e11c5651520a876d0f4db161e8674aae935da9"}, 444 | ] 445 | 446 | [package.dependencies] 447 | six = ">=1.5" 448 | 449 | [[package]] 450 | name = "pyyaml" 451 | version = "6.0.1" 452 | description = "YAML parser and emitter for Python" 453 | optional = false 454 | python-versions = ">=3.6" 455 | groups = ["dev"] 456 | files = [ 457 | {file = "PyYAML-6.0.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:d858aa552c999bc8a8d57426ed01e40bef403cd8ccdd0fc5f6f04a00414cac2a"}, 458 | {file = "PyYAML-6.0.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:fd66fc5d0da6d9815ba2cebeb4205f95818ff4b79c3ebe268e75d961704af52f"}, 459 | {file = "PyYAML-6.0.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:69b023b2b4daa7548bcfbd4aa3da05b3a74b772db9e23b982788168117739938"}, 460 | {file = "PyYAML-6.0.1-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:81e0b275a9ecc9c0c0c07b4b90ba548307583c125f54d5b6946cfee6360c733d"}, 461 | {file = "PyYAML-6.0.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ba336e390cd8e4d1739f42dfe9bb83a3cc2e80f567d8805e11b46f4a943f5515"}, 462 | {file = "PyYAML-6.0.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:326c013efe8048858a6d312ddd31d56e468118ad4cdeda36c719bf5bb6192290"}, 463 | {file = "PyYAML-6.0.1-cp310-cp310-win32.whl", hash = "sha256:bd4af7373a854424dabd882decdc5579653d7868b8fb26dc7d0e99f823aa5924"}, 464 | {file = "PyYAML-6.0.1-cp310-cp310-win_amd64.whl", hash = "sha256:fd1592b3fdf65fff2ad0004b5e363300ef59ced41c2e6b3a99d4089fa8c5435d"}, 465 | {file = "PyYAML-6.0.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:6965a7bc3cf88e5a1c3bd2e0b5c22f8d677dc88a455344035f03399034eb3007"}, 466 | {file = "PyYAML-6.0.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:f003ed9ad21d6a4713f0a9b5a7a0a79e08dd0f221aff4525a2be4c346ee60aab"}, 467 | {file = "PyYAML-6.0.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:42f8152b8dbc4fe7d96729ec2b99c7097d656dc1213a3229ca5383f973a5ed6d"}, 468 | {file = "PyYAML-6.0.1-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:062582fca9fabdd2c8b54a3ef1c978d786e0f6b3a1510e0ac93ef59e0ddae2bc"}, 469 | {file = "PyYAML-6.0.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d2b04aac4d386b172d5b9692e2d2da8de7bfb6c387fa4f801fbf6fb2e6ba4673"}, 470 | {file = "PyYAML-6.0.1-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:e7d73685e87afe9f3b36c799222440d6cf362062f78be1013661b00c5c6f678b"}, 471 | {file = "PyYAML-6.0.1-cp311-cp311-win32.whl", hash = "sha256:1635fd110e8d85d55237ab316b5b011de701ea0f29d07611174a1b42f1444741"}, 472 | {file = "PyYAML-6.0.1-cp311-cp311-win_amd64.whl", hash = "sha256:bf07ee2fef7014951eeb99f56f39c9bb4af143d8aa3c21b1677805985307da34"}, 473 | {file = "PyYAML-6.0.1-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:855fb52b0dc35af121542a76b9a84f8d1cd886ea97c84703eaa6d88e37a2ad28"}, 474 | {file = "PyYAML-6.0.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:40df9b996c2b73138957fe23a16a4f0ba614f4c0efce1e9406a184b6d07fa3a9"}, 475 | {file = "PyYAML-6.0.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a08c6f0fe150303c1c6b71ebcd7213c2858041a7e01975da3a99aed1e7a378ef"}, 476 | {file = "PyYAML-6.0.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6c22bec3fbe2524cde73d7ada88f6566758a8f7227bfbf93a408a9d86bcc12a0"}, 477 | {file = "PyYAML-6.0.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:8d4e9c88387b0f5c7d5f281e55304de64cf7f9c0021a3525bd3b1c542da3b0e4"}, 478 | {file = "PyYAML-6.0.1-cp312-cp312-win32.whl", hash = "sha256:d483d2cdf104e7c9fa60c544d92981f12ad66a457afae824d146093b8c294c54"}, 479 | {file = "PyYAML-6.0.1-cp312-cp312-win_amd64.whl", hash = "sha256:0d3304d8c0adc42be59c5f8a4d9e3d7379e6955ad754aa9d6ab7a398b59dd1df"}, 480 | {file = "PyYAML-6.0.1-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:50550eb667afee136e9a77d6dc71ae76a44df8b3e51e41b77f6de2932bfe0f47"}, 481 | {file = "PyYAML-6.0.1-cp36-cp36m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1fe35611261b29bd1de0070f0b2f47cb6ff71fa6595c077e42bd0c419fa27b98"}, 482 | {file = "PyYAML-6.0.1-cp36-cp36m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:704219a11b772aea0d8ecd7058d0082713c3562b4e271b849ad7dc4a5c90c13c"}, 483 | {file = "PyYAML-6.0.1-cp36-cp36m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:afd7e57eddb1a54f0f1a974bc4391af8bcce0b444685d936840f125cf046d5bd"}, 484 | {file = "PyYAML-6.0.1-cp36-cp36m-win32.whl", hash = "sha256:fca0e3a251908a499833aa292323f32437106001d436eca0e6e7833256674585"}, 485 | {file = "PyYAML-6.0.1-cp36-cp36m-win_amd64.whl", hash = "sha256:f22ac1c3cac4dbc50079e965eba2c1058622631e526bd9afd45fedd49ba781fa"}, 486 | {file = "PyYAML-6.0.1-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:b1275ad35a5d18c62a7220633c913e1b42d44b46ee12554e5fd39c70a243d6a3"}, 487 | {file = "PyYAML-6.0.1-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:18aeb1bf9a78867dc38b259769503436b7c72f7a1f1f4c93ff9a17de54319b27"}, 488 | {file = "PyYAML-6.0.1-cp37-cp37m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:596106435fa6ad000c2991a98fa58eeb8656ef2325d7e158344fb33864ed87e3"}, 489 | {file = "PyYAML-6.0.1-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:baa90d3f661d43131ca170712d903e6295d1f7a0f595074f151c0aed377c9b9c"}, 490 | {file = "PyYAML-6.0.1-cp37-cp37m-win32.whl", hash = "sha256:9046c58c4395dff28dd494285c82ba00b546adfc7ef001486fbf0324bc174fba"}, 491 | {file = "PyYAML-6.0.1-cp37-cp37m-win_amd64.whl", hash = "sha256:4fb147e7a67ef577a588a0e2c17b6db51dda102c71de36f8549b6816a96e1867"}, 492 | {file = "PyYAML-6.0.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:1d4c7e777c441b20e32f52bd377e0c409713e8bb1386e1099c2415f26e479595"}, 493 | {file = "PyYAML-6.0.1-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a0cd17c15d3bb3fa06978b4e8958dcdc6e0174ccea823003a106c7d4d7899ac5"}, 494 | {file = "PyYAML-6.0.1-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:28c119d996beec18c05208a8bd78cbe4007878c6dd15091efb73a30e90539696"}, 495 | {file = "PyYAML-6.0.1-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7e07cbde391ba96ab58e532ff4803f79c4129397514e1413a7dc761ccd755735"}, 496 | {file = "PyYAML-6.0.1-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:49a183be227561de579b4a36efbb21b3eab9651dd81b1858589f796549873dd6"}, 497 | {file = "PyYAML-6.0.1-cp38-cp38-win32.whl", hash = "sha256:184c5108a2aca3c5b3d3bf9395d50893a7ab82a38004c8f61c258d4428e80206"}, 498 | {file = "PyYAML-6.0.1-cp38-cp38-win_amd64.whl", hash = "sha256:1e2722cc9fbb45d9b87631ac70924c11d3a401b2d7f410cc0e3bbf249f2dca62"}, 499 | {file = "PyYAML-6.0.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:9eb6caa9a297fc2c2fb8862bc5370d0303ddba53ba97e71f08023b6cd73d16a8"}, 500 | {file = "PyYAML-6.0.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:c8098ddcc2a85b61647b2590f825f3db38891662cfc2fc776415143f599bb859"}, 501 | {file = "PyYAML-6.0.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5773183b6446b2c99bb77e77595dd486303b4faab2b086e7b17bc6bef28865f6"}, 502 | {file = "PyYAML-6.0.1-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b786eecbdf8499b9ca1d697215862083bd6d2a99965554781d0d8d1ad31e13a0"}, 503 | {file = "PyYAML-6.0.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bc1bf2925a1ecd43da378f4db9e4f799775d6367bdb94671027b73b393a7c42c"}, 504 | {file = "PyYAML-6.0.1-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:04ac92ad1925b2cff1db0cfebffb6ffc43457495c9b3c39d3fcae417d7125dc5"}, 505 | {file = "PyYAML-6.0.1-cp39-cp39-win32.whl", hash = "sha256:faca3bdcf85b2fc05d06ff3fbc1f83e1391b3e724afa3feba7d13eeab355484c"}, 506 | {file = "PyYAML-6.0.1-cp39-cp39-win_amd64.whl", hash = "sha256:510c9deebc5c0225e8c96813043e62b680ba2f9c50a08d3724c7f28a747d1486"}, 507 | {file = "PyYAML-6.0.1.tar.gz", hash = "sha256:bfdf460b1736c775f2ba9f6a92bca30bc2095067b8a9d77876d1fad6cc3b4a43"}, 508 | ] 509 | 510 | [[package]] 511 | name = "redis" 512 | version = "5.2.1" 513 | description = "Python client for Redis database and key-value store" 514 | optional = false 515 | python-versions = ">=3.8" 516 | groups = ["main"] 517 | files = [ 518 | {file = "redis-5.2.1-py3-none-any.whl", hash = "sha256:ee7e1056b9aea0f04c6c2ed59452947f34c4940ee025f5dd83e6a6418b6989e4"}, 519 | {file = "redis-5.2.1.tar.gz", hash = "sha256:16f2e22dff21d5125e8481515e386711a34cbec50f0e44413dd7d9c060a54e0f"}, 520 | ] 521 | 522 | [package.dependencies] 523 | async-timeout = {version = ">=4.0.3", markers = "python_full_version < \"3.11.3\""} 524 | 525 | [package.extras] 526 | hiredis = ["hiredis (>=3.0.0)"] 527 | ocsp = ["cryptography (>=36.0.1)", "pyopenssl (==23.2.1)", "requests (>=2.31.0)"] 528 | 529 | [[package]] 530 | name = "ruff" 531 | version = "0.4.1" 532 | description = "An extremely fast Python linter and code formatter, written in Rust." 533 | optional = false 534 | python-versions = ">=3.7" 535 | groups = ["dev"] 536 | files = [ 537 | {file = "ruff-0.4.1-py3-none-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:2d9ef6231e3fbdc0b8c72404a1a0c46fd0dcea84efca83beb4681c318ea6a953"}, 538 | {file = "ruff-0.4.1-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:9485f54a7189e6f7433e0058cf8581bee45c31a25cd69009d2a040d1bd4bfaef"}, 539 | {file = "ruff-0.4.1-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d2921ac03ce1383e360e8a95442ffb0d757a6a7ddd9a5be68561a671e0e5807e"}, 540 | {file = "ruff-0.4.1-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:eec8d185fe193ad053eda3a6be23069e0c8ba8c5d20bc5ace6e3b9e37d246d3f"}, 541 | {file = "ruff-0.4.1-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:baa27d9d72a94574d250f42b7640b3bd2edc4c58ac8ac2778a8c82374bb27984"}, 542 | {file = "ruff-0.4.1-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:f1ee41580bff1a651339eb3337c20c12f4037f6110a36ae4a2d864c52e5ef954"}, 543 | {file = "ruff-0.4.1-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0926cefb57fc5fced629603fbd1a23d458b25418681d96823992ba975f050c2b"}, 544 | {file = "ruff-0.4.1-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2c6e37f2e3cd74496a74af9a4fa67b547ab3ca137688c484749189bf3a686ceb"}, 545 | {file = "ruff-0.4.1-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:efd703a5975ac1998c2cc5e9494e13b28f31e66c616b0a76e206de2562e0843c"}, 546 | {file = "ruff-0.4.1-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:b92f03b4aa9fa23e1799b40f15f8b95cdc418782a567d6c43def65e1bbb7f1cf"}, 547 | {file = "ruff-0.4.1-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:1c859f294f8633889e7d77de228b203eb0e9a03071b72b5989d89a0cf98ee262"}, 548 | {file = "ruff-0.4.1-py3-none-musllinux_1_2_i686.whl", hash = "sha256:b34510141e393519a47f2d7b8216fec747ea1f2c81e85f076e9f2910588d4b64"}, 549 | {file = "ruff-0.4.1-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:6e68d248ed688b9d69fd4d18737edcbb79c98b251bba5a2b031ce2470224bdf9"}, 550 | {file = "ruff-0.4.1-py3-none-win32.whl", hash = "sha256:b90506f3d6d1f41f43f9b7b5ff845aeefabed6d2494307bc7b178360a8805252"}, 551 | {file = "ruff-0.4.1-py3-none-win_amd64.whl", hash = "sha256:c7d391e5936af5c9e252743d767c564670dc3889aff460d35c518ee76e4b26d7"}, 552 | {file = "ruff-0.4.1-py3-none-win_arm64.whl", hash = "sha256:a1eaf03d87e6a7cd5e661d36d8c6e874693cb9bc3049d110bc9a97b350680c43"}, 553 | {file = "ruff-0.4.1.tar.gz", hash = "sha256:d592116cdbb65f8b1b7e2a2b48297eb865f6bdc20641879aa9d7b9c11d86db79"}, 554 | ] 555 | 556 | [[package]] 557 | name = "setuptools" 558 | version = "78.1.1" 559 | description = "Easily download, build, install, upgrade, and uninstall Python packages" 560 | optional = false 561 | python-versions = ">=3.9" 562 | groups = ["dev"] 563 | files = [ 564 | {file = "setuptools-78.1.1-py3-none-any.whl", hash = "sha256:c3a9c4211ff4c309edb8b8c4f1cbfa7ae324c4ba9f91ff254e3d305b9fd54561"}, 565 | {file = "setuptools-78.1.1.tar.gz", hash = "sha256:fcc17fd9cd898242f6b4adfaca46137a9edef687f43e6f78469692a5e70d851d"}, 566 | ] 567 | 568 | [package.extras] 569 | check = ["pytest-checkdocs (>=2.4)", "pytest-ruff (>=0.2.1) ; sys_platform != \"cygwin\"", "ruff (>=0.8.0) ; sys_platform != \"cygwin\""] 570 | core = ["importlib_metadata (>=6) ; python_version < \"3.10\"", "jaraco.functools (>=4)", "jaraco.text (>=3.7)", "more_itertools", "more_itertools (>=8.8)", "packaging (>=24.2)", "platformdirs (>=4.2.2)", "tomli (>=2.0.1) ; python_version < \"3.11\"", "wheel (>=0.43.0)"] 571 | cover = ["pytest-cov"] 572 | doc = ["furo", "jaraco.packaging (>=9.3)", "jaraco.tidelift (>=1.4)", "pygments-github-lexers (==0.0.5)", "pyproject-hooks (!=1.1)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-favicon", "sphinx-inline-tabs", "sphinx-lint", "sphinx-notfound-page (>=1,<2)", "sphinx-reredirects", "sphinxcontrib-towncrier", "towncrier (<24.7)"] 573 | enabler = ["pytest-enabler (>=2.2)"] 574 | test = ["build[virtualenv] (>=1.0.3)", "filelock (>=3.4.0)", "ini2toml[lite] (>=0.14)", "jaraco.develop (>=7.21) ; python_version >= \"3.9\" and sys_platform != \"cygwin\"", "jaraco.envs (>=2.2)", "jaraco.path (>=3.7.2)", "jaraco.test (>=5.5)", "packaging (>=24.2)", "pip (>=19.1)", "pyproject-hooks (!=1.1)", "pytest (>=6,!=8.1.*)", "pytest-home (>=0.5)", "pytest-perf ; sys_platform != \"cygwin\"", "pytest-subprocess", "pytest-timeout", "pytest-xdist (>=3)", "tomli-w (>=1.0.0)", "virtualenv (>=13.0.0)", "wheel (>=0.44.0)"] 575 | type = ["importlib_metadata (>=7.0.2) ; python_version < \"3.10\"", "jaraco.develop (>=7.21) ; sys_platform != \"cygwin\"", "mypy (==1.14.*)", "pytest-mypy"] 576 | 577 | [[package]] 578 | name = "six" 579 | version = "1.16.0" 580 | description = "Python 2 and 3 compatibility utilities" 581 | optional = false 582 | python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*" 583 | groups = ["main"] 584 | files = [ 585 | {file = "six-1.16.0-py2.py3-none-any.whl", hash = "sha256:8abb2f1d86890a2dfb989f9a77cfcfd3e47c2a354b01111771326f8aa26e0254"}, 586 | {file = "six-1.16.0.tar.gz", hash = "sha256:1e61c37477a1626458e36f7b1d82aa5c9b094fa4802892072e49de9c60c4c926"}, 587 | ] 588 | 589 | [[package]] 590 | name = "tomli" 591 | version = "2.0.1" 592 | description = "A lil' TOML parser" 593 | optional = false 594 | python-versions = ">=3.7" 595 | groups = ["dev"] 596 | markers = "python_version < \"3.11\"" 597 | files = [ 598 | {file = "tomli-2.0.1-py3-none-any.whl", hash = "sha256:939de3e7a6161af0c887ef91b7d41a53e7c5a1ca976325f429cb46ea9bc30ecc"}, 599 | {file = "tomli-2.0.1.tar.gz", hash = "sha256:de526c12914f0c550d15924c62d72abc48d6fe7364aa87328337a31007fe8a4f"}, 600 | ] 601 | 602 | [[package]] 603 | name = "tox" 604 | version = "4.11.4" 605 | description = "tox is a generic virtualenv management and test command line tool" 606 | optional = false 607 | python-versions = ">=3.8" 608 | groups = ["dev"] 609 | files = [ 610 | {file = "tox-4.11.4-py3-none-any.whl", hash = "sha256:2adb83d68f27116812b69aa36676a8d6a52249cb0d173649de0e7d0c2e3e7229"}, 611 | {file = "tox-4.11.4.tar.gz", hash = "sha256:73a7240778fabf305aeb05ab8ea26e575e042ab5a18d71d0ed13e343a51d6ce1"}, 612 | ] 613 | 614 | [package.dependencies] 615 | cachetools = ">=5.3.1" 616 | chardet = ">=5.2" 617 | colorama = ">=0.4.6" 618 | filelock = ">=3.12.3" 619 | packaging = ">=23.1" 620 | platformdirs = ">=3.10" 621 | pluggy = ">=1.3" 622 | pyproject-api = ">=1.6.1" 623 | tomli = {version = ">=2.0.1", markers = "python_version < \"3.11\""} 624 | virtualenv = ">=20.24.3" 625 | 626 | [package.extras] 627 | docs = ["furo (>=2023.8.19)", "sphinx (>=7.2.4)", "sphinx-argparse-cli (>=1.11.1)", "sphinx-autodoc-typehints (>=1.24)", "sphinx-copybutton (>=0.5.2)", "sphinx-inline-tabs (>=2023.4.21)", "sphinxcontrib-towncrier (>=0.2.1a0)", "towncrier (>=23.6)"] 628 | testing = ["build[virtualenv] (>=0.10)", "covdefaults (>=2.3)", "detect-test-pollution (>=1.1.1)", "devpi-process (>=1)", "diff-cover (>=7.7)", "distlib (>=0.3.7)", "flaky (>=3.7)", "hatch-vcs (>=0.3)", "hatchling (>=1.18)", "psutil (>=5.9.5)", "pytest (>=7.4)", "pytest-cov (>=4.1)", "pytest-mock (>=3.11.1)", "pytest-xdist (>=3.3.1)", "re-assert (>=1.1)", "time-machine (>=2.12) ; implementation_name != \"pypy\"", "wheel (>=0.41.2)"] 629 | 630 | [[package]] 631 | name = "typing-extensions" 632 | version = "4.11.0" 633 | description = "Backported and Experimental Type Hints for Python 3.8+" 634 | optional = false 635 | python-versions = ">=3.8" 636 | groups = ["dev"] 637 | files = [ 638 | {file = "typing_extensions-4.11.0-py3-none-any.whl", hash = "sha256:c1f94d72897edaf4ce775bb7558d5b79d8126906a14ea5ed1635921406c0387a"}, 639 | {file = "typing_extensions-4.11.0.tar.gz", hash = "sha256:83f085bd5ca59c80295fc2a82ab5dac679cbe02b9f33f7d83af68e241bea51b0"}, 640 | ] 641 | 642 | [[package]] 643 | name = "virtualenv" 644 | version = "20.25.0" 645 | description = "Virtual Python Environment builder" 646 | optional = false 647 | python-versions = ">=3.7" 648 | groups = ["dev"] 649 | files = [ 650 | {file = "virtualenv-20.25.0-py3-none-any.whl", hash = "sha256:4238949c5ffe6876362d9c0180fc6c3a824a7b12b80604eeb8085f2ed7460de3"}, 651 | {file = "virtualenv-20.25.0.tar.gz", hash = "sha256:bf51c0d9c7dd63ea8e44086fa1e4fb1093a31e963b86959257378aef020e1f1b"}, 652 | ] 653 | 654 | [package.dependencies] 655 | distlib = ">=0.3.7,<1" 656 | filelock = ">=3.12.2,<4" 657 | platformdirs = ">=3.9.1,<5" 658 | 659 | [package.extras] 660 | docs = ["furo (>=2023.7.26)", "proselint (>=0.13)", "sphinx (>=7.1.2)", "sphinx-argparse (>=0.4)", "sphinxcontrib-towncrier (>=0.2.1a0)", "towncrier (>=23.6)"] 661 | test = ["covdefaults (>=2.3)", "coverage (>=7.2.7)", "coverage-enable-subprocess (>=1)", "flaky (>=3.7)", "packaging (>=23.1)", "pytest (>=7.4)", "pytest-env (>=0.8.2)", "pytest-freezer (>=0.4.8) ; platform_python_implementation == \"PyPy\"", "pytest-mock (>=3.11.1)", "pytest-randomly (>=3.12)", "pytest-timeout (>=2.1)", "setuptools (>=68)", "time-machine (>=2.10) ; platform_python_implementation == \"CPython\""] 662 | 663 | [metadata] 664 | lock-version = "2.1" 665 | python-versions = ">=3.9,<3.13" 666 | content-hash = "e0d6891c93c1e9eabec24389c7552893193260e78cca3cfd3ae4ca13f29efb30" 667 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [tool.poetry] 2 | name = "bitmapist" 3 | version = "3.116" 4 | description="Implements a powerful analytics library using Redis bitmaps." 5 | authors = [ 6 | "Amir Salihefendic ", 7 | "Doist Developers ", 8 | ] 9 | license="BSD" 10 | readme = "README.md" 11 | repository = "https://github.com/Doist/bitmapist" 12 | keywords=[ "redis", "bitmap", "analytics", "bitmaps", "realtime", "cohort", ] 13 | classifiers=[ 14 | "Development Status :: 5 - Production/Stable", 15 | "Intended Audience :: Developers", 16 | "License :: OSI Approved :: BSD License", 17 | "Operating System :: OS Independent", 18 | "Programming Language :: Python", 19 | "Programming Language :: Python :: 3", 20 | "Programming Language :: Python :: 3.9", 21 | "Programming Language :: Python :: 3.10", 22 | "Programming Language :: Python :: 3.11", 23 | "Programming Language :: Python :: 3.12", 24 | "Topic :: Software Development :: Libraries :: Python Modules", 25 | ] 26 | packages=[ { include = "bitmapist" } ] 27 | exclude = ['tests/'] 28 | include = [ 29 | 'AUTHORS', 30 | 'README.md', 31 | 'static/bitmapist.png', 32 | 'static/bitmapist.svg', 33 | ] 34 | 35 | [tool.poetry.dependencies] 36 | python = ">=3.9,<3.13" 37 | redis = ">=2.10,<6.0" 38 | python-dateutil = "*" 39 | future = ">=0.14.3,<0.19.0" 40 | Mako= "^1.0.4" 41 | 42 | [tool.poetry.dev-dependencies] 43 | pytest-runner = "*" 44 | pytest = "*" 45 | pre-commit = "*" 46 | tox = "*" 47 | mypy = "*" 48 | ruff = "^0.4.1" 49 | 50 | [build-system] 51 | requires = ["poetry-core>=1.0.0"] 52 | build-backend = "poetry.core.masonry.api" 53 | 54 | [tool.ruff] 55 | # By default, always show source code snippets. 56 | output-format = 'full' 57 | 58 | extend-exclude = [ 59 | "env", 60 | "runtime", 61 | ] 62 | 63 | [tool.ruff.lint] 64 | select = [ 65 | "ASYNC", # flake8-async 66 | "B", # flake8-bugbear 67 | "C4", # flake8-comprehensions 68 | "D", # pydocstyle, 69 | "E", "W", # pycodestyle 70 | "F", # pyflakes 71 | "I", # isort 72 | "PL", # pylint 73 | "RUF", # ruff 74 | "S", # flake8-bandit 75 | "T20", # flake8-print 76 | "SIM", # flake8-simplify 77 | "UP", # pyupgrade 78 | "TCH", # flake8-type-checking 79 | "TRY", # tryceratops 80 | "BLE", # flake8-blind-except 81 | "LOG", # flake8-logging 82 | "G", # flake8-logging-format 83 | "RET", # flake8-logging-return 84 | "ISC", # flake8-implicit-str-concat 85 | "INP", # flake8-no-pep420 86 | "PIE", # flake8-pie 87 | "PT", # flake8-pytest-style 88 | "PERF", # perflint 89 | ] 90 | 91 | ignore = [ 92 | ## D - pydocstyle ## 93 | # D1XX errors are OK. Don't force people into over-documenting. 94 | "D100", "D101", "D102", "D103", "D104", "D105", "D107", 95 | # These need to be fixed. 96 | "D205", 97 | "D400", 98 | "D401", 99 | 100 | ## E / W - pycodestyle ## 101 | "E501", # line too long 102 | 103 | ## PL - pylint ## 104 | # Commented-out rules are rules that we disable in pylint but are not supported by ruff yet. 105 | 106 | "PLR6301", # no-self-use 107 | "PLC2701", # import-private-name 108 | 109 | # Import order issues 110 | # "PLC0411", # wrong-import-order 111 | # "PLC0412", # wrong-import-position 112 | "PLC0414", # ungrouped-imports 113 | "PLC0415", # import-outside-top-level 114 | 115 | # flake8-implicit-str-concat 116 | "ISC001", # May conflict with the formatter 117 | 118 | # Documentation issues 119 | # "C0114", # missing-module-docstring 120 | 121 | # Complexity issues 122 | "PLR0904", # too-many-public-methods 123 | # "PLC0302", # too-many-lines 124 | "PLR1702", # too-many-nested-blocks 125 | # "PLR0902", # too-many-instance-attributes 126 | "PLR0911", # too-many-return-statements 127 | "PLR0915", # too-many-statements 128 | "PLR0912", # too-many-branches 129 | # "PLR0903", # too-few-public-methods 130 | "PLR0914", # too-many-locals 131 | # "PLC0301", # line-too-long 132 | "PLR0913", # too-many-arguments 133 | "PLR0917", # too-many-positional 134 | "PLR2004", # magic-value-comparison 135 | "PLW0603", # global-statement 136 | "PLW2901", # redefined-loop-name 137 | 138 | ## RUF - ruff ## 139 | "RUF001", # ambiguous-unicode-character-string 140 | "RUF002", # ambiguous-unicode-character-docstring 141 | "RUF003", # ambiguous-unicode-character-comment 142 | "RUF012", # mutable-class-default 143 | 144 | # Enable when Poetry supports PEP 621 and we migrate our confguration to it. 145 | # See: https://github.com/python-poetry/poetry-core/pull/567 146 | "RUF200", 147 | 148 | "S101", # assert 149 | "S104", # hardcoded-bind-all-interfaces 150 | "S105", # hardcoded-password-string 151 | "S106", # hardcoded-password-func-arg 152 | "S303", # suspicious-insecure-hash-usage 153 | "S310", # suspicious-url-open-usage 154 | "S311", # suspicious-non-cryptographic-random-usage 155 | "S324", # hashlib-insecure-hash-function 156 | "S603", # subprocess-without-shell-equals-true 157 | "S607", # start-process-with-partial-path 158 | "S608", # hardcoded-sql-expression 159 | 160 | ## SIM - flake8-simplify ## 161 | "SIM102", # collapsible-if 162 | "SIM114", # if-with-same-arms 163 | "SIM117", # multiple-with-statements 164 | 165 | # Enable when the rule is out of preview and false-positives are handled. 166 | # See: https://docs.astral.sh/ruff/rules/in-dict-keys/ 167 | "SIM118", # in-dict-keys 168 | 169 | ## TRY - tryceratops ## 170 | "TRY003", # raise-vanilla-args 171 | "TRY004", # type-check-without-type-error 172 | "TRY301", # raise-within-try 173 | 174 | ## BLE - flake8-blind-except ## 175 | "BLE001", # blind-except 176 | 177 | ## RET - flake8-return ## 178 | "RET504", # unnecessary-assign 179 | 180 | ## PT - flake8-pytest-style ## 181 | "PT004", # pytest-missing-fixture-name-underscore 182 | "PT012", # pytest-raises-with-multiple-statements 183 | 184 | ## UP - pyupgrade ## 185 | "UP038", # non-pep604-isinstance 186 | 187 | ## B - flake8-bugbear ## 188 | "B008", # function-call-in-default-argument 189 | "B009", # get-attr-with-constant 190 | "B010", # set-attr-with-constant 191 | "B018", # useless-expression 192 | ] 193 | 194 | flake8-pytest-style.fixture-parentheses = false 195 | flake8-pytest-style.mark-parentheses = false 196 | 197 | pylint.allow-dunder-method-names = [ 198 | "__json__", 199 | "__get_pydantic_core_schema__" 200 | ] 201 | 202 | [tool.ruff.lint.flake8-type-checking] 203 | runtime-evaluated-base-classes = [ 204 | "pydantic.BaseModel", 205 | "typing_extensions.TypedDict", 206 | "sqlalchemy.orm.DeclarativeBase", 207 | ] 208 | runtime-evaluated-decorators = [ 209 | "pydantic.dataclasses.dataclass", 210 | "pydantic.validate_call", 211 | ] 212 | 213 | [tool.ruff.lint.per-file-ignores] 214 | # Open devnull without a context manager 215 | "conftest.py" = ["SIM115"] 216 | 217 | [tool.ruff.lint.isort] 218 | section-order = [ 219 | "future", 220 | "standard-library", 221 | "third-party", 222 | "first-party", 223 | "local-folder", 224 | ] 225 | 226 | [tool.ruff.lint.pydocstyle] 227 | convention = "pep257" 228 | 229 | [tool.ruff.lint.pyupgrade] 230 | # Required by tools like Pydantic that use type information at runtime. 231 | # https://github.com/asottile/pyupgrade/issues/622#issuecomment-1088766572 232 | keep-runtime-typing = true 233 | 234 | [tool.ruff.format] 235 | docstring-code-format = true 236 | -------------------------------------------------------------------------------- /static/bitmapist.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Doist/bitmapist/daea44564d3f9b7779826cd1b82c633dd7c064a1/static/bitmapist.png -------------------------------------------------------------------------------- /static/bitmapist.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | bitmapist 5 | Created with Sketch. 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | bitmapist 23 | 24 | 25 | 26 | -------------------------------------------------------------------------------- /static/bitmapist_logo.sketch: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Doist/bitmapist/daea44564d3f9b7779826cd1b82c633dd7c064a1/static/bitmapist_logo.sketch -------------------------------------------------------------------------------- /static/cohort_screenshot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Doist/bitmapist/daea44564d3f9b7779826cd1b82c633dd7c064a1/static/cohort_screenshot.png -------------------------------------------------------------------------------- /test/__init__.py: -------------------------------------------------------------------------------- 1 | # 2 | -------------------------------------------------------------------------------- /test/conftest.py: -------------------------------------------------------------------------------- 1 | import atexit 2 | import os 3 | import socket 4 | import subprocess 5 | import time 6 | 7 | import pytest 8 | 9 | from bitmapist import delete_all_events, setup_redis 10 | 11 | 12 | @pytest.fixture(scope="session", autouse=True) 13 | def redis_server(): 14 | """Fixture starting the Redis server""" 15 | redis_host = "127.0.0.1" 16 | redis_port = 6399 17 | if is_socket_open(redis_host, redis_port): 18 | yield None 19 | else: 20 | proc = start_redis_server(redis_port) 21 | wait_for_socket(redis_host, redis_port) 22 | yield proc 23 | proc.terminate() 24 | 25 | 26 | @pytest.fixture(scope="session", autouse=True) 27 | def setup_redis_for_bitmapist(): 28 | setup_redis("default", "localhost", 6399) 29 | setup_redis("default_copy", "localhost", 6399) 30 | setup_redis("db1", "localhost", 6399, db=1) 31 | 32 | 33 | @pytest.fixture(autouse=True) 34 | def clean_redis(): 35 | delete_all_events() 36 | 37 | 38 | def start_redis_server(port): 39 | """Helper function starting Redis server""" 40 | devzero = open(os.devnull) 41 | devnull = open(os.devnull, "w") 42 | proc = subprocess.Popen( 43 | ["/usr/bin/redis-server", "--port", str(port)], 44 | stdin=devzero, 45 | stdout=devnull, 46 | stderr=devnull, 47 | close_fds=True, 48 | ) 49 | atexit.register(lambda: proc.terminate()) 50 | return proc 51 | 52 | 53 | def is_socket_open(host, port): 54 | """Helper function which tests is the socket open""" 55 | sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) 56 | sock.settimeout(0.1) 57 | return sock.connect_ex((host, port)) == 0 58 | 59 | 60 | def wait_for_socket(host, port, seconds=3): 61 | """Check if socket is up for :param:`seconds` sec, raise an error otherwise""" 62 | polling_interval = 0.1 63 | iterations = int(seconds / polling_interval) 64 | 65 | sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) 66 | sock.settimeout(0.1) 67 | for _ in range(iterations): 68 | result = sock.connect_ex((host, port)) 69 | if result == 0: 70 | sock.close() 71 | break 72 | time.sleep(polling_interval) 73 | else: 74 | raise RuntimeError("Service at %s:%d is unreachable" % (host, port)) 75 | -------------------------------------------------------------------------------- /test/test_bitmapist.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime, timedelta, timezone 2 | 3 | from bitmapist import ( 4 | BitOpAnd, 5 | BitOpOr, 6 | DayEvents, 7 | HourEvents, 8 | MonthEvents, 9 | WeekEvents, 10 | YearEvents, 11 | get_event_names, 12 | mark_event, 13 | unmark_event, 14 | ) 15 | 16 | 17 | def test_mark_with_diff_days(): 18 | mark_event("active", 123, track_hourly=True) 19 | 20 | now = datetime.now(tz=timezone.utc) 21 | 22 | # Month 23 | assert 123 in MonthEvents("active", now.year, now.month) 24 | assert 124 not in MonthEvents("active", now.year, now.month) 25 | 26 | # Week 27 | assert 123 in WeekEvents("active", now.year, now.isocalendar()[1]) 28 | assert 124 not in WeekEvents("active", now.year, now.isocalendar()[1]) 29 | 30 | # Day 31 | assert 123 in DayEvents("active", now.year, now.month, now.day) 32 | assert 124 not in DayEvents("active", now.year, now.month, now.day) 33 | 34 | # Hour 35 | assert 123 in HourEvents("active", now.year, now.month, now.day, now.hour) 36 | assert 124 not in HourEvents("active", now.year, now.month, now.day, now.hour) 37 | assert 124 not in HourEvents("active", now.year, now.month, now.day, now.hour - 1) 38 | 39 | 40 | def test_mark_unmark(): 41 | now = datetime.now(tz=timezone.utc) 42 | 43 | mark_event("active", 125) 44 | assert 125 in MonthEvents("active", now.year, now.month) 45 | 46 | unmark_event("active", 125) 47 | assert 125 not in MonthEvents("active", now.year, now.month) 48 | 49 | 50 | def test_mark_counts(): 51 | now = datetime.now(tz=timezone.utc) 52 | 53 | assert MonthEvents("active", now.year, now.month).get_count() == 0 54 | 55 | mark_event("active", 123) 56 | mark_event("active", 23232) 57 | 58 | assert len(MonthEvents("active", now.year, now.month)) == 2 59 | 60 | 61 | def test_mark_iter(): 62 | now = datetime.now(tz=timezone.utc) 63 | ev = MonthEvents("active", now.year, now.month) 64 | 65 | assert list(ev) == [] 66 | 67 | mark_event("active", 5) 68 | mark_event("active", 55) 69 | mark_event("active", 555) 70 | mark_event("active", 5555) 71 | 72 | assert list(ev) == [5, 55, 555, 5555] 73 | 74 | 75 | def test_different_dates(): 76 | now = datetime.now(tz=timezone.utc) 77 | yesterday = now - timedelta(days=1) 78 | 79 | mark_event("active", 123, now=now) 80 | mark_event("active", 23232, now=yesterday) 81 | 82 | assert DayEvents("active", now.year, now.month, now.day).get_count() == 1 83 | 84 | assert ( 85 | DayEvents("active", yesterday.year, yesterday.month, yesterday.day).get_count() 86 | == 1 87 | ) 88 | 89 | 90 | def test_different_buckets(): 91 | now = datetime.now(tz=timezone.utc) 92 | 93 | mark_event("active", 123) 94 | mark_event("tasks:completed", 23232) 95 | 96 | assert MonthEvents("active", now.year, now.month).get_count() == 1 97 | assert MonthEvents("tasks:completed", now.year, now.month).get_count() == 1 98 | 99 | 100 | def test_bit_operations(): 101 | now = datetime.now(tz=timezone.utc) 102 | last_month = now - timedelta(days=30) 103 | 104 | # 123 has been active for two months 105 | mark_event("active", 123, now=now) 106 | mark_event("active", 123, now=last_month) 107 | 108 | # 224 has only been active last_month 109 | mark_event("active", 224, now=last_month) 110 | 111 | # Assert basic premises 112 | assert MonthEvents("active", last_month.year, last_month.month).get_count() == 2 113 | assert MonthEvents("active", now.year, now.month).get_count() == 1 114 | 115 | # Try out with bit AND operation 116 | active_2_months = BitOpAnd( 117 | MonthEvents("active", last_month.year, last_month.month), 118 | MonthEvents("active", now.year, now.month), 119 | ) 120 | assert active_2_months.get_count() == 1 121 | assert 123 in active_2_months 122 | assert 224 not in active_2_months 123 | active_2_months.delete() 124 | 125 | # Try out with bit OR operation 126 | assert ( 127 | BitOpOr( 128 | MonthEvents("active", last_month.year, last_month.month), 129 | MonthEvents("active", now.year, now.month), 130 | ).get_count() 131 | == 2 132 | ) 133 | 134 | # Try out with a different system 135 | active_2_months = BitOpAnd( 136 | "default_copy", 137 | MonthEvents("active", last_month.year, last_month.month), 138 | MonthEvents("active", now.year, now.month), 139 | ) 140 | assert active_2_months.get_count() == 1 141 | assert active_2_months.system == "default_copy" 142 | active_2_months.delete() 143 | 144 | # Try nested operations 145 | active_2_months = BitOpAnd( 146 | BitOpAnd( 147 | MonthEvents("active", last_month.year, last_month.month), 148 | MonthEvents("active", now.year, now.month), 149 | ), 150 | MonthEvents("active", now.year, now.month), 151 | ) 152 | 153 | assert 123 in active_2_months 154 | assert 224 not in active_2_months 155 | active_2_months.delete() 156 | 157 | 158 | def test_bit_operations_complex(): 159 | now = datetime.now(tz=timezone.utc) 160 | tom = now + timedelta(days=1) 161 | 162 | mark_event("task1", 111, now=now) 163 | mark_event("task1", 111, now=tom) 164 | mark_event("task2", 111, now=now) 165 | mark_event("task2", 111, now=tom) 166 | mark_event("task1", 222, now=now) 167 | mark_event("task1", 222, now=tom) 168 | mark_event("task2", 222, now=now) 169 | mark_event("task2", 222, now=tom) 170 | 171 | now_events = BitOpAnd( 172 | DayEvents("task1", now.year, now.month, now.day), 173 | DayEvents("task2", now.year, now.month, now.day), 174 | ) 175 | 176 | tom_events = BitOpAnd( 177 | DayEvents("task1", tom.year, tom.month, tom.day), 178 | DayEvents("task2", tom.year, tom.month, tom.day), 179 | ) 180 | 181 | both_events = BitOpAnd(now_events, tom_events) 182 | 183 | assert len(now_events) == len(tom_events) 184 | assert len(now_events) == len(both_events) 185 | 186 | 187 | def test_bitop_key_sharing(): 188 | today = datetime.now(tz=timezone.utc) 189 | 190 | mark_event("task1", 111, now=today) 191 | mark_event("task2", 111, now=today) 192 | mark_event("task1", 222, now=today) 193 | mark_event("task2", 222, now=today) 194 | 195 | ev1_task1 = DayEvents("task1", today.year, today.month, today.day) 196 | ev1_task2 = DayEvents("task2", today.year, today.month, today.day) 197 | ev1_both = BitOpAnd(ev1_task1, ev1_task2) 198 | 199 | ev2_task1 = DayEvents("task1", today.year, today.month, today.day) 200 | ev2_task2 = DayEvents("task2", today.year, today.month, today.day) 201 | ev2_both = BitOpAnd(ev2_task1, ev2_task2) 202 | 203 | assert ev1_both.redis_key == ev2_both.redis_key 204 | assert len(ev1_both) == len(ev2_both) == 2 205 | ev1_both.delete() 206 | assert len(ev1_both) == len(ev2_both) == 0 207 | 208 | 209 | def test_events_marked(): 210 | now = datetime.now(tz=timezone.utc) 211 | 212 | assert MonthEvents("active", now.year, now.month).get_count() == 0 213 | assert MonthEvents("active", now.year, now.month).has_events_marked() is False 214 | 215 | mark_event("active", 123, now=now) 216 | 217 | assert MonthEvents("active", now.year, now.month).get_count() == 1 218 | assert MonthEvents("active", now.year, now.month).has_events_marked() is True 219 | 220 | 221 | def test_get_event_names(): 222 | event_names = {"foo", "bar", "baz", "spam", "egg"} 223 | for e in event_names: 224 | mark_event(e, 1) 225 | BitOpAnd(DayEvents("foo"), DayEvents("bar")) 226 | assert set(get_event_names(batch=2)) == event_names 227 | 228 | 229 | def test_get_event_names_prefix(): 230 | event_names = {"foo", "bar", "baz", "spam", "egg"} 231 | for e in event_names: 232 | mark_event(e, 1) 233 | BitOpAnd(DayEvents("foo"), DayEvents("bar")) 234 | assert set(get_event_names(prefix="b", batch=2)) == {"bar", "baz"} 235 | 236 | 237 | def test_bit_operations_magic(): 238 | mark_event("foo", 1) 239 | mark_event("foo", 2) 240 | mark_event("bar", 2) 241 | mark_event("bar", 3) 242 | foo = DayEvents("foo") 243 | bar = DayEvents("bar") 244 | assert list(foo & bar) == [2] 245 | assert list(foo | bar) == [1, 2, 3] 246 | assert list(foo ^ bar) == [1, 3] 247 | assert list(~foo & bar) == [3] 248 | 249 | 250 | def test_year_events(): 251 | mark_event("foo", 1, system="db1") 252 | assert 1 in YearEvents("foo", system="db1") 253 | -------------------------------------------------------------------------------- /test/test_cohort.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime, timedelta, timezone 2 | 3 | import pytest 4 | 5 | from bitmapist import mark_event 6 | from bitmapist.cohort import get_dates_data 7 | 8 | 9 | @pytest.fixture 10 | def events(): 11 | today = datetime.now(tz=timezone.utc) 12 | tomorrow = today + timedelta(days=1) 13 | 14 | mark_event("signup", 111, now=today) 15 | mark_event("active", 111, now=today) 16 | mark_event("active", 111, now=tomorrow) 17 | 18 | mark_event("signup", 222, now=today) 19 | mark_event("active", 222, now=today) 20 | 21 | mark_event("task1", 111, now=today) 22 | mark_event("task1", 111, now=tomorrow) 23 | mark_event("task2", 111, now=today) 24 | mark_event("task2", 111, now=tomorrow) 25 | mark_event("task1", 222, now=today) 26 | mark_event("task1", 222, now=tomorrow) 27 | mark_event("task2", 222, now=today) 28 | mark_event("task2", 222, now=tomorrow) 29 | 30 | 31 | @pytest.mark.parametrize( 32 | ("select1", "select1b", "select2", "select2b", "expected"), 33 | [ 34 | ("active", None, "active", None, [2, 100, 50]), 35 | ("active", None, "unknown", None, [2, "", ""]), 36 | ("unknown", None, "active", None, [0, "", ""]), 37 | ("signup", "active", "active", None, [2, 100, 50]), 38 | ("signup", "active", "active", "signup", [2, 100, 0]), 39 | ("task1", "task2", "task2", "task1", [2, 100, 100]), 40 | ("task1", "task2", "task1", "task2", [2, 100, 100]), 41 | ], 42 | ) 43 | def test_cohort(select1, select1b, select2, select2b, expected, events): 44 | r = get_dates_data( 45 | select1=select1, 46 | select1b=select1b, 47 | select2=select2, 48 | select2b=select2b, 49 | time_group="days", 50 | as_percent=1, 51 | num_results=1, 52 | num_of_rows=1, 53 | ) 54 | assert r[0][1:] == expected 55 | -------------------------------------------------------------------------------- /test/test_delta_methods.py: -------------------------------------------------------------------------------- 1 | import bitmapist 2 | 3 | 4 | def test_delta_hour(): 5 | ev = bitmapist.HourEvents("foo", 2014, 1, 1, 0) 6 | n = ev.next() 7 | assert (n.year, n.month, n.day, n.hour) == (2014, 1, 1, 1) 8 | p = ev.prev() 9 | assert (p.year, p.month, p.day, p.hour) == (2013, 12, 31, 23) 10 | 11 | 12 | def test_delta_day(): 13 | ev = bitmapist.DayEvents("foo", 2014, 1, 1) 14 | n = ev.next() 15 | assert (n.year, n.month, n.day) == (2014, 1, 2) 16 | p = ev.prev() 17 | assert (p.year, p.month, p.day) == (2013, 12, 31) 18 | 19 | 20 | def test_delta_week(): 21 | ev = bitmapist.WeekEvents("foo", 2014, 1) 22 | n = ev.next() 23 | assert (n.year, n.week) == (2014, 2) 24 | p = ev.prev() 25 | assert (p.year, p.week) == (2013, 52) 26 | 27 | 28 | def test_delta_month(): 29 | ev = bitmapist.MonthEvents("foo", 2014, 1) 30 | n = ev.next() 31 | assert (n.year, n.month) == (2014, 2) 32 | p = ev.prev() 33 | assert (p.year, p.month) == (2013, 12) 34 | 35 | 36 | def test_delta_year(): 37 | ev = bitmapist.YearEvents("foo", 2014) 38 | n = ev.next() 39 | assert n.year == 2015 40 | p = ev.prev() 41 | assert p.year == 2013 42 | -------------------------------------------------------------------------------- /test/test_equality.py: -------------------------------------------------------------------------------- 1 | import bitmapist 2 | 3 | 4 | def test_equality(): 5 | ev1 = bitmapist.YearEvents("foo", 2014) 6 | ev2 = bitmapist.YearEvents("foo", 2014) 7 | ev3 = bitmapist.YearEvents("foo", 2015) 8 | assert ev1 == ev2 9 | assert ev1 != ev3 10 | -------------------------------------------------------------------------------- /test/test_from_date.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime, timezone 2 | 3 | import bitmapist 4 | 5 | 6 | def test_from_date_year(): 7 | ev1 = bitmapist.YearEvents.from_date( 8 | "foo", datetime(2014, 1, 1, tzinfo=timezone.utc) 9 | ) 10 | ev2 = bitmapist.YearEvents("foo", 2014) 11 | assert ev1 == ev2 12 | 13 | 14 | def test_from_date_month(): 15 | ev1 = bitmapist.MonthEvents.from_date( 16 | "foo", datetime(2014, 1, 1, tzinfo=timezone.utc) 17 | ) 18 | ev2 = bitmapist.MonthEvents("foo", 2014, 1) 19 | assert ev1 == ev2 20 | 21 | 22 | def test_from_date_week(): 23 | ev1 = bitmapist.MonthEvents.from_date( 24 | "foo", datetime(2014, 1, 1, tzinfo=timezone.utc) 25 | ) 26 | ev2 = bitmapist.MonthEvents("foo", 2014, 1) 27 | assert ev1 == ev2 28 | 29 | 30 | def test_from_date_day(): 31 | ev1 = bitmapist.DayEvents.from_date( 32 | "foo", datetime(2014, 1, 1, tzinfo=timezone.utc) 33 | ) 34 | ev2 = bitmapist.DayEvents("foo", 2014, 1, 1) 35 | assert ev1 == ev2 36 | 37 | 38 | def test_from_date_hour(): 39 | ev1 = bitmapist.HourEvents.from_date( 40 | "foo", datetime(2014, 1, 1, 1, tzinfo=timezone.utc) 41 | ) 42 | ev2 = bitmapist.HourEvents("foo", 2014, 1, 1, 1) 43 | assert ev1 == ev2 44 | -------------------------------------------------------------------------------- /test/test_period_start_end.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime, timezone 2 | 3 | import pytest 4 | 5 | import bitmapist 6 | 7 | 8 | @pytest.mark.parametrize( 9 | "cls", 10 | [ 11 | bitmapist.HourEvents, 12 | bitmapist.DayEvents, 13 | bitmapist.WeekEvents, 14 | bitmapist.MonthEvents, 15 | bitmapist.YearEvents, 16 | ], 17 | ) 18 | def test_period_start_end(cls): 19 | dt = datetime(2014, 1, 1, 8, 30, tzinfo=timezone.utc) 20 | ev = cls.from_date("foo", dt) 21 | assert ev.period_start() <= dt <= ev.period_end() 22 | -------------------------------------------------------------------------------- /test/test_unique_events.py: -------------------------------------------------------------------------------- 1 | import bitmapist 2 | 3 | 4 | def test_mark(): 5 | ev = bitmapist.UniqueEvents("foo") 6 | bitmapist.mark_unique("foo", 1) 7 | assert 1 in ev 8 | assert 2 not in ev 9 | 10 | 11 | def test_unmark(): 12 | ev = bitmapist.UniqueEvents("foo") 13 | bitmapist.mark_unique("foo", 1) 14 | bitmapist.unmark_unique("foo", 1) 15 | assert 1 not in ev 16 | 17 | 18 | def test_ops(): 19 | bitmapist.mark_unique("foo", 1) 20 | bitmapist.mark_unique("foo", 2) 21 | bitmapist.mark_unique("bar", 2) 22 | bitmapist.mark_unique("bar", 3) 23 | 24 | foo = bitmapist.UniqueEvents("foo") 25 | bar = bitmapist.UniqueEvents("bar") 26 | assert list(foo & bar) == [2] 27 | assert list(foo | bar) == [1, 2, 3] 28 | 29 | 30 | def test_ops_with_dates(): 31 | bitmapist.mark_event("active", 1) 32 | bitmapist.mark_event("active", 2) 33 | bitmapist.mark_unique("foo", 2) 34 | bitmapist.mark_unique("foo", 3) 35 | 36 | foo = bitmapist.UniqueEvents("foo") 37 | active = bitmapist.DayEvents("active") 38 | 39 | assert list(foo & active) == [2] 40 | assert list(foo | active) == [1, 2, 3] 41 | 42 | assert list(foo & active.prev()) == [] 43 | assert list(foo | active.prev()) == [2, 3] 44 | 45 | 46 | def test_track_unique(): 47 | bitmapist.mark_event("foo", 1, track_unique=True) 48 | bitmapist.mark_event("foo", 2, track_unique=False) 49 | assert list(bitmapist.DayEvents("foo")) == [1, 2] 50 | assert list(bitmapist.UniqueEvents("foo")) == [1] 51 | --------------------------------------------------------------------------------