diff --git a/README.md b/README.md index c99f911..c3e0cb9 100644 --- a/README.md +++ b/README.md @@ -1,20 +1,31 @@ # tumblr-stats ## 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 - -positional arguments: - OPERATION operation used to calculate stats +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 options: -h, --help show this help message and exit -b, --blog BLOG blog name for which to calculate stats -t, --tags TAGS [TAGS ...] - tag(s) to focus on in status (if applicable) - -i, --input INPUT Don't make API calls, just use a JSON input file - --after AFTER only gather posts after YYYY-MM-DD + tag(s) on which to filter (only affects posts) + -s, --suppress {none,posts,queued,drafts,activity} [{none,posts,queued,drafts,activity} ...] + 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 ``` \ No newline at end of file diff --git a/fetcher.py b/fetcher.py new file mode 100644 index 0000000..d0055d0 --- /dev/null +++ b/fetcher.py @@ -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 \ No newline at end of file diff --git a/model.py b/model.py new file mode 100644 index 0000000..1054b0f --- /dev/null +++ b/model.py @@ -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) diff --git a/pyproject.toml b/pyproject.toml index ca40761..7b1acbc 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,11 +1,14 @@ [project] name = "tumblr_stats" -version = "0.1.0" -description = "Calculate Tumblr stats using pytumblr." +version = "1.0.0" +description = "Calculate Tumblr stats using pytumblr and Polars." authors = [ {name = "Amber", email = "amber@ailuridae.io"}, ] -dependencies = ["pytumblr==0.1.2"] +dependencies = [ + "pytumblr==0.1.2", + "polars==1.44.1", +] requires-python = "==3.14.*" readme = "README.md" diff --git a/tumblr_stats.py b/tumblr_stats.py index 2ac5732..dec205c 100644 --- a/tumblr_stats.py +++ b/tumblr_stats.py @@ -1,46 +1,46 @@ - import argparse -import csv from dataclasses import asdict -from datetime import datetime +import dataclasses +from datetime import datetime as dt import json import os from pathlib import Path import sys -from typing import Any, Callable, Dict, List, Tuple +from typing import Any, Dict, List import pytumblr - -from build_deep_stats_model import BuildDeepStatsModel -from build_draft_stats_model import BuildDraftStatsModel -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 +import polars as pl +import fetcher +from model import StatsModel def get_args() -> Dict[str, Any]: """Pull arguments from command line, turn them into a dictionary of """ parser: argparse.ArgumentParser = argparse.ArgumentParser( 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', 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, help='blog name for which to calculate stats') parser.add_argument('-t', '--tags', type=str, nargs='+', - help='tag(s) to focus on in status (if applicable)') - parser.add_argument('-i', '--input', type=str, - help='Don\'t make API calls, just use a JSON input file') - # TODO: Make 'before' work, but it depends on https://github.com/tumblr/pytumblr/issues/174. - # parser.add_argument('--before', type=lambda s: datetime.strptime(s, '%Y-%m-%d'), - # help='only gather posts before YYYY-MM-DD') - parser.add_argument('--after', type=lambda s: datetime.strptime(s, '%Y-%m-%d'), - help='only gather posts after YYYY-MM-DD') + help='tag(s) on which to filter (only affects posts)') + parser.add_argument('-s', '--suppress', type=str, nargs='+', + choices=['none', 'posts', 'queued', 'drafts', 'activity'], + default='activity', + help='objects to skip when aggregating data; will render some stats empty') + parser.add_argument('--until', type=lambda s: dt.strptime(s, '%Y-%m-%d'), + help='only gather posts and activity until 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()) @@ -68,205 +68,76 @@ def init_client() -> pytumblr.TumblrRestClient: ) -def filter_posts_for_after(post_list: List[Dict[str, Any]], - after: datetime) -> List[Dict[str, Any]]: - # Quick short circuit check. - if not post_list or not after: - return [] +def json_encoder(obj): + if dataclasses.is_dataclass(obj): + return dataclasses.asdict(obj) # type: ignore + elif isinstance(obj, pl.DataFrame): + 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, - args: Dict[str, Any]) -> Tuple[Dict[str, Any], Dict[str, Any]]: - og_post_map: Dict[str, Any] = {} - un_og_post_map: Dict[str, Any] = {} - blog_name: str = args['blog'] +def file_is_used(obj_arg: str, args: Dict[str, Any]) -> bool: + return obj_arg in args and args[obj_arg] - # 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" - is_draft_stats: bool = 'build_draft_stats' in args['operation'] - 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 load_from_file(path: str) -> pl.DataFrame: + return pl.read_json(source=Path(path), infer_schema_length=None) def main() -> None: args: Dict[str, Any] = get_args() + suppress = args['suppress'] client: pytumblr.TumblrRestClient = init_client() - operation: List[str] = args['operation'] - # Quick bail for bad use of build_queue_stats and build_draft_stats. - if (set(operation) & set(['build_queue_stats', 'build_draft_stats'])) and len(operation) > 1: - print(f"You can't mix operations {operation} together. Sorry.") - sys.exit(1) - pass + # Read queued posts from a file, or fetch them via API and write queued post JSON. + queued_posts: pl.DataFrame = pl.DataFrame() + if not suppress or 'queued' not in suppress: + if file_is_used('queued_file', args): + queued_posts = load_from_file(args['queued_file']) + else: + queued_posts = fetcher.fetch_queued_posts(client, args) + queued_posts.write_json("queued_posts.json") - # 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: - # Get the og_post_map (original posts) and un_og_post_map (not original posts). - og_post_map, un_og_post_map = build_post_maps(args=args, client=client) + # Read drafts from a file, or fetch them via API and write draft JSON. + drafts: pl.DataFrame = pl.DataFrame() + if not suppress or 'drafts' not in suppress: + if file_is_used('drafts_file', args): + drafts = load_from_file(args['drafts_file']) + else: + drafts = fetcher.fetch_drafts(client, args) + drafts.write_json("drafts.json") - # Pick a stats model, which will determine output. - stats_model: StatsModel - match args: - case {'operation': ['build_queue_stats']}: - stats_model = BuildQueueStatsModel(blog_name=args['blog'], - original_post_map=og_post_map, - unoriginal_post_map=un_og_post_map) - case {'operation': ['build_draft_stats']}: - 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) + # 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") - # Write the selected model as JSON output. + # 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: - 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 diff --git a/uv.lock b/uv.lock new file mode 100644 index 0000000..d2d5ab5 --- /dev/null +++ b/uv.lock @@ -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" }, +]