Complete rewrite with Polars. No more single loop to fetch all data; posts, queued posts, drafts, and activity are all fetched separately. Extra caution when querying API to prevent HTTP 429

This commit is contained in:
2026-10-21 22:12:31 -04:00
parent ea116ee0a3
commit a23bd35001
6 changed files with 964 additions and 219 deletions

View File

@@ -1,20 +1,31 @@
# tumblr-stats # tumblr-stats
## Usage ## Usage
``` ```
usage: tumblr_stats.py [-h] -b BLOG [-t TAGS [TAGS ...]] [-i INPUT] [--after AFTER] OPERATION [OPERATION ...] usage: tumblr_stats.py [-h] -b BLOG [-t TAGS [TAGS ...]]
[-s {none,posts,queued,drafts,activity} [{none,posts,queued,drafts,activity} ...]] [--until UNTIL]
[--since SINCE] [--queued-file QUEUED_FILE] [--drafts-file DRAFTS_FILE] [--posts-file POSTS_FILE]
[--activity-file ACTIVITY_FILE]
Use pytumblr to calculate stats after setting these enviroment variables: $TUMBLR_CONSUMER_KEY, $TUMBLR_CONSUMER_SECRET, $TUMBLR_OAUTH_TOKEN, and $TUMBLR_OAUTH_SECRET Use pytumblr and Polars to calculate stats after setting these enviroment variables: $TUMBLR_CONSUMER_KEY,
$TUMBLR_CONSUMER_SECRET, $TUMBLR_OAUTH_TOKEN, and $TUMBLR_OAUTH_SECRET
positional arguments:
OPERATION operation used to calculate stats
options: options:
-h, --help show this help message and exit -h, --help show this help message and exit
-b, --blog BLOG blog name for which to calculate stats -b, --blog BLOG blog name for which to calculate stats
-t, --tags TAGS [TAGS ...] -t, --tags TAGS [TAGS ...]
tag(s) to focus on in status (if applicable) tag(s) on which to filter (only affects posts)
-i, --input INPUT Don't make API calls, just use a JSON input file -s, --suppress {none,posts,queued,drafts,activity} [{none,posts,queued,drafts,activity} ...]
--after AFTER only gather posts after YYYY-MM-DD objects to skip when aggregating data; will render some stats empty
--until UNTIL only gather posts and activity until YYYY-MM-DD
--since SINCE only fetch posts and activity since YYYY-MM-DD
--queued-file QUEUED_FILE
read queued posts from this path instead of fetching them from APIs
--drafts-file DRAFTS_FILE
read drafts from this path instead of fetching them from APIs
--posts-file POSTS_FILE
read posts from this path instead of fetching them from APIs
--activity-file ACTIVITY_FILE
read activity from this path instead of fetching it from APIs
— Be gay and do crime — Be gay and do crime
``` ```

357
fetcher.py Normal file
View File

@@ -0,0 +1,357 @@
from datetime import datetime as dt
import json
import sys
from time import sleep
import traceback
from typing import Any, Dict, List
import pytumblr
import polars as pl
def filter_for_after(df: pl.DataFrame,
after: dt) -> pl.DataFrame:
if df.is_empty() or not after: return pl.DataFrame()
base_col = pl.col('date')
if df.get_column('date').dtype == pl.String:
base_col = pl.when(base_col.str.contains('GMT')) \
.then(base_col.str.to_date('%Y-%m-%d %H:%M:%S GMT')) \
.otherwise(base_col.str.to_date('%Y-%m-%d %H:%M:%S'))
return df.filter(base_col >= after.date())
def filter_for_before(df: pl.DataFrame,
before: dt) -> pl.DataFrame:
if df.is_empty() or not before: return pl.DataFrame()
base_col = pl.col('date')
if df.get_column('date').dtype == pl.String:
base_col = pl.when(base_col.str.contains('GMT')) \
.then(base_col.str.to_date('%Y-%m-%d %H:%M:%S GMT')) \
.otherwise(base_col.str.to_date('%Y-%m-%d %H:%M:%S'))
return df.filter(base_col <= before.date())
def handle_error(e: Exception, data: Any, df: pl.DataFrame) -> None:
print(e)
traceback.print_exc()
schema_dict = {col: str(dtype) for col, dtype in df.schema.items()}
with open("schema.json", "w") as f:
json.dump(schema_dict, f, indent=4)
with open("bad_posts.json", "w") as f:
json.dump(data, f, indent=4, default=str)
def handle_limit_exceeded(data) -> bool:
if 'meta' in data and data['meta']['status'] == 429:
print("WARN: API limit exceeded. Sleeping for 10 minutes...")
sleep(600)
return True
sleep(3)
return False
def fetch_minimized_post(blog_name: str,
client: pytumblr.TumblrRestClient,
id_string: str) -> Dict[str, Any]:
api_url = f"/v2/blog/{blog_name}/posts"
data: Dict[str, Any] = client.send_api_request(method='get',
url=api_url,
params={'id': id_string},
valid_parameters=['id'])
if 'posts' in data:
posts = data['posts']
if handle_limit_exceeded(data):
data = client.send_api_request(method='get', url=api_url, params={'id': id_string}, valid_parameters=['id'])
posts = data['posts']
if 'posts' not in data or not data['posts']:
print(f"WARN: Post with ID {id_string} could not be retrieved!")
print(data)
return {}
minimized_post: Dict[str, Any] = {}
if posts:
minimized_post = posts[0]
minimized_post = {
'id_string': minimized_post['id_string'],
'url': minimized_post['post_url'],
'date_str': minimized_post['date'],
'tags': minimized_post['tags'],
'is_og': 'parent_post_url' not in minimized_post
}
return minimized_post
def fetch_queued_posts(client: pytumblr.TumblrRestClient,
args: Dict[str, Any]) -> pl.DataFrame:
queued_posts: pl.DataFrame = pl.DataFrame()
blog_name: str = args['blog']
offset: int = 0
limit: int = 20
while True:
# Begin LOOP
# Get me some queued posts via REST! 😈🍪🍪🍪
data: Dict[str, Any]
data = client.queue(f"{blog_name}.tumblr.com",
offset=offset,
limit=limit)
if handle_limit_exceeded(data):
data = client.queue(f"{blog_name}.tumblr.com",
offset=offset,
limit=limit)
# This struct breaks Polars for some reason 🤷
posts_json = data['posts']
for post in posts_json:
if 'trail' in post:
del post['trail']
try:
curr_posts: pl.DataFrame = pl.DataFrame(posts_json)
except Exception as e:
handle_error(e, data, queued_posts)
sys.exit(0)
# Stop the presses if we found no posts.
if curr_posts.is_empty() or len(curr_posts) < 1:
print('Stopping queued post retrieval, as no more queued posts were found.')
break
queued_posts = pl.concat([queued_posts, curr_posts], how="diagonal_relaxed")
offset += limit
# End LOOP
# Empty check.
if queued_posts.is_empty():
print("No queued posts were found.")
return queued_posts
def fetch_drafts(client: pytumblr.TumblrRestClient,
args: Dict[str, Any]) -> pl.DataFrame:
drafts: pl.DataFrame = pl.DataFrame()
blog_name: str = args['blog']
draft_url = f"/v2/blog/{blog_name}/posts/draft"
while True:
# Begin LOOP
# Get me some drafts via REST! 😈🍪🍪🍪
data: Dict[str, Any]
data = client.send_api_request("get", draft_url)
if handle_limit_exceeded(data):
data = client.send_api_request("get", draft_url)
# This struct breaks Polars for some reason 🤷
drafts_json = data['posts']
for draft in drafts_json:
if 'trail' in draft:
del draft['trail']
curr_drafts: pl.DataFrame = pl.DataFrame(drafts_json)
drafts = pl.concat([drafts, curr_drafts], how="diagonal_relaxed")
if '_links' in data:
draft_url = data['_links']['next']['href']
continue
else:
print('Stopping draft retrieval, as no more drafts were found.')
break
# End LOOP
# Empty check.
if drafts.is_empty():
print("No drafts were found.")
return drafts
def fetch_posts(client: pytumblr.TumblrRestClient,
args: Dict[str, Any]) -> pl.DataFrame:
posts: pl.DataFrame = pl.DataFrame()
blog_name: str = args['blog']
# We populate params, starting with any tags for filtering.
params = {}
if args['tags']:
params.update({'tag': ','.join(args['tags'])})
total: int = 0
offset: int = 0
limit: int = 20
# The request loop that pulls all data from the APIs.
while True:
# Begin LOOP
# Get me some posts via REST! 😈🍪🍪🍪
data: Dict[str, Any]
data = client.posts(f"{blog_name}.tumblr.com",
offset=offset,
limit=limit,
**params)
if handle_limit_exceeded(data):
data = client.posts(f"{blog_name}.tumblr.com",
offset=offset,
limit=limit,
**params)
# These structs break Polars for some reason 🤷
posts_json = data['posts']
for post in posts_json:
if 'trail' in post:
del post['trail']
if 'player' in post:
del post['player']
try:
curr_posts: pl.DataFrame = pl.DataFrame(posts_json, strict=False, infer_schema_length=None)
except Exception as e:
handle_error(e, data, curr_posts)
sys.exit(1)
# Stop the presses if we found no posts.
if curr_posts.is_empty() or len(curr_posts) < 1:
print('Stopping, as no more posts were found.')
break
next_off: int = 0
# Total init check for the first iteration, but always checked for sanity.
if not total and 'total_posts' in data:
total_posts = data['total_posts']
print(f"I'm working with {total_posts} total posts...")
total = total_posts
if args['since'] and not args['until']:
after: dt = args['since']
curr_posts = filter_for_after(curr_posts, after)
if curr_posts.is_empty():
print(
f"All posts since {after.year}-{after.month}-{after.day} retrieved.")
return posts
if args['until'] and not args['since']:
before: dt = args['until']
curr_posts = filter_for_before(curr_posts, before)
if curr_posts.is_empty():
print(
f"All posts until {before.year}-{before.month}-{before.day} retrieved.")
return posts
if args['since'] and args['until']:
if not posts.is_empty() and curr_posts.is_empty():
print(f"All posts since {after.year}-{after.month}-{after.day}, "
+ f"until {before.year}-{before.month}-{before.day} retrieved.")
return posts
# Update the frame with what we found.
try:
posts = pl.concat([posts, curr_posts], how="diagonal_relaxed")
except Exception as e:
handle_error(e, data, posts)
sys.exit(1)
# The increment and status printing.
if next_off != 0 and next_off != offset:
offset = next_off
else:
offset += limit
if not (args['since'] or args['until']) and total:
print(
f"Processed batch {offset // limit} of {(total // 20) + 1}...")
# End LOOP
if posts.is_empty():
print("No posts were found.")
# Return posts (both original and not original).
return posts.unique(subset=["id_string"])
def fetch_activity(client: pytumblr.TumblrRestClient,
args: Dict[str, Any],
posts: pl.DataFrame = pl.DataFrame()) -> pl.DataFrame:
activity: pl.DataFrame = pl.DataFrame(infer_schema_length=None)
blog_name: str = args['blog']
before: dt = args['until']
after: dt = args['since']
activity_url = f"/v2/blog/{blog_name}/notifications"
if before:
activity_url += f"?before={str(int(before.timestamp()))}"
params = {}
params.update({'rollups': 'false'})
post_map: Dict[str, Any] = {}
if not posts.is_empty():
post_map = { row["id"]: row for row in posts.to_dicts() }
data: Dict[str, Any]
while True:
# Begin LOOP
# Get me some activity via REST! 😈🍪🍪🍪
data = client.send_api_request("get", activity_url)
if handle_limit_exceeded(data):
data = client.send_api_request("get", activity_url)
try:
curr_activity: pl.DataFrame = pl.DataFrame(data['notifications'], infer_schema_length=None)
except Exception as e:
handle_error(e, data, activity)
sys.exit(0)
curr_activity = curr_activity.with_columns(pl.from_epoch('timestamp').alias('date'))
if before:
curr_activity = filter_for_before(curr_activity, before)
if after:
curr_activity = filter_for_after(curr_activity, after)
# Add additional post info to notifications.
# Yeah, df to dictionary list. Sorry. :c
curr_activity_dicts: List[Dict[str, Any]] = curr_activity.to_dicts()
for notification in curr_activity_dicts:
if 'target_post_id' in notification and notification['target_post_id']:
target_post_id = notification['target_post_id']
post: Any
if target_post_id in post_map:
post = post_map.get(target_post_id)
notification['post'] = post
continue
elif 'type' in notification and notification['type'] == 'post_attribution':
notification['post'] = {}
continue
else:
post = fetch_minimized_post(blog_name, client, notification['target_post_id'])
notification['post'] = post
post_map.update({target_post_id: post})
continue
curr_activity = pl.DataFrame(curr_activity_dicts, strict=False, infer_schema_length=None)
activity = pl.concat([activity, curr_activity], how="diagonal_relaxed")
if '_links' in data:
next_time = dt.fromtimestamp(int(data['_links']['next']['query_params']['before']))
if before or after:
print(f"Reached activity time: {next_time}...")
if (before and next_time >= before) or (after and next_time <= after):
print('Stopping activity retrieval, as no more activity is left in date range.')
break
activity_url = data['_links']['next']['href']
continue
else:
print('Stopping activity retrieval, as no more activity was found.')
break
# End LOOP
# Empty check.
if activity.is_empty():
print("No activity was found.")
else:
activity = activity.unique(subset=["id"])
return activity

309
model.py Normal file
View File

@@ -0,0 +1,309 @@
from dataclasses import dataclass, field
from enum import Enum
from typing import Any, Dict, List
import polars as pl
class DateAgg(Enum):
DAY = 'DAY'
MONTH = 'MONTH'
QUARTER = 'QUARTER'
YEAR = 'YEAR'
@dataclass
class StatsModel:
"""Class that models the output of the Tumblr stats script."""
# The blog in question.
blog_name: str
# Contains posts.
posts: pl.DataFrame
# Contains queued posts.
queued_posts: pl.DataFrame
# Contains drafted posts, both original and not original.
drafts: pl.DataFrame
# Contains activity (notifications).
activity: pl.DataFrame
# Any tags used.
tags: List[str] = field(default_factory=list)
# Total count of posts processed.
total_posts: int = field(init=False)
# Total original posts (for blog_name) processed.
total_original_posts: int = field(init=False)
# Total original post (for blog_name) notes processed.
total_original_post_notes: int = field(init=False)
# Note stats for original posts by YYYY-MM-DD.
original_post_notes_by_date: List[Dict[str, Any]] = field(init=False)
# Note stats for original posts by month and year.
original_post_notes_by_month_and_year: List[Dict[str, Any]] = field(init=False)
# Note stats for original posts by quarter and year.
original_post_notes_by_qtr_and_year: List[Dict[str, Any]] = field(init=False)
# Note stats for original posts by year.
original_post_notes_by_year: List[Dict[str, Any]] = field(init=False)
# Tags ranked from most popular to least popular by notes.
most_popular_tags: List[Dict[str, Any]] = field(init=False)
# Original posts ranked from most popular to least popular by notes.
ranked_original_posts: List[Dict[str, Any]] = field(init=False)
# Original post note stats by interaction month and year.
original_post_notes_by_interaction_date: List[Dict[str, Any]] = field(init=False)
# Original post note stats by interaction month and year.
original_post_notes_by_interaction_month_and_year: List[Dict[str, Any]] = field(init=False)
# Top blogs by original post note count.
top_interactors: List[Dict[str, Any]] = field(init=False)
# Queued posts (both original and not original), sorted in publish order.
ordered_queue: List[Dict[str, Any]] = field(init=False)
# Top tags in queued posts (both original and not original), sorted by post count.
top_queued_tags: List[Dict[str, Any]] = field(init=False)
# Top tags in drafts (both original and not original), sorted by post count.
top_draft_tags: List[Dict[str, Any]] = field(init=False)
def __post_init__(self):
self.total_posts = self.calculate_total_posts()
self.total_original_posts = self.calculate_total_original_posts()
self.total_original_post_notes = self.calculate_original_post_notes()
self.original_post_notes_by_date = self.collect_note_stats_by_original_post_year_month_day()
self.original_post_notes_by_month_and_year = self.collect_note_stats_by_original_post_month_and_year()
self.original_post_notes_by_qtr_and_year = self.collect_note_stats_by_original_post_qtr_and_year()
self.original_post_notes_by_year = self.collect_note_stats_by_original_post_year()
self.most_popular_tags = self.determine_most_popular_tags(
self.get_original_posts(), "note_count")
self.ranked_original_posts = self.rank_original_posts()
self.original_post_notes_by_interaction_month_and_year = self.collect_note_stats_by_op_interaction_month_and_year()
self.original_post_notes_by_interaction_date = self.collect_note_stats_by_op_interaction_year_month_day()
self.top_interactors = self.determine_top_interactors()
self.ordered_queue = self.determine_ordered_queue()
self.top_queued_tags = self.determine_most_popular_tags(
self.queued_posts, 'post_count')
self.top_draft_tags = self.determine_most_popular_tags(
self.drafts, 'post_count')
@staticmethod
def determine_most_popular_tags(df: pl.DataFrame, sort_key: str) -> List[Dict[str, Any]]:
if df.is_empty():
return []
grouped_tags = df.explode(
'tags', keep_nulls=False, empty_as_null=False).group_by('tags')
result: pl.DataFrame = pl.DataFrame()
by_post_count = grouped_tags.agg(pl.count().alias(
'post_count')).sort('post_count', descending=True)
if sort_key == 'post_count':
result = by_post_count.rename({'tags': 'tag'})
elif sort_key == 'note_count':
result = grouped_tags.agg(pl.sum('note_count'))
result = result.join(other=by_post_count, on='tags', how='inner')
result = result.select(
(pl.col('tags').alias('tag')),
(pl.col('note_count')),
(pl.col('post_count')),
(pl.col('note_count').truediv(pl.col('post_count'))).alias(
"notes_to_posts_ratio")
)
result = result.sort('note_count', descending=True)
else:
print(f"WARN: {sort_key} is not a supported sort key!")
return result.to_dicts()
def get_original_posts(self) -> pl.DataFrame:
if self.posts.is_empty():
return pl.DataFrame()
return self.posts.filter(pl.col('parent_post_url').is_null())
def calculate_total_posts(self) -> int:
if self.posts.is_empty():
return 0
return len(self.posts)
def calculate_total_original_posts(self) -> int:
if self.posts.is_empty():
return 0
return len(self.get_original_posts())
def calculate_original_post_notes(self) -> int:
if self.posts.is_empty():
return 0
og_posts = self.get_original_posts()
return int(og_posts['note_count'].sum())
def rank_original_posts(self) -> List[Dict[str, Any]]:
og_posts = self.get_original_posts()
sorted_posts = og_posts.clone()
if not sorted_posts.is_empty():
sorted_posts = sorted_posts.sort(by='note_count', descending=True)
return sorted_posts.to_dicts()
def determine_top_interactors(self) -> List[Dict[str, Any]]:
if self.activity.is_empty(): return []
og_activity = self.activity.filter(pl.col('post').struct.field('is_og') == True)
interactor_group_by = og_activity.group_by(pl.col('from_tumblelog_name'))
agg = interactor_group_by.agg(pl.count().alias('interaction_count')).sort('interaction_count', descending=True)
return agg.to_dicts()
def determine_ordered_queue(self) -> List[Dict[str, Any]]:
if self.queued_posts.is_empty():
return []
sorted_queued_posts = self.queued_posts.sort(
by='scheduled_publish_time', descending=False)
return sorted_queued_posts.to_dicts()
def collect_note_stats_by_original_post_date(self, date_agg: DateAgg) -> List[Dict[str, Any]]:
og_posts = self.get_original_posts()
if og_posts.is_empty(): return []
sorted_og_posts = og_posts.sort(by='date')
group_key = self._get_group_key(sorted_og_posts, date_agg)
grouped_posts = sorted_og_posts.group_by(group_key)
base_stats = grouped_posts.agg(pl.col('id_string').n_unique().alias('post_count'),
pl.sum('note_count').alias('note_count'))
# For breakdown by tag.
exploded_tags = sorted_og_posts.explode(
'tags', keep_nulls=False, empty_as_null=False)
exploded_tags = exploded_tags.rename({'tags': 'tag'})
grouped_tag_explosion = exploded_tags.group_by([group_key, pl.col('tag')])
by_tags = grouped_tag_explosion.agg(
pl.col('id_string').n_unique().alias('post_count'),
pl.sum('note_count'),
pl.sum('note_count').truediv(pl.col('id_string').n_unique())
.alias("notes_to_posts_ratio")).sort('note_count', descending=True)
by_tags = by_tags.group_by(pl.col('group_key')).agg(
pl.struct(pl.all()).alias('tags'))
# For breakdown by post.
grouped_by_posts = sorted_og_posts.group_by([group_key,
pl.col('post_url').alias('url')])
by_posts = grouped_by_posts.agg(pl.sum('note_count')) \
.sort('note_count', descending=True)
by_posts = by_posts.group_by(pl.col('group_key')).agg(pl.struct(pl.all()).alias('posts'))
post_cols = [(pl.col('note_count')),
(pl.col('post_count')),
(pl.col('note_count').truediv(pl.col('post_count'))).alias(
"notes_to_posts_ratio"),
(pl.col('tags')),
(pl.col('posts'))]
# Join to get final result.
result = base_stats.join(other=by_tags, on='group_key', how='inner')
result = result.join(other=by_posts, on='group_key', how='inner')
return self._date_agg_final_select(result, post_cols, date_agg).to_dicts()
def collect_note_stats_by_original_post_year_month_day(self) -> List[Dict[str, Any]]:
return self.collect_note_stats_by_original_post_date(DateAgg.DAY)
def collect_note_stats_by_original_post_month_and_year(self) -> List[Dict[str, Any]]:
return self.collect_note_stats_by_original_post_date(DateAgg.MONTH)
def collect_note_stats_by_original_post_qtr_and_year(self) -> List[Dict[str, Any]]:
return self.collect_note_stats_by_original_post_date(DateAgg.QUARTER)
def collect_note_stats_by_original_post_year(self) -> List[Dict[str, Any]]:
return self.collect_note_stats_by_original_post_date(DateAgg.YEAR)
def collect_note_stats_by_op_interaction_month_and_year(self) -> List[Dict[str, Any]]:
return self.collect_note_stats_by_op_interaction_date(DateAgg.MONTH)
def collect_note_stats_by_op_interaction_year_month_day(self) -> List[Dict[str, Any]]:
return self.collect_note_stats_by_op_interaction_date(DateAgg.DAY)
def collect_note_stats_by_op_interaction_date(self, date_agg: DateAgg) -> List[Dict[str, Any]]:
if self.activity.is_empty(): return []
og_activity = self.activity.filter(pl.col('post').struct.field('is_og') == True)
og_activity = og_activity.with_columns(pl.col('post').struct.field('tags').alias('og_post_tags'),
pl.col('post').struct.field('url').alias('og_post_url'))
sorted_og_activity = og_activity.sort(by='date')
group_key = self._get_group_key(sorted_og_activity, date_agg)
grouped_activity = sorted_og_activity.group_by(group_key)
base_stats = grouped_activity.agg(pl.col('id').n_unique().alias('interaction_count'))
base_stats.write_json('lesbo.json')
# For breakdown by tag.
exploded_tags = sorted_og_activity.explode(columns='og_post_tags', keep_nulls=False, empty_as_null=False)
exploded_tags = exploded_tags.rename({'og_post_tags': 'tag'})
grouped_tag_explosion = exploded_tags.group_by([group_key, pl.col('tag')])
by_tags = grouped_tag_explosion.agg(pl.col('id').n_unique().alias('interaction_count')) \
.sort('interaction_count', descending=True)
by_tags = by_tags.group_by(pl.col('group_key')).agg(pl.struct(pl.all()).alias('tags'))
# For breakdown by post.
grouped_by_posts = sorted_og_activity.group_by([group_key,
pl.col('og_post_url').alias('url')])
by_posts = grouped_by_posts.agg(pl.col('id').n_unique().alias('interaction_count')) \
.sort('interaction_count', descending=True)
by_posts = by_posts.group_by(pl.col('group_key')).agg(pl.struct(pl.all()).alias('posts'))
actv_columns = [(pl.col('interaction_count')),
(pl.col('tags')),
(pl.col('posts'))]
# Join to get final result.
result = base_stats.join(other=by_tags, on='group_key', how='inner')
result = result.join(other=by_posts, on='group_key', how='inner')
return self._date_agg_final_select(result, actv_columns, date_agg).to_dicts()
def _get_group_key(self, df: pl.DataFrame, date_agg: DateAgg):
base_col = pl.col('date')
if df.get_column('date').dtype == pl.String:
base_col = pl.when(base_col.str.contains('GMT')) \
.then(base_col.str.to_date('%Y-%m-%d %H:%M:%S GMT')) \
.otherwise(base_col.str.to_date('%Y-%m-%d %H:%M:%S'))
match date_agg:
case DateAgg.DAY:
group_key = base_col.dt.date().alias('group_key')
case DateAgg.MONTH:
group_key = base_col.dt.month_start().dt.date().alias('group_key')
case DateAgg.QUARTER:
group_key = pl.struct(base_col.dt.year().alias('year'),
base_col.dt.quarter().alias('quarter')) \
.alias('group_key')
case DateAgg.YEAR:
group_key = base_col.dt.year().alias('group_key')
return group_key
def _date_agg_final_select(self, result: pl.DataFrame, cols: List[pl.Expr], date_agg: DateAgg) -> pl.DataFrame:
match date_agg:
case DateAgg.DAY:
final_cols = [(pl.col('group_key').dt.year().alias('year')),
(pl.col('group_key').dt.month().alias('month')),
(pl.col('group_key').dt.day().alias('day'))]
final_cols.extend(cols)
return result.select(final_cols).sort(by=['year', 'month', 'day'], descending=True)
case DateAgg.MONTH:
final_cols = [(pl.col('group_key').dt.year().alias('year')), (pl.col('group_key').dt.month().alias('month'))]
final_cols.extend(cols)
return result.select(final_cols).sort(by=['year', 'month'], descending=True)
case DateAgg.QUARTER:
final_cols = [(pl.col('group_key').alias('quarter'))]
final_cols.extend(cols)
return result.select(final_cols).sort(by='quarter', descending=True)
case DateAgg.YEAR:
final_cols = [(pl.col('group_key').alias('year'))]
final_cols.extend(cols)
return result.select(final_cols).sort(by='year', descending=True)

View File

@@ -1,11 +1,14 @@
[project] [project]
name = "tumblr_stats" name = "tumblr_stats"
version = "0.1.0" version = "1.0.0"
description = "Calculate Tumblr stats using pytumblr." description = "Calculate Tumblr stats using pytumblr and Polars."
authors = [ authors = [
{name = "Amber", email = "amber@ailuridae.io"}, {name = "Amber", email = "amber@ailuridae.io"},
] ]
dependencies = ["pytumblr==0.1.2"] dependencies = [
"pytumblr==0.1.2",
"polars==1.44.1",
]
requires-python = "==3.14.*" requires-python = "==3.14.*"
readme = "README.md" readme = "README.md"

View File

@@ -1,46 +1,46 @@
import argparse import argparse
import csv
from dataclasses import asdict from dataclasses import asdict
from datetime import datetime import dataclasses
from datetime import datetime as dt
import json import json
import os import os
from pathlib import Path from pathlib import Path
import sys import sys
from typing import Any, Callable, Dict, List, Tuple from typing import Any, Dict, List
import pytumblr import pytumblr
import polars as pl
from build_deep_stats_model import BuildDeepStatsModel import fetcher
from build_draft_stats_model import BuildDraftStatsModel from model import StatsModel
from build_tag_stats_model import BuildTagStatsModel
from build_total_stats_model import BuildTotalStatsModel
from build_queue_stats_model import BuildQueueStatsModel
from stats_model import StatsModel
def get_args() -> Dict[str, Any]: def get_args() -> Dict[str, Any]:
"""Pull arguments from command line, turn them into a dictionary of <arg, value>""" """Pull arguments from command line, turn them into a dictionary of <arg, value>"""
parser: argparse.ArgumentParser = argparse.ArgumentParser( parser: argparse.ArgumentParser = argparse.ArgumentParser(
prog='tumblr_stats.py', prog='tumblr_stats.py',
description='Use pytumblr to calculate stats after setting these enviroment variables: ' description='Use pytumblr and Polars to calculate stats after setting these enviroment variables: '
+ '$TUMBLR_CONSUMER_KEY, $TUMBLR_CONSUMER_SECRET, $TUMBLR_OAUTH_TOKEN, and $TUMBLR_OAUTH_SECRET', + '$TUMBLR_CONSUMER_KEY, $TUMBLR_CONSUMER_SECRET, $TUMBLR_OAUTH_TOKEN, and $TUMBLR_OAUTH_SECRET',
epilog='— Be gay and do crime') epilog='— Be gay and do crime')
parser.add_argument('operation', type=str, nargs='+', metavar='OPERATION',
choices=['build_tag_stats', 'build_queue_stats', 'build_draft_stats',
'build_deep_stats'],
help="operation used to calculate stats")
parser.add_argument('-b', '--blog', type=str, required=True, parser.add_argument('-b', '--blog', type=str, required=True,
help='blog name for which to calculate stats') help='blog name for which to calculate stats')
parser.add_argument('-t', '--tags', type=str, nargs='+', parser.add_argument('-t', '--tags', type=str, nargs='+',
help='tag(s) to focus on in status (if applicable)') help='tag(s) on which to filter (only affects posts)')
parser.add_argument('-i', '--input', type=str, parser.add_argument('-s', '--suppress', type=str, nargs='+',
help='Don\'t make API calls, just use a JSON input file') choices=['none', 'posts', 'queued', 'drafts', 'activity'],
# TODO: Make 'before' work, but it depends on https://github.com/tumblr/pytumblr/issues/174. default='activity',
# parser.add_argument('--before', type=lambda s: datetime.strptime(s, '%Y-%m-%d'), help='objects to skip when aggregating data; will render some stats empty')
# help='only gather posts before YYYY-MM-DD') parser.add_argument('--until', type=lambda s: dt.strptime(s, '%Y-%m-%d'),
parser.add_argument('--after', type=lambda s: datetime.strptime(s, '%Y-%m-%d'), help='only gather posts and activity until YYYY-MM-DD')
help='only gather posts after YYYY-MM-DD') parser.add_argument('--since', type=lambda s: dt.strptime(s, '%Y-%m-%d'),
help='only fetch posts and activity since YYYY-MM-DD')
parser.add_argument('--queued-file', type=str,
help='read queued posts from this path instead of fetching them from APIs')
parser.add_argument('--drafts-file', type=str,
help='read drafts from this path instead of fetching them from APIs')
parser.add_argument('--posts-file', type=str,
help='read posts from this path instead of fetching them from APIs')
parser.add_argument('--activity-file', type=str,
help='read activity from this path instead of fetching it from APIs')
return vars(parser.parse_args()) return vars(parser.parse_args())
@@ -68,205 +68,76 @@ def init_client() -> pytumblr.TumblrRestClient:
) )
def filter_posts_for_after(post_list: List[Dict[str, Any]], def json_encoder(obj):
after: datetime) -> List[Dict[str, Any]]: if dataclasses.is_dataclass(obj):
# Quick short circuit check. return dataclasses.asdict(obj) # type: ignore
if not post_list or not after: elif isinstance(obj, pl.DataFrame):
return [] return json.loads(obj.write_json())
raise TypeError(f"Type {type(obj)} is not serializable!")
# Handle 'after'.
after_check: Callable[[Dict[str, Any]], bool] = lambda x: datetime.strptime(
x['date'], '%Y-%m-%d %H:%M:%S %Z') > after
return [post for post in post_list if after_check(post)]
def build_post_maps(client: pytumblr.TumblrRestClient, def file_is_used(obj_arg: str, args: Dict[str, Any]) -> bool:
args: Dict[str, Any]) -> Tuple[Dict[str, Any], Dict[str, Any]]: return obj_arg in args and args[obj_arg]
og_post_map: Dict[str, Any] = {}
un_og_post_map: Dict[str, Any] = {}
blog_name: str = args['blog']
# We populate params, starting with any tags for filtering.
params = {}
if args['tags']:
params.update({'tag': ','.join(args['tags'])})
# TODO: Make 'before' work.
# if args['before']:
# before: datetime = args['before']
# params.update({'before': int(before.timestamp())})
draft_url = f"/v2/blog/{blog_name}/posts/draft" def load_from_file(path: str) -> pl.DataFrame:
is_draft_stats: bool = 'build_draft_stats' in args['operation'] return pl.read_json(source=Path(path), infer_schema_length=None)
is_deep_stats: bool = 'build_deep_stats' in args['operation']
total: int = 0
offset: int = 0
limit: int = 20
# The request loop that pulls all data from the APIs.
while True:
# Begin LOOP
# Get me some posts via REST! 😈🍪🍪🍪
data: Dict[str, Any]
if 'build_queue_stats' in args['operation']:
data = client.queue(f"{blog_name}.tumblr.com",
offset=offset,
limit=limit,
**params)
elif is_draft_stats:
data = client.send_api_request("get", draft_url)
else: # Above is for queued + draft posts, below is for published posts.
if is_deep_stats:
params.update({'notes_info': 'true'})
data = client.posts(f"{blog_name}.tumblr.com",
offset=offset,
limit=limit,
**params)
# Stop the presses if we found no posts.
curr_posts: List[Dict[str, Any]] = data['posts']
if not curr_posts or len(curr_posts) < 1:
print('Stopping, as no more posts were found.')
break
next_off: int = 0
if '_links' in data and not is_draft_stats:
links = data['_links']
if 'next' in links and 'query_params' in links['next']:
next_off = int(links['next']['query_params']['offset'])
# Total init check for the first iteration, but always checked for sanity.
if not total and 'total_posts' in data:
total_posts = data['total_posts']
print(f"I'm working with {total_posts} total posts...")
total = total_posts
# Behavior for 'after'.
if args['after']:
after: datetime = args['after']
curr_posts = filter_posts_for_after(curr_posts, after)
if not curr_posts:
print(f"All posts after {after.year}-{after.month}-{after.day} processed.")
return (og_post_map, un_og_post_map)
# This block populates the local post_maps from the raw response data.
local_og_post_map: Dict[str, Any] = {
item['id_string']: item for item in curr_posts if 'parent_post_url' not in item
}
local_un_og_post_map: Dict[str, Any] = {
item['id_string']: item for item in curr_posts if 'parent_post_url' in item
}
# Update the maps with what we found.
og_post_map.update(local_og_post_map)
un_og_post_map.update(local_un_og_post_map)
# For build_draft_stats.
if is_draft_stats:
if '_links' in data:
draft_url = data['_links']['next']['href']
continue
else:
print('All draft posts processed.')
break
# The increment and status printing.
if next_off != 0 and next_off != offset:
offset = next_off
else:
offset += limit
if not args['after'] and total:
print(
f"Processed batch {offset // limit} of {(total // 20) + 1}...")
# End LOOP
# Return (og_posts, not_og_posts).
return (og_post_map, un_og_post_map)
def main() -> None: def main() -> None:
args: Dict[str, Any] = get_args() args: Dict[str, Any] = get_args()
suppress = args['suppress']
client: pytumblr.TumblrRestClient = init_client() client: pytumblr.TumblrRestClient = init_client()
operation: List[str] = args['operation']
# Quick bail for bad use of build_queue_stats and build_draft_stats. # Read queued posts from a file, or fetch them via API and write queued post JSON.
if (set(operation) & set(['build_queue_stats', 'build_draft_stats'])) and len(operation) > 1: queued_posts: pl.DataFrame = pl.DataFrame()
print(f"You can't mix operations {operation} together. Sorry.") if not suppress or 'queued' not in suppress:
sys.exit(1) if file_is_used('queued_file', args):
pass queued_posts = load_from_file(args['queued_file'])
# Handle JSON input (if you don't want to make API calls.)
if 'input' in args and args['input']:
input_path = Path(args['input'])
with open(input_path, "r") as f:
data = json.load(f)
og_post_map = data['original_post_map']
un_og_post_map = data['unoriginal_post_map']
for post_key in og_post_map.copy():
post = og_post_map[post_key]
date: datetime = datetime.strptime(
post['date'], '%Y-%m-%d %H:%M:%S %Z')
if date.year != 2025:
del og_post_map[post_key]
for post_key in un_og_post_map.copy():
post = un_og_post_map[post_key]
date: datetime = datetime.strptime(
post['date'], '%Y-%m-%d %H:%M:%S %Z')
if date.year != 2025:
del un_og_post_map[post_key]
else: else:
# Get the og_post_map (original posts) and un_og_post_map (not original posts). queued_posts = fetcher.fetch_queued_posts(client, args)
og_post_map, un_og_post_map = build_post_maps(args=args, client=client) queued_posts.write_json("queued_posts.json")
# Pick a stats model, which will determine output. # Read drafts from a file, or fetch them via API and write draft JSON.
stats_model: StatsModel drafts: pl.DataFrame = pl.DataFrame()
match args: if not suppress or 'drafts' not in suppress:
case {'operation': ['build_queue_stats']}: if file_is_used('drafts_file', args):
stats_model = BuildQueueStatsModel(blog_name=args['blog'], drafts = load_from_file(args['drafts_file'])
original_post_map=og_post_map, else:
unoriginal_post_map=un_og_post_map) drafts = fetcher.fetch_drafts(client, args)
case {'operation': ['build_draft_stats']}: drafts.write_json("drafts.json")
stats_model = BuildDraftStatsModel(blog_name=args['blog'],
original_post_map=og_post_map,
unoriginal_post_map=un_og_post_map)
case {'operation': op} if 'build_tag_stats' in operation:
stats_model = BuildTagStatsModel(blog_name=args['blog'],
original_post_map=og_post_map,
unoriginal_post_map=un_og_post_map)
stats_model.tags = args['tags']
case {'operation': op} if 'build_total_stats' in operation:
if 'before' not in args: # or 'after' not in args:
print(f"You must specify a time range for {op}. " +
'You\'ll otherwise request TOO MUCH DATA!')
sys.exit(1)
stats_model = BuildTotalStatsModel(blog_name=args['blog'],
original_post_map=og_post_map,
unoriginal_post_map=un_og_post_map)
case {'operation': op} if 'build_deep_stats' in operation:
if 'after' not in args: # or 'before' not in args:
print(f"You must specify a time range for {op}. " +
'You\'ll otherwise request TOO MUCH DATA!')
sys.exit(1)
stats_model = BuildDeepStatsModel(blog_name=args['blog'],
original_post_map=og_post_map,
unoriginal_post_map=un_og_post_map)
case _:
print('Unsupported command. How did you even make it this far?!')
sys.exit(1)
# Write the selected model as JSON output. # Read posts from a file, or fetch them via API and write post JSON.
posts: pl.DataFrame = pl.DataFrame()
if not suppress or 'posts' not in suppress:
if file_is_used('posts_file', args):
posts = load_from_file(args['posts_file'])
else:
posts = fetcher.fetch_posts(client, args)
posts.write_json("posts.json")
# Read activity from a file, or fetch them via API and write activity JSON.
activity: pl.DataFrame = pl.DataFrame(infer_schema_length=None)
if not suppress or 'activity' not in suppress:
if file_is_used('activity_file', args):
activity = load_from_file(args['activity_file'])
else:
activity = fetcher.fetch_activity(client, args, posts)
activity.write_json('activity.json')
# The model.
model: StatsModel = StatsModel(blog_name=args['blog'],
posts = posts,
queued_posts = queued_posts,
drafts = drafts,
activity = activity,
tags = args['tags'])
# Write the model as JSON output.
# https://stackoverflow.com/q/26244323
with open('./tumblr_stats.json', 'w') as f: with open('./tumblr_stats.json', 'w') as f:
json.dump(asdict(stats_model), f, indent=1, default=str) json.dump(asdict(model), f, indent=1, default=json_encoder)
# If there were original posts, create a CSV for them.
if og_post_map:
with open('./tumblr_original_posts.csv', 'w', newline='') as f:
post_list: List[Dict[str, Any]] = list(og_post_map.values())
wr = csv.DictWriter(f, quoting=csv.QUOTE_ALL, extrasaction='ignore',
fieldnames=post_list[0].keys())
wr.writeheader()
wr.writerows(post_list)
else:
print('No original posts were found, so a CSV of original posts was not written.')
return return

194
uv.lock generated Normal file
View File

@@ -0,0 +1,194 @@
version = 1
revision = 3
requires-python = "==3.14.*"
[[package]]
name = "certifi"
version = "2026.7.22"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/a3/c2/24167ea9858356b47a87a50d39908bfdb72ceeefe0041586e704e5376b3a/certifi-2026.7.22.tar.gz", hash = "sha256:741e2c3b351ddf169a738da9f2c048608ff7f2c5cc02f1ebc6b118bb090d5d55", size = 138112, upload-time = "2026-07-22T03:35:12.644Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/0b/a7/71ac2cff56fec219ed242bb11b8efb69fcc4bec75db06fb7bfe35de520e6/certifi-2026.7.22-py3-none-any.whl", hash = "sha256:62f22742b58a1a33014a2b6b706588a8d7e2a88ae7bd1a6ebe8c992928483775", size = 136983, upload-time = "2026-07-22T03:35:11.276Z" },
]
[[package]]
name = "charset-normalizer"
version = "3.5.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/e5/3f/143b048436775b0f76ac3eec145c019e8173ccc2885c8f20319b996d5e83/charset_normalizer-3.5.1.tar.gz", hash = "sha256:6117b84ea48435e5356dc737f5121485c30920ba43375fa7b434fd753df0eac3", size = 171764, upload-time = "2026-08-15T08:20:44.807Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/29/cd/2b812ce5e888f1ce69a5350281e58aab07ae64a958ecae8912f30865718e/charset_normalizer-3.5.1-cp314-cp314-android_24_arm64_v8a.whl", hash = "sha256:774d157f112367ff4abd29019f38f023c24e00e56edc7829c20e358a5a913ad8", size = 212318, upload-time = "2026-08-15T08:18:04.403Z" },
{ url = "https://files.pythonhosted.org/packages/9e/4a/a6ee107430768a5334e6d63f31f148a04a1a491ef161a1ac9415a73f2fa8/charset_normalizer-3.5.1-cp314-cp314-android_24_x86_64.whl", hash = "sha256:26422d45fd13551cf564c58932f7d72b4f58b93b0fcf18c35ba6be12b46bb102", size = 224897, upload-time = "2026-08-15T08:18:05.997Z" },
{ url = "https://files.pythonhosted.org/packages/c3/d9/35ae3f64f29d0179c35c3baefe575904df2913dde519129c7f75995a2b1d/charset_normalizer-3.5.1-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:09a7bba9f739468c8e78c36a75c33768e53cb1959fc638f510454c14683f00d5", size = 194848, upload-time = "2026-08-15T08:18:07.397Z" },
{ url = "https://files.pythonhosted.org/packages/74/76/f2fc7380f056cc273a53af37f50d08ad54b2c59f61078f31432edcf1c2bd/charset_normalizer-3.5.1-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:4c9548dc78002099910abaebc0a72ac58b7d30931869e0351c09b507dff4ece3", size = 198163, upload-time = "2026-08-15T08:18:08.989Z" },
{ url = "https://files.pythonhosted.org/packages/e9/40/095ce62fa078483cccc1fa2b36e6bc9580b85422a20ee9f925341c50e44f/charset_normalizer-3.5.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:c428c6c31eb5f4277d7f8eccaf767fbd548ddd5ce3c8b4f4cbbfab3d96b5904c", size = 341823, upload-time = "2026-08-15T08:18:10.458Z" },
{ url = "https://files.pythonhosted.org/packages/f1/5a/0e58b1c04a1596e0256f407274a92d5fb2ee21324409d1fab1da48a65b5b/charset_normalizer-3.5.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2f06b7eae9dbe77fe1d644ca244dad508de8d302870a43f3c559b521270938a0", size = 242458, upload-time = "2026-08-15T08:18:11.989Z" },
{ url = "https://files.pythonhosted.org/packages/22/95/b4618ce912e6db0b1aae89ba788e38e8a7eba0f3025cc66e8c0699f977b2/charset_normalizer-3.5.1-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:6b7430cf5728e68f6c462254009a6ef4086e1bea43cf2f57aa9c55fb4f50ff96", size = 226717, upload-time = "2026-08-15T08:18:13.401Z" },
{ url = "https://files.pythonhosted.org/packages/8a/76/c681192bbda3d55356db5dadd64381d5202b37c6b598fcda5282e88b5d3d/charset_normalizer-3.5.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ab743e9bc90c1f73552ec33e10e3331315acd2c397b36065b591b0181de533cc", size = 266111, upload-time = "2026-08-15T08:18:14.961Z" },
{ url = "https://files.pythonhosted.org/packages/88/be/55127bfca72c0cff6c022488d140d7c5b04c771e3b72e9bdb4836d54979d/charset_normalizer-3.5.1-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f6f7deae3feb4edfa2efaf7c574fe88cbf055038a6abdb40188e4fff66d5699f", size = 263128, upload-time = "2026-08-15T08:18:16.515Z" },
{ url = "https://files.pythonhosted.org/packages/e0/91/39c3af510b0aa32bbda03374259200f28430febfd1bf5e511fe765282ce5/charset_normalizer-3.5.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:15f024313246a4ed976c60f440bb8d257815513a681d212ff74fd46f7d715a90", size = 251240, upload-time = "2026-08-15T08:18:18.127Z" },
{ url = "https://files.pythonhosted.org/packages/1c/a5/cbe418bbc6ecdfc3e05a0116002897c4b403a5e838d697e64c78e9f0190d/charset_normalizer-3.5.1-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:823f82903d189af463d7df250ef1f7f696f3cee08cc8d91deb565e8d425f6506", size = 245282, upload-time = "2026-08-15T08:18:19.625Z" },
{ url = "https://files.pythonhosted.org/packages/cc/a4/689bb42e8e7cd492f3cb64907c6bc00ad247ec9a3628cd3f8eed126e8ae1/charset_normalizer-3.5.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:01e93745f7f219b703b60ba7afead36cfc4242782be5af484673fc500df12da5", size = 244597, upload-time = "2026-08-15T08:18:21.121Z" },
{ url = "https://files.pythonhosted.org/packages/c1/ce/9962938e179cf9f699d3f1e7b3114b5d7642dee6a893745229f9dd04f274/charset_normalizer-3.5.1-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:329fc3ccb63ad22d867d84c2adea759a64079a37ba4a343433b02c7a2816871e", size = 231376, upload-time = "2026-08-15T08:18:22.57Z" },
{ url = "https://files.pythonhosted.org/packages/85/54/46000450ada53bd9eac5429a2c8c54cd2d9b39c0c255f229aea9af0948a5/charset_normalizer-3.5.1-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:bb57753e36e4855b8ca375069482250a6246372331a3e4f3407eaebb007443f5", size = 266715, upload-time = "2026-08-15T08:18:24.235Z" },
{ url = "https://files.pythonhosted.org/packages/3d/bb/618749d70f792b44252a777bf89bfb86823b9bbc1ea13fe8ce759b07f38a/charset_normalizer-3.5.1-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:fce8cbd4997efeb450bd298b54f755dcdff18d496f7a5ddbb4867c6d7c88fdc3", size = 245848, upload-time = "2026-08-15T08:18:25.726Z" },
{ url = "https://files.pythonhosted.org/packages/7e/3f/ffb64458527c7668031d5eb095d978de561958dc9f5b53f8e488a533e603/charset_normalizer-3.5.1-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:6c9cdde8becb25a7fde49924511aa2644d6f8081cc8df8e9452724303348d8e3", size = 264521, upload-time = "2026-08-15T08:18:27.193Z" },
{ url = "https://files.pythonhosted.org/packages/4f/ab/74a55fd803916a35ac461daf002708191aac19b546b80dc8cabfedc63d98/charset_normalizer-3.5.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:9ac4444d8d4fd4c4bd08bf451ed3167aa9e7ec6cdb41b648794f1d1103652e36", size = 253054, upload-time = "2026-08-15T08:18:28.568Z" },
{ url = "https://files.pythonhosted.org/packages/a0/2a/6a9034b7d3c60b17499afb482df5878bf9fa20b50cc3887d5ef017a833db/charset_normalizer-3.5.1-cp314-cp314-pyemscripten_2026_0_wasm32.whl", hash = "sha256:f03ac127268b43ef4fe9e6ab6794a6794b49485a0cc0c1db79876d2f33f75bc7", size = 140580, upload-time = "2026-08-15T08:18:30.214Z" },
{ url = "https://files.pythonhosted.org/packages/f3/46/1d362e1a00d035d66b9869e1281eee115907f7e390a16a07824ab5737360/charset_normalizer-3.5.1-cp314-cp314-win32.whl", hash = "sha256:1f5883d77fd409a261abb5dc8ccbe335720d798b1de4abb3b1d47ccbbc76b53b", size = 180325, upload-time = "2026-08-15T08:18:31.877Z" },
{ url = "https://files.pythonhosted.org/packages/7a/7c/4938c329b6a9d446f6a59aa2092ff7118f274209b5ed0e26893d1d30a63c/charset_normalizer-3.5.1-cp314-cp314-win_amd64.whl", hash = "sha256:c658c50ac0c98cd755a2dd50b7977d3bca7df401dcc47fbdfa87db53ef7d4e8b", size = 204175, upload-time = "2026-08-15T08:18:33.466Z" },
{ url = "https://files.pythonhosted.org/packages/ac/33/eeb384dbd8dec570661354592f4f2e1b2fcc92585624d146a000caf53841/charset_normalizer-3.5.1-cp314-cp314-win_arm64.whl", hash = "sha256:4bea7f8ebe90bbd7f0e4a2de42ca6924ba23e3e76418c408ff82f1d46fabd687", size = 184123, upload-time = "2026-08-15T08:18:34.913Z" },
{ url = "https://files.pythonhosted.org/packages/1c/6c/c73fa9d5a85f6ab05395de61c5f6984e0a9ff40bb5ff888d46dff02526c6/charset_normalizer-3.5.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:fbc597639158fd7c14d55e808718848319540f51b0e6746e3eefa59723a4a348", size = 381682, upload-time = "2026-08-15T08:18:36.349Z" },
{ url = "https://files.pythonhosted.org/packages/30/c7/63565f860921457feba93bae6c86fb7746deb4cffeed2f375cb845318146/charset_normalizer-3.5.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e71c909f353863b2b89c83de2ebed71ea6d0df8a6ef65a128193c5e650766bef", size = 240826, upload-time = "2026-08-15T08:18:37.887Z" },
{ url = "https://files.pythonhosted.org/packages/06/ae/7ae8807410dfa33f8e6f1715740adeaafa8a816cc4cb33508f54b1f7c896/charset_normalizer-3.5.1-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:7ac76cf9afd34929d76eb7fcb63be476a4853d8a96f0dcf2d0db68a0cbdf9885", size = 227861, upload-time = "2026-08-15T08:18:39.315Z" },
{ url = "https://files.pythonhosted.org/packages/e9/a3/887c1642f0da26000b0e0652d91071113c0e72cea33952e225cf589f49a9/charset_normalizer-3.5.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a3a370082ce34d0612f421e15fe011c53bb1feff21a26d06ad4fb244dab5a375", size = 260758, upload-time = "2026-08-15T08:18:40.88Z" },
{ url = "https://files.pythonhosted.org/packages/3e/11/e6f5b9a3d0e55b0ef7505cd3765cdd48f22db89994c947b316f52f801fd8/charset_normalizer-3.5.1-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:256dd4d85d9e4dc595e2bc983c980e73f62ddeb3165c58b4c3dfe78c5c8548c1", size = 259950, upload-time = "2026-08-15T08:18:42.351Z" },
{ url = "https://files.pythonhosted.org/packages/1b/ee/e4e10a94d51cd1ee638aa7e00b65399e6b2a4e8376ab6d2eac9f95586671/charset_normalizer-3.5.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:58d4aa13a59c969dbfdf9e6a9560e242cbfd9e8a8f50c2747714df1a423adf65", size = 249329, upload-time = "2026-08-15T08:18:43.914Z" },
{ url = "https://files.pythonhosted.org/packages/c4/25/d5f4198819e6059735a84e8d0bfb72dc33976da67b97adcd3fb5a5e07ec6/charset_normalizer-3.5.1-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0c6dfb5ca6723eeed15aa8e564a014d69fcb8812f94eef11fe3631e0508199f5", size = 243137, upload-time = "2026-08-15T08:18:45.368Z" },
{ url = "https://files.pythonhosted.org/packages/a5/e9/e925ca7569cf9fb9701fd82503fee73eea5268fdb856bdd64947092d3daa/charset_normalizer-3.5.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:c010f5581d9c612804cc59fcf7b524b707fbcb72828551237ab545bb5c7034af", size = 242820, upload-time = "2026-08-15T08:18:46.842Z" },
{ url = "https://files.pythonhosted.org/packages/34/17/672c251a888ed2aebcdd2fe830ad0104e25ff83c43f5c4f9c15e9fc6853c/charset_normalizer-3.5.1-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:52ec005752a56ae79547a05c0139ca2501a0c866390b6115008456b9f0e7cde1", size = 230504, upload-time = "2026-08-15T08:18:48.353Z" },
{ url = "https://files.pythonhosted.org/packages/3f/fc/f6a85abebd42ce4da2f1db0aa56cc6a0df1995e318b3875d14401b8381d1/charset_normalizer-3.5.1-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:2bced4061f000f7187254a02ad3433ae17eaf991747ceea2f478422590a5bba9", size = 263087, upload-time = "2026-08-15T08:18:49.859Z" },
{ url = "https://files.pythonhosted.org/packages/98/66/7c42677e739ba66746b297e2046918d793078094dc239e1e72768cffccc6/charset_normalizer-3.5.1-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:9eea3ab2597a5e65fe65296e2d6a84570845a6b55532d90333d740d48bbc850a", size = 243269, upload-time = "2026-08-15T08:18:51.601Z" },
{ url = "https://files.pythonhosted.org/packages/de/d8/a50b79237f417af10f8c2a501ce8d1ca87829a22e69117891ca4ba20a69e/charset_normalizer-3.5.1-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:496846868fea80e479324862fa877f02411f2fd0f83b79ccee2607aa68b2a032", size = 258766, upload-time = "2026-08-15T08:18:53.23Z" },
{ url = "https://files.pythonhosted.org/packages/2e/1d/0fc91aeaeb3c83b748f532399ce67cf84604b48297405d740000f7a9e786/charset_normalizer-3.5.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:85d5855daafc240cc045c026d7a15fd198a09b0fc8ff6f5ecbb5297b509cb11e", size = 250814, upload-time = "2026-08-15T08:18:54.768Z" },
{ url = "https://files.pythonhosted.org/packages/ae/10/3d8c777cf9024615295aa1b808324ad5b4a77855869c00824bad74ffaf8a/charset_normalizer-3.5.1-cp314-cp314t-win32.whl", hash = "sha256:58d3e12c88e0950bca850ae1f7c256055c097639c2edb9eb123af9807d8b15e4", size = 191074, upload-time = "2026-08-15T08:18:56.305Z" },
{ url = "https://files.pythonhosted.org/packages/4d/81/ae557d3c44d1a1d688696d60563413a0866a91b7ebc50f20df838be3d8c8/charset_normalizer-3.5.1-cp314-cp314t-win_amd64.whl", hash = "sha256:acaf604462bf330b0d07e7a07c1d6e4adac79e5fb13e9c5140590542cafacc00", size = 216476, upload-time = "2026-08-15T08:18:57.889Z" },
{ url = "https://files.pythonhosted.org/packages/27/e9/61c01fb8b804692569c036b3fc50495814502dcf13a60649c6055390b02c/charset_normalizer-3.5.1-cp314-cp314t-win_arm64.whl", hash = "sha256:fdb8a068947befafba9952162645dc2fecaeb400e64584829ed5e9b2fbe21a7f", size = 194115, upload-time = "2026-08-15T08:18:59.418Z" },
{ url = "https://files.pythonhosted.org/packages/5b/97/fb4e82231aba271ffd775a1b4993b0defc4e3059f286ae41d9433409fe85/charset_normalizer-3.5.1-cp37-abi3-macosx_10_9_universal2.whl", hash = "sha256:41876ee62a3dddf48ff1121ad8f0798032aa03f2fd35f21f34a4cab14f18d8d2", size = 331467, upload-time = "2026-08-15T08:19:50.959Z" },
{ url = "https://files.pythonhosted.org/packages/9f/2f/fe3f187327aac18e2d54e9d2b08e15d27bf9b642d9e51c219f130fc34d1a/charset_normalizer-3.5.1-cp37-abi3-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:a6dac12ff6b846103483683f60c5f8fee205121adc58ffd87e90a90a3af69e99", size = 253057, upload-time = "2026-08-15T08:19:52.654Z" },
{ url = "https://files.pythonhosted.org/packages/d7/c7/9e48cee5c161fe24da823b61bf381921d77cb994a0a4de148e95018c1984/charset_normalizer-3.5.1-cp37-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:cee5dd7c6fb5dd52a0fe2a740f9bc6e3593f5f8b1788bde49de02086f30182b2", size = 240930, upload-time = "2026-08-15T08:19:54.163Z" },
{ url = "https://files.pythonhosted.org/packages/49/e0/716601f3cc69be7b198951150c75ead1ece33c3c8036ff6ffa46029659a0/charset_normalizer-3.5.1-cp37-abi3-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:343fb4f2821043bd87095f7b08a1a181febc8e36ac64212143bbfd0a0e1bc235", size = 230822, upload-time = "2026-08-15T08:19:55.807Z" },
{ url = "https://files.pythonhosted.org/packages/d3/05/71bfc5caa0abcc45aea1f6a4d50ac68e59605ddc7666fe8494f4cd229665/charset_normalizer-3.5.1-cp37-abi3-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ae4a097991662cd4fff0ddc74e0fe7874f82e00042fa0ea00855645ed0c79598", size = 260037, upload-time = "2026-08-15T08:19:57.312Z" },
{ url = "https://files.pythonhosted.org/packages/c3/92/de7e32ed05341e7a9c4c877c318418197b7f2d66a3b68d561bf2ac57ca3e/charset_normalizer-3.5.1-cp37-abi3-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:4b599739b93b2cbeded49645ae3c8d1405c29ddfbceac1545c87a3f9580a9e96", size = 255097, upload-time = "2026-08-15T08:19:59.056Z" },
{ url = "https://files.pythonhosted.org/packages/f5/7b/ade0a122600319dfa0b1000ab0f9731c94a817904cf3c5de408c73a4ede7/charset_normalizer-3.5.1-cp37-abi3-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b39b69b347e5e47a3b5b8cfc005c68c1ba347474e3960236c4944a8ecd174962", size = 250166, upload-time = "2026-08-15T08:20:00.612Z" },
{ url = "https://files.pythonhosted.org/packages/75/9c/019fbb9f4834491a160951349b1a3714439376f66e5f7cf18b4f18f0c7aa/charset_normalizer-3.5.1-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:a2028475ba855475b8b4d3cfeb4994269c967aea8b9892dfba907f4263a863a3", size = 241821, upload-time = "2026-08-15T08:20:02.321Z" },
{ url = "https://files.pythonhosted.org/packages/2b/b8/11d4840bfc99330cc7fbcc2681ee5a044553a6e77655508d8f9b2bff7b34/charset_normalizer-3.5.1-cp37-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:36047af20e17097c3bb9476c2b7655f2f7aa51322c0ba58c07695bedf755a950", size = 232529, upload-time = "2026-08-15T08:20:04.008Z" },
{ url = "https://files.pythonhosted.org/packages/18/96/2b3a21492d9f65171ac75d872f5018260013d00bfa0ff70ec9f179148cbd/charset_normalizer-3.5.1-cp37-abi3-musllinux_1_2_ppc64le.whl", hash = "sha256:4c4fb141a727957c93edfe5c32a26ceb6b5f6461d67146e2d39f51e16170bea8", size = 260348, upload-time = "2026-08-15T08:20:05.877Z" },
{ url = "https://files.pythonhosted.org/packages/d6/aa/a69a2028e8bd052476c245460ab19d7de595de084dd968f2d75cd50c3e25/charset_normalizer-3.5.1-cp37-abi3-musllinux_1_2_riscv64.whl", hash = "sha256:2f293479cce755c75f1697e87c409b7ae4c555c7dfecb6e988ad13abba943031", size = 247234, upload-time = "2026-08-15T08:20:07.487Z" },
{ url = "https://files.pythonhosted.org/packages/35/8a/3d130aeabcaf3d2466af76b7b141c08d9e89c9016ab4b7cdd0f7dc2d1c62/charset_normalizer-3.5.1-cp37-abi3-musllinux_1_2_s390x.whl", hash = "sha256:3588e376b3ea2eea84976f67273d679f229e24c66dce7b82ae45aef04ff6e072", size = 256917, upload-time = "2026-08-15T08:20:09.142Z" },
{ url = "https://files.pythonhosted.org/packages/80/c2/a7379b840292d0c1ab9fbd17d1f3967aa81794dc95bc74be8999d7fedcf7/charset_normalizer-3.5.1-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:e199fb99720074809a7720f1c0b4d919eea8b87e88713e0f8f602f7bef543d9d", size = 254846, upload-time = "2026-08-15T08:20:10.727Z" },
{ url = "https://files.pythonhosted.org/packages/01/65/d43b714731bb2f40d4053dfa00ecfc1c5a301f8e3316c5db3a09af59fe94/charset_normalizer-3.5.1-cp37-abi3-win32.whl", hash = "sha256:dd732602a7009217f658d5863d12d79d373a4de0eebc111094bcdd3bb8e0a6cc", size = 174216, upload-time = "2026-08-15T08:20:12.334Z" },
{ url = "https://files.pythonhosted.org/packages/35/4f/b911ed898b26a09789eba9c9200c999aff6c61b4bafaf4838e56d1a1e1a3/charset_normalizer-3.5.1-cp37-abi3-win_amd64.whl", hash = "sha256:70055ff39b97c99e7ae40ea3e393fb62aa2e44dbd9b29f8d14f42fb0025c3959", size = 199764, upload-time = "2026-08-15T08:20:13.908Z" },
{ url = "https://files.pythonhosted.org/packages/f0/a7/920baf467bfd9bf689f3b318340f37aee4572a71f162bd8db51da55ba4fa/charset_normalizer-3.5.1-cp37-abi3-win_arm64.whl", hash = "sha256:87e4f41d375c0b9be2fb5251aee4b8a689169e134535aed81bf085c3b647451e", size = 287318, upload-time = "2026-08-15T08:20:15.551Z" },
{ url = "https://files.pythonhosted.org/packages/cc/61/d01fc49b8dea277640b55a9e15960dbca9fdc8c9fde18e572d39c59f4019/charset_normalizer-3.5.1-py3-none-any.whl", hash = "sha256:6df0ec430f9a831772c23ca5a224cba36517a58a84bb32c32bb59a9fa67c47f6", size = 68658, upload-time = "2026-08-15T08:20:43.306Z" },
]
[[package]]
name = "future"
version = "1.0.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/a7/b2/4140c69c6a66432916b26158687e821ba631a4c9273c474343badf84d3ba/future-1.0.0.tar.gz", hash = "sha256:bd2968309307861edae1458a4f8a4f3598c03be43b97521076aebf5d94c07b05", size = 1228490, upload-time = "2024-02-21T11:52:38.461Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/da/71/ae30dadffc90b9006d77af76b393cb9dfbfc9629f339fc1574a1c52e6806/future-1.0.0-py3-none-any.whl", hash = "sha256:929292d34f5872e70396626ef385ec22355a1fae8ad29e1a734c3e43f9fbc216", size = 491326, upload-time = "2024-02-21T11:52:35.956Z" },
]
[[package]]
name = "idna"
version = "3.20"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/f5/08/8eea9d4b8302028f3abb2c0813953f7aec26d33b7a8960ed760e65ff29fa/idna-3.20.tar.gz", hash = "sha256:a7db850025b95ded1eae8a46181a1a6c56c92c96f0e2b005d9ff8dc0210cab44", size = 216463, upload-time = "2026-09-17T14:11:04.752Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/58/a2/bb081bab032533a855d44de1d56f8e8426114ff1ba5d1f07a438a0a654f8/idna-3.20-py3-none-any.whl", hash = "sha256:ab7ae7122974553370f0bdb919e1a960b2cd1bc1ef0276416d896db81c14582c", size = 69583, upload-time = "2026-09-17T14:11:03.168Z" },
]
[[package]]
name = "oauthlib"
version = "3.3.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/0b/5f/19930f824ffeb0ad4372da4812c50edbd1434f678c90c2733e1188edfc63/oauthlib-3.3.1.tar.gz", hash = "sha256:0f0f8aa759826a193cf66c12ea1af1637f87b9b4622d46e866952bb022e538c9", size = 185918, upload-time = "2025-06-19T22:48:08.269Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/be/9c/92789c596b8df838baa98fa71844d84283302f7604ed565dafe5a6b5041a/oauthlib-3.3.1-py3-none-any.whl", hash = "sha256:88119c938d2b8fb88561af5f6ee0eec8cc8d552b7bb1f712743136eb7523b7a1", size = 160065, upload-time = "2025-06-19T22:48:06.508Z" },
]
[[package]]
name = "polars"
version = "1.44.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "polars-runtime-32" },
]
sdist = { url = "https://files.pythonhosted.org/packages/26/73/258a1fe17bb2744a507199566ed712663144fdd0811b615b59a47dfa38d2/polars-1.44.1.tar.gz", hash = "sha256:ef3c89e9ebbbe8eb343c06873f1945683f8b6f97a1bdf001c60551c6c5e3cda1", size = 765660, upload-time = "2026-08-26T07:09:12.704Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/3a/f1/59154659081930fbde291ea6225607956b500a71b0ce45d88217b7d32da2/polars-1.44.1-py3-none-any.whl", hash = "sha256:1fa62fc1c88fba77a68b28291b5aabdd69e5f38b34e59721a064ae3169b59bb5", size = 865208, upload-time = "2026-08-26T07:07:44.646Z" },
]
[[package]]
name = "polars-runtime-32"
version = "1.44.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/fd/b2/2a76415d047a45df05489f2334c91ff120a274cf655d4ca030c7f54a8743/polars_runtime_32-1.44.1.tar.gz", hash = "sha256:abd10a54ed1caff42228610fcba0f93251f9870bd7cffb0c78bc26f5e0718ce4", size = 3171156, upload-time = "2026-08-26T07:09:14.243Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/f1/93/ef9344dcec16757cf21027dc907ef989197e50742cfe4407f2e87edb0a7f/polars_runtime_32-1.44.1-cp310-abi3-macosx_10_12_x86_64.whl", hash = "sha256:1dfccb2b52aa50468a7d28e3e61c8338a13fb5bffc8646e388a649f5bdc6b463", size = 53962370, upload-time = "2026-08-26T07:07:47.21Z" },
{ url = "https://files.pythonhosted.org/packages/10/da/38b32b7901af33f1fee2172ceaa39e9159825657920064298917392d78fa/polars_runtime_32-1.44.1-cp310-abi3-macosx_11_0_arm64.whl", hash = "sha256:0580807dc3eed258f0db70bb65d905dd43f0135392119ec25308033ae24258fb", size = 48620468, upload-time = "2026-08-26T07:07:50.436Z" },
{ url = "https://files.pythonhosted.org/packages/49/51/185af877d1d2236671493cf72bd3327a6046240eeea69c0696d1af2a5acb/polars_runtime_32-1.44.1-cp310-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0627f9aa82cb869725235e5188f698862fd9ada0c8c1cf65c3dc5a49a4a0ec26", size = 52455947, upload-time = "2026-08-26T07:07:53.776Z" },
{ url = "https://files.pythonhosted.org/packages/fc/0a/0858f60cb5a6f8f73ec4cdd73eccd9f748d66bfc23304c5c23fa3468094a/polars_runtime_32-1.44.1-cp310-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:eea4283be8e60822d890dbda20588fe59b4172b508bd5ebf3471e531ca9f50d7", size = 58561262, upload-time = "2026-08-26T07:07:57.508Z" },
{ url = "https://files.pythonhosted.org/packages/73/08/de4774b5612d7c8739f89ac01b601486b4f057b1da35a5b876bf9276fd95/polars_runtime_32-1.44.1-cp310-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:04e2c0f46e7a9906fffb1897f18f23b079b74f83c56b50060bace9e7b9b49b1a", size = 52636064, upload-time = "2026-08-26T07:08:06.337Z" },
{ url = "https://files.pythonhosted.org/packages/98/ac/769c598dd106e2a6647798da3ed25ddeee67f2d12c04f5da316cb3da6360/polars_runtime_32-1.44.1-cp310-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:0956f0cae632d8fad3a04b4315bf2bb69b56d10c83c79a75c2c4c5a13b9ce5cc", size = 56447612, upload-time = "2026-08-26T07:08:12.36Z" },
{ url = "https://files.pythonhosted.org/packages/b2/ee/98408296e15388020b6183323fdbe78ccab4f72c20d8e0d7092c062d3ad2/polars_runtime_32-1.44.1-cp310-abi3-win_amd64.whl", hash = "sha256:159334184e6fbb074c9f4692221ea19970a5e2bed2a479f9d7bdb00b7f3eedb9", size = 53702970, upload-time = "2026-08-26T07:08:15.398Z" },
{ url = "https://files.pythonhosted.org/packages/e9/9d/8b17e075aac73c881a50b6c1f690d20df46db2f3bcabc99f600ecdee1290/polars_runtime_32-1.44.1-cp310-abi3-win_arm64.whl", hash = "sha256:3ba28d638d0513e0b4afbcdab5c0059a85021e5f81d62b5f793e7e23badb2cf7", size = 47281050, upload-time = "2026-08-26T07:08:18.43Z" },
]
[[package]]
name = "pytumblr"
version = "0.1.2"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "future" },
{ name = "requests-oauthlib" },
]
sdist = { url = "https://files.pythonhosted.org/packages/e3/7c/f4002420a03bc367ee6ac58943b4968ae2cdfd690f1890e0c8cd75e53fbb/PyTumblr-0.1.2.tar.gz", hash = "sha256:eef2653110f84df74a9d8628a4edf30131004de07e93484bc8cc95e44072036e", size = 16655, upload-time = "2023-01-18T16:18:50.992Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/82/fb/4623b76a15f198aab85aa91a4bbc29e4cd1d52ed9dbd44f15e6af7dde4ce/PyTumblr-0.1.2-py2.py3-none-any.whl", hash = "sha256:4435bf4ddbd3543a980a2920788914e8e41a9b91b255eabfd22dd7a2f4eedfec", size = 19083, upload-time = "2023-01-18T16:18:48.862Z" },
]
[[package]]
name = "requests"
version = "2.34.2"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "certifi" },
{ name = "charset-normalizer" },
{ name = "idna" },
{ name = "urllib3" },
]
sdist = { url = "https://files.pythonhosted.org/packages/ac/c3/e2a2b89f2d3e2179abd6d00ebd70bff6273f37fb3e0cc209f48b39d00cbf/requests-2.34.2.tar.gz", hash = "sha256:f288924cae4e29463698d6d60bc6a4da69c89185ad1e0bcc4104f584e960b9ed", size = 142856, upload-time = "2026-05-14T19:25:27.735Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/a0/f4/c67b0b3f1b9245e8d266f0f112c500d50e5b4e83cb6f3b71b6528104182a/requests-2.34.2-py3-none-any.whl", hash = "sha256:2a0d60c172f83ac6ab31e4554906c0f3b3588d37b5cb939b1c061f4907e278e0", size = 73075, upload-time = "2026-05-14T19:25:26.443Z" },
]
[[package]]
name = "requests-oauthlib"
version = "2.0.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "oauthlib" },
{ name = "requests" },
]
sdist = { url = "https://files.pythonhosted.org/packages/42/f2/05f29bc3913aea15eb670be136045bf5c5bbf4b99ecb839da9b422bb2c85/requests-oauthlib-2.0.0.tar.gz", hash = "sha256:b3dffaebd884d8cd778494369603a9e7b58d29111bf6b41bdc2dcd87203af4e9", size = 55650, upload-time = "2024-03-22T20:32:29.939Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/3b/5d/63d4ae3b9daea098d5d6f5da83984853c1bbacd5dc826764b249fe119d24/requests_oauthlib-2.0.0-py2.py3-none-any.whl", hash = "sha256:7dd8a5c40426b779b0868c404bdef9768deccf22749cde15852df527e6269b36", size = 24179, upload-time = "2024-03-22T20:32:28.055Z" },
]
[[package]]
name = "tumblr-stats"
version = "1.0.0"
source = { virtual = "." }
dependencies = [
{ name = "polars" },
{ name = "pytumblr" },
]
[package.metadata]
requires-dist = [
{ name = "polars", specifier = "==1.44.1" },
{ name = "pytumblr", specifier = "==0.1.2" },
]
[[package]]
name = "urllib3"
version = "2.8.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/e3/05/b17359e1cefb4f909b5e40b1b90a496d987258916dbbf88e842c729f510e/urllib3-2.8.0.tar.gz", hash = "sha256:63bf2ead4c879426ebf22ef2a781eeb4aa3b4ae798a0435506f8687fd5bb9b63", size = 458972, upload-time = "2026-09-15T19:29:36.253Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/92/9d/c4e665119135114480843e7ab388fa94d8480650450e6f8e26b70d323a4c/urllib3-2.8.0-py3-none-any.whl", hash = "sha256:0cf3cae568d36aa9576b28dfb35f11328f1cb974ca7647d9475ebb86c75ac6e3", size = 135717, upload-time = "2026-09-15T19:29:34.577Z" },
]