Compare commits
23
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a4b09b2f57
|
||
|
|
1d4b983a17
|
||
|
|
cf84f6c21c
|
||
|
|
c0773085bf
|
||
|
|
dede6441a9
|
||
|
|
adc5767236 | ||
|
|
1ad828a2b9
|
||
|
|
33cbc5c6ed
|
||
|
|
5af82e5753 | ||
|
|
d155a4c907 | ||
|
|
2b60d6af10 | ||
|
|
727d483e62 | ||
|
|
c97a169637 | ||
|
|
975f0df22b | ||
|
|
91d67896d1 | ||
|
|
bd0ebd38e9 | ||
|
|
9e7ff808a5 | ||
|
|
d999a8175c | ||
|
|
845f6a28cc | ||
|
|
7b80b52998 | ||
|
|
173c0594a8 | ||
|
|
201f8eb2c9 | ||
|
|
3504641643 |
@@ -8,3 +8,5 @@ __pycache__/*
|
||||
*/__pycache__/*
|
||||
*.pbix
|
||||
/logs/*
|
||||
.vscode/*
|
||||
*.coverage
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
3.13
|
||||
@@ -3,6 +3,7 @@ import json
|
||||
import logging
|
||||
from typing import override
|
||||
|
||||
|
||||
class custom_json_logger(logging.Formatter):
|
||||
def __init__(
|
||||
self,
|
||||
@@ -21,7 +22,7 @@ class custom_json_logger(logging.Formatter):
|
||||
always_fields = {
|
||||
"message" : record.getMessage(),
|
||||
"timestamp" : dt.datetime.fromtimestamp(
|
||||
record.created, tz=dt.timezone.utc
|
||||
record.created, tz=dt.UTC
|
||||
).isoformat(),
|
||||
}
|
||||
if record.exc_info is not None:
|
||||
|
||||
@@ -11,3 +11,6 @@ CONFLICT = 9
|
||||
MOVE_FILE_ERROR = 10
|
||||
DUPLICATE_RESOLUTION_ERROR = 11
|
||||
UNIQUE_ID_NOT_FOUND = 12
|
||||
NO_DATA_PRODUCED = 13
|
||||
MISSING_DATA_FILES = 14
|
||||
BAD_JOIN = 15
|
||||
|
||||
+11
-10
@@ -1,17 +1,18 @@
|
||||
import duckdb
|
||||
import os
|
||||
import polars as pl
|
||||
|
||||
df = pl.read_parquet('data/warehouse/transactions.parquet')
|
||||
print("Data loaded from Parquet file:")
|
||||
print(df)
|
||||
|
||||
relevant_data = df.sql('''
|
||||
db = duckdb.connect('/mnt/bulk/data/ynab_data.duckdb')
|
||||
|
||||
# SELECT * FROM gold.transactions
|
||||
relevant_data = db.sql('''
|
||||
SELECT
|
||||
date,
|
||||
transaction_date,
|
||||
sum(transaction_amount) as total
|
||||
FROM self
|
||||
GROUP BY date
|
||||
ORDER BY date DESC
|
||||
'''
|
||||
)
|
||||
FROM gold.transactions
|
||||
GROUP BY transaction_date
|
||||
ORDER BY transaction_date DESC
|
||||
''')
|
||||
print("Data after SQL query:")
|
||||
print(relevant_data)
|
||||
+17
-7
@@ -34,23 +34,29 @@ erDiagram
|
||||
}
|
||||
|
||||
DATES {
|
||||
int date_id
|
||||
string date
|
||||
string date_id
|
||||
date date
|
||||
int year
|
||||
int month
|
||||
int day
|
||||
boolean is_weekday
|
||||
int weekday
|
||||
}
|
||||
|
||||
TRANSACTIONS {
|
||||
int transaction_id
|
||||
str transaction_id
|
||||
int account_id
|
||||
int category_id
|
||||
int payee_id
|
||||
int date_id
|
||||
int transaction_date
|
||||
decimal amount
|
||||
boolean cleared
|
||||
boolean approved
|
||||
boolean deleted
|
||||
string memo
|
||||
string flag_color
|
||||
str transfer_account_id
|
||||
|
||||
}
|
||||
|
||||
SCHEDULED_TRANSACTIONS {
|
||||
@@ -58,10 +64,14 @@ erDiagram
|
||||
int account_id
|
||||
int category_id
|
||||
int payee_id
|
||||
int date_id
|
||||
str date_first
|
||||
str date_next
|
||||
decimal amount
|
||||
string frequency
|
||||
boolean deleted
|
||||
text memo
|
||||
string flag_color
|
||||
str transfer_account_id
|
||||
}
|
||||
|
||||
TRANSACTIONS ||--o{ ACCOUNTS : "belongs to"
|
||||
@@ -71,6 +81,6 @@ erDiagram
|
||||
SCHEDULED_TRANSACTIONS ||--o{ ACCOUNTS : "belongs to"
|
||||
SCHEDULED_TRANSACTIONS ||--o{ CATEGORIES : "belongs to"
|
||||
SCHEDULED_TRANSACTIONS ||--o{ PAYEES : "belongs to"
|
||||
SCHEDULED_TRANSACTIONS ||--o{ DATES : "scheduled on"
|
||||
SCHEDULED_TRANSACTIONS ||--o{ DATES : "First Scheduled"
|
||||
SCHEDULED_TRANSACTIONS ||--o{ DATES : "Next Scheduled"
|
||||
```
|
||||
|
||||
|
||||
+1
-1
@@ -25,7 +25,7 @@ For the `BUDGET_ID`, you can get it from the URL of your budget page on the YNAB
|
||||
### Clone the repository
|
||||
|
||||
```bash
|
||||
git clone #link tbc
|
||||
git clone https://github.com/Jake-Pullen/data_pipeline_for_YNAB.git
|
||||
```
|
||||
|
||||
### Install dependencies
|
||||
|
||||
@@ -28,3 +28,7 @@ The Data Warehouse is the data after it has been aggregated and transformed. It
|
||||
## Processed Archive
|
||||
|
||||
The Processed Archive is the data after it has been processed and stored in the base tables. It is the raw json files in the `data/processed/` directory with a folder for each entity and file for each load that has been processed.
|
||||
|
||||
## Visualisation datasets
|
||||
|
||||
When preparing the data for visualisation, we create dataframes in memory that are used to create the visualisations. These are not stored on disk.
|
||||
|
||||
@@ -1,17 +1,17 @@
|
||||
import os
|
||||
import dotenv
|
||||
import logging
|
||||
import yaml
|
||||
import sys
|
||||
import atexit
|
||||
import logging
|
||||
import logging.config
|
||||
import logging.handlers
|
||||
import os
|
||||
import sys
|
||||
|
||||
import dotenv
|
||||
import yaml
|
||||
|
||||
import config.exit_codes as ec
|
||||
from pipeline.ingest import Ingest
|
||||
from pipeline.raw_to_base import RawToBase
|
||||
from pipeline.dimensions import DimAccounts, DimCategories, DimPayees, DimDate
|
||||
from pipeline.facts import FactTransactions, FactScheduledTransactions
|
||||
from pipeline.pipeline_main import pipeline_main
|
||||
|
||||
logger = logging.getLogger("data_pipeline_for_ynab")
|
||||
|
||||
def set_up_logging():
|
||||
try:
|
||||
@@ -27,55 +27,48 @@ def set_up_logging():
|
||||
queue_handler.listener.start()
|
||||
atexit.register(queue_handler.listener.stop)
|
||||
|
||||
logger = logging.getLogger("data_pipeline_for_ynab")
|
||||
def load_config(logger):
|
||||
try:
|
||||
with open('config/config.yaml', 'r') as file:
|
||||
config = yaml.safe_load(file)
|
||||
return config
|
||||
except FileNotFoundError:
|
||||
logger.error('config.yaml file not found')
|
||||
sys.exit(ec.MISSING_CONFIG_FILE)
|
||||
except yaml.YAMLError as e:
|
||||
logger.error(f'Error loading config.yaml: {e}')
|
||||
sys.exit(ec.CORRUPTED_CONFIG_FILE)
|
||||
|
||||
os.makedirs('logs', exist_ok=True)
|
||||
set_up_logging()
|
||||
|
||||
# Load environment variables
|
||||
dotenv.load_dotenv()
|
||||
|
||||
API_TOKEN = os.getenv('API_TOKEN')
|
||||
BUDGET_ID = os.getenv('BUDGET_ID')
|
||||
|
||||
def main():
|
||||
if not API_TOKEN or not BUDGET_ID:
|
||||
logging.error('API_TOKEN or BUDGET_ID is not set in .env file')
|
||||
sys.exit(ec.MISSING_ENV_VARS)
|
||||
|
||||
try:
|
||||
with open('config/config.yaml', 'r') as file:
|
||||
config = yaml.safe_load(file)
|
||||
except FileNotFoundError:
|
||||
logging.error('config.yaml file not found')
|
||||
sys.exit(ec.MISSING_CONFIG_FILE)
|
||||
except yaml.YAMLError as e:
|
||||
logging.error(f'Error loading config.yaml: {e}')
|
||||
sys.exit(ec.CORRUPTED_CONFIG_FILE)
|
||||
|
||||
config['API_TOKEN'] = API_TOKEN
|
||||
config['BUDGET_ID'] = BUDGET_ID
|
||||
|
||||
logging.info('Starting data pipeline')
|
||||
|
||||
Ingest(config)
|
||||
RawToBase(config)
|
||||
DimAccounts(config)
|
||||
DimCategories(config)
|
||||
DimPayees(config)
|
||||
DimDate(config)
|
||||
FactTransactions(config)
|
||||
FactScheduledTransactions(config)
|
||||
|
||||
logging.info('Data pipeline completed successfully')
|
||||
sys.exit(ec.SUCCESS)
|
||||
if not API_TOKEN or not BUDGET_ID:
|
||||
logger.error('API_TOKEN or BUDGET_ID is not set in .env file')
|
||||
sys.exit(ec.MISSING_ENV_VARS)
|
||||
|
||||
if __name__ == '__main__':
|
||||
config = load_config(logger)
|
||||
config['API_TOKEN'] = API_TOKEN
|
||||
config['BUDGET_ID'] = BUDGET_ID
|
||||
try:
|
||||
main()
|
||||
pipeline_main(config, logger)
|
||||
|
||||
data_exists = os.path.exists('data/processed') and os.listdir('data/processed')
|
||||
if data_exists:
|
||||
logger.info('Processing Successful')
|
||||
sys.exit(ec.SUCCESS)
|
||||
else:
|
||||
logger.error('Data pipeline did not produce any data. Dash app will not run.')
|
||||
sys.exit(ec.NO_DATA_PRODUCED)
|
||||
except SystemExit as e:
|
||||
exit_code = e.code
|
||||
if exit_code == ec.SUCCESS:
|
||||
logging.info('Program exited successfully')
|
||||
logger.info('Program exited successfully')
|
||||
else:
|
||||
logging.error(f'Program exited with code {exit_code}')
|
||||
logger.error(f'Program exited with code {exit_code}')
|
||||
raise
|
||||
|
||||
+113
-86
@@ -1,88 +1,97 @@
|
||||
import polars as pl
|
||||
import logging
|
||||
import os
|
||||
from datetime import date
|
||||
|
||||
import polars as pl
|
||||
|
||||
|
||||
class Dimensions:
|
||||
def __init__(self, config):
|
||||
def __init__(self, config,logger):
|
||||
self.config = config
|
||||
self.base_file_path = self.config['base_data_path']
|
||||
os.makedirs(self.config['warehouse_data_path'], exist_ok=True)
|
||||
self.logger = logger
|
||||
|
||||
def get_full_file_path(self, file_name):
|
||||
return f"{self.base_file_path}/{file_name}"
|
||||
|
||||
|
||||
class DimAccounts(Dimensions):
|
||||
def __init__(self, config):
|
||||
super().__init__(config)
|
||||
def __init__(self, config,logger):
|
||||
super().__init__(config,logger)
|
||||
self.file_path = self.get_full_file_path('accounts.parquet')
|
||||
self.transform()
|
||||
|
||||
def transform(self):
|
||||
# Read the parquet file into a polars DataFrame
|
||||
try:
|
||||
accounts_df = pl.read_parquet(self.file_path)
|
||||
source_accounts = pl.read_parquet(self.file_path)
|
||||
except Exception as e:
|
||||
logging.error(f"Failed to read the base accounts parquet file: {e}")
|
||||
self.logger.error(f"Failed to read the base accounts parquet file: {e}")
|
||||
return
|
||||
|
||||
# Transform the DataFrame
|
||||
logging.info("Transforming the accounts DataFrame")
|
||||
self.logger.info("Transforming the accounts DataFrame")
|
||||
try:
|
||||
accounts_df = (
|
||||
accounts_df
|
||||
.with_columns([
|
||||
pl.col("id").alias("account_id"),
|
||||
pl.col("name").alias("account_name"),
|
||||
pl.col("type").alias("account_type"),
|
||||
pl.col("on_budget").alias("on_budget"),
|
||||
pl.col("closed").alias("closed"),
|
||||
pl.col("note").alias("note"),
|
||||
pl.col("balance").alias("balance"),
|
||||
pl.col("cleared_balance").alias("cleared_balance"),
|
||||
pl.col("uncleared_balance").alias("uncleared_balance"),
|
||||
pl.col("deleted").alias("deleted"),
|
||||
])
|
||||
.with_columns([
|
||||
pl.col("note").fill_null("unknown"),
|
||||
(pl.col("balance") / 100).alias("balance"),
|
||||
(pl.col("cleared_balance") / 100).alias("cleared_balance"),
|
||||
(pl.col("uncleared_balance") / 100).alias("uncleared_balance"),
|
||||
])
|
||||
.drop([
|
||||
"transfer_payee_id", "direct_import_linked", "direct_import_in_error",
|
||||
"last_reconciled_at", "debt_original_balance", "debt_interest_rates",
|
||||
"debt_minimum_payments", "debt_escrow_amounts", "ingestion_date"
|
||||
base_accounts = (
|
||||
source_accounts.select([
|
||||
"id",
|
||||
"name",
|
||||
"type",
|
||||
"on_budget",
|
||||
"closed",
|
||||
"note",
|
||||
"balance",
|
||||
"cleared_balance",
|
||||
"uncleared_balance",
|
||||
"deleted"
|
||||
])
|
||||
)
|
||||
except Exception as e:
|
||||
logging.error(f"Failed to transform the accounts DataFrame: {e}")
|
||||
self.logger.error(f"Failed to select columns from the categories DataFrame: {e}")
|
||||
return
|
||||
# Write the DataFrame to a new parquet file
|
||||
logging.info("Writing the transformed accounts DataFrame to parquet file")
|
||||
|
||||
try:
|
||||
accounts_df.write_parquet(self.config['warehouse_data_path'] + '/accounts.parquet')
|
||||
add_accounts_prefix = base_accounts.with_columns([
|
||||
pl.col("id").alias("account_id"),
|
||||
pl.col("name").alias("account_name"),
|
||||
pl.col("type").alias("account_type")
|
||||
])
|
||||
fill_accounts_null_values = add_accounts_prefix.with_columns([
|
||||
pl.col('note').fill_null('none')
|
||||
])
|
||||
fix_accounts_values = fill_accounts_null_values.with_columns([
|
||||
(pl.col("balance") / 1000).alias("balance"),
|
||||
(pl.col("cleared_balance") / 1000).alias("cleared_balance"),
|
||||
(pl.col("uncleared_balance") / 1000).alias("uncleared_balance"),
|
||||
])
|
||||
drop_accounts_columns = fix_accounts_values.drop([
|
||||
"id", "name", "type"
|
||||
])
|
||||
except Exception as e:
|
||||
logging.error(f"Failed to write the transformed accounts DataFrame to parquet file: {e}")
|
||||
self.logger.error(f"Failed to transform the accounts DataFrame: {e}")
|
||||
return
|
||||
|
||||
self.logger.info("Writing the transformed accounts DataFrame to parquet file")
|
||||
try:
|
||||
drop_accounts_columns.write_parquet(self.config['warehouse_data_path'] + '/accounts.parquet')
|
||||
except Exception as e:
|
||||
self.logger.error(f"Failed to write the transformed accounts DataFrame to parquet file: {e}")
|
||||
return
|
||||
|
||||
class DimCategories(Dimensions):
|
||||
def __init__(self, config):
|
||||
super().__init__(config)
|
||||
def __init__(self, config,logger):
|
||||
super().__init__(config,logger)
|
||||
self.file_path = self.get_full_file_path('categories.parquet')
|
||||
self.transform()
|
||||
|
||||
def transform(self):
|
||||
# Read the parquet file into a polars DataFrame
|
||||
try:
|
||||
categories_df = pl.read_parquet(self.file_path)
|
||||
source_categories = pl.read_parquet(self.file_path)
|
||||
except Exception as e:
|
||||
logging.error(f"Failed to read the base categories parquet file: {e}")
|
||||
self.logger.error(f"Failed to read the base categories parquet file: {e}")
|
||||
return
|
||||
logging.info("Transforming the categories DataFrame")
|
||||
self.logger.info("Transforming the categories DataFrame")
|
||||
try:
|
||||
categories_df = categories_df.select([
|
||||
base_categories = source_categories.select([
|
||||
'id',
|
||||
'name',
|
||||
'category_group_name',
|
||||
@@ -94,75 +103,82 @@ class DimCategories(Dimensions):
|
||||
'deleted'
|
||||
])
|
||||
except Exception as e:
|
||||
logging.error(f"Failed to select columns from the categories DataFrame: {e}")
|
||||
self.logger.error(f"Failed to select columns from the categories DataFrame: {e}")
|
||||
return
|
||||
|
||||
try:
|
||||
# Rename the columns
|
||||
categories_df = categories_df.with_columns(pl.col('id').alias('category_id'))
|
||||
categories_df = categories_df.with_columns(pl.col('name').alias('category_name'))
|
||||
|
||||
# Fill null values in the note column
|
||||
categories_df = categories_df.with_columns(pl.col('note').fill_null('unknown'))
|
||||
|
||||
# Convert the balance, budgeted, and activity columns to decimal
|
||||
categories_df = categories_df.with_columns(pl.col('balance') / 100)
|
||||
categories_df = categories_df.with_columns(pl.col('budgeted') / 100)
|
||||
categories_df = categories_df.with_columns(pl.col('activity') / 100)
|
||||
add_categories_prefix = base_categories.with_columns([
|
||||
pl.col('id').alias('category_id'),
|
||||
pl.col('name').alias('category_name')
|
||||
])
|
||||
fill_null_category_values = add_categories_prefix.with_columns([
|
||||
pl.col('note').fill_null('none')
|
||||
])
|
||||
fix_categories_values = fill_null_category_values.with_columns([
|
||||
(pl.col('balance') / 1000),
|
||||
(pl.col('budgeted') / 1000),
|
||||
(pl.col('activity') / 1000)
|
||||
])
|
||||
drop_categories_columns = fix_categories_values.drop([
|
||||
'id', 'name'
|
||||
])
|
||||
except Exception as e:
|
||||
logging.error(f"Failed to transform the categories DataFrame: {e}")
|
||||
self.logger.error(f"Failed to transform the categories DataFrame: {e}")
|
||||
return
|
||||
|
||||
# Write the DataFrame to a new parquet file
|
||||
logging.info("Writing the transformed categories DataFrame to parquet file")
|
||||
self.logger.info("Writing the transformed categories DataFrame to parquet file")
|
||||
try:
|
||||
categories_df.write_parquet(self.config['warehouse_data_path'] + '/categories.parquet')
|
||||
drop_categories_columns.write_parquet(self.config['warehouse_data_path'] + '/categories.parquet')
|
||||
except Exception as e:
|
||||
logging.error(f"Failed to write the transformed categories DataFrame to parquet file: {e}")
|
||||
self.logger.error(f"Failed to write the transformed categories DataFrame to parquet file: {e}")
|
||||
return
|
||||
|
||||
class DimPayees(Dimensions):
|
||||
def __init__(self, config):
|
||||
super().__init__(config)
|
||||
def __init__(self, config,logger):
|
||||
super().__init__(config,logger)
|
||||
self.file_path = self.get_full_file_path('payees.parquet')
|
||||
self.transform()
|
||||
|
||||
def transform(self):
|
||||
# Read the parquet file into a polars DataFrame
|
||||
try:
|
||||
payees_df = pl.read_parquet(self.file_path)
|
||||
source_payees = pl.read_parquet(self.file_path)
|
||||
except Exception as e:
|
||||
logging.error(f"Failed to read the base payees parquet file: {e}")
|
||||
self.logger.error(f"Failed to read the base payees parquet file: {e}")
|
||||
return
|
||||
logging.info("Transforming the payees DataFrame")
|
||||
self.logger.info("Transforming the payees DataFrame")
|
||||
try:
|
||||
payees_df = payees_df.select([
|
||||
base_payees = source_payees.select([
|
||||
'id',
|
||||
'name',
|
||||
'deleted'
|
||||
])
|
||||
except Exception as e:
|
||||
logging.error(f"Failed to select columns from the payees DataFrame: {e}")
|
||||
self.logger.error(f"Failed to select columns from the payees DataFrame: {e}")
|
||||
return
|
||||
|
||||
try:
|
||||
# Rename the columns
|
||||
payees_df = payees_df.with_columns(pl.col('id').alias('payee_id'))
|
||||
payees_df = payees_df.with_columns(pl.col('name').alias('payee_name'))
|
||||
add_payees_prefix = base_payees.with_columns([
|
||||
pl.col('id').alias('payee_id'),
|
||||
pl.col('name').alias('payee_name')
|
||||
])
|
||||
drop_payees_columns = add_payees_prefix.drop([
|
||||
'id', 'name'
|
||||
])
|
||||
except Exception as e:
|
||||
logging.error(f"Failed to rename columns in the payees DataFrame: {e}")
|
||||
self.logger.error(f"Failed to rename columns in the payees DataFrame: {e}")
|
||||
return
|
||||
|
||||
# Write the DataFrame to a new parquet file
|
||||
logging.info("Writing the transformed payees DataFrame to parquet file")
|
||||
self.logger.info("Writing the transformed payees DataFrame to parquet file")
|
||||
try:
|
||||
payees_df.write_parquet(self.config['warehouse_data_path'] + '/payees.parquet')
|
||||
drop_payees_columns.write_parquet(self.config['warehouse_data_path'] + '/payees.parquet')
|
||||
except Exception as e:
|
||||
logging.error(f"Failed to write the transformed payees DataFrame to parquet file: {e}")
|
||||
self.logger.error(f"Failed to write the transformed payees DataFrame to parquet file: {e}")
|
||||
return
|
||||
|
||||
class DimDate(Dimensions):
|
||||
def __init__(self, config):
|
||||
super().__init__(config)
|
||||
def __init__(self, config,logger):
|
||||
super().__init__(config,logger)
|
||||
self.transform()
|
||||
|
||||
def transform(self):
|
||||
@@ -170,7 +186,7 @@ class DimDate(Dimensions):
|
||||
try:
|
||||
dates_df = pl.DataFrame({'date':pl.date_range(date(2020, 1, 1), date(2030, 12, 31), "1d", eager=True)})
|
||||
except Exception as e:
|
||||
logging.error(f"Failed to create a DataFrame with dates: {e}")
|
||||
self.logger.error(f"Failed to create a DataFrame with dates: {e}")
|
||||
return
|
||||
# Extract year, month, day, and weekday from the date column
|
||||
try:
|
||||
@@ -181,21 +197,32 @@ class DimDate(Dimensions):
|
||||
pl.col('date').dt.weekday().alias('weekday')
|
||||
])
|
||||
except Exception as e:
|
||||
logging.error(f"Failed to extract year, month, day, and weekday from the date column: {e}")
|
||||
self.logger.error(f"Failed to extract year, month, day, and weekday from the date column: {e}")
|
||||
return
|
||||
try:
|
||||
# Create a new column to indicate if the date is a weekday or weekend
|
||||
dates_df = dates_df.with_columns([
|
||||
(pl.col('weekday') < 5).alias('is_weekday') # True for weekdays (Monday to Friday), False for weekends (Saturday and Sunday)
|
||||
(pl.col('weekday') < 6).alias('is_weekday') # True for weekdays (Monday to Friday), False for weekends (Saturday and Sunday)
|
||||
])
|
||||
except Exception as e:
|
||||
logging.error(f"Failed to create a new column to indicate if the date is a weekday or weekend: {e}")
|
||||
self.logger.error(f"Failed to create a new column to indicate if the date is a weekday or weekend: {e}")
|
||||
return
|
||||
|
||||
# Create a primary key by concatenating year, month, and day with no separators
|
||||
try:
|
||||
dates_df = dates_df.with_columns([
|
||||
(pl.col('year').cast(pl.Utf8) +
|
||||
pl.col('month').cast(pl.Utf8).str.zfill(2) +
|
||||
pl.col('day').cast(pl.Utf8).str.zfill(2)
|
||||
).alias('date_id')
|
||||
])
|
||||
except Exception as e:
|
||||
self.logger.error(f"Failed to create the primary key column: {e}")
|
||||
return
|
||||
# Write the DataFrame to a new parquet file
|
||||
logging.info("Writing the transformed dates DataFrame to parquet file")
|
||||
self.logger.info("Writing the transformed dates DataFrame to parquet file")
|
||||
try:
|
||||
dates_df.write_parquet(self.config['warehouse_data_path'] + '/dates.parquet')
|
||||
except Exception as e:
|
||||
logging.error(f"Failed to write the transformed dates DataFrame to parquet file: {e}")
|
||||
self.logger.error(f"Failed to write the transformed dates DataFrame to parquet file: {e}")
|
||||
return
|
||||
|
||||
|
||||
@@ -0,0 +1,241 @@
|
||||
"""DuckDB-based silver and gold layer for the YNAB data pipeline."""
|
||||
|
||||
import os
|
||||
from datetime import date, timedelta
|
||||
|
||||
import duckdb
|
||||
|
||||
|
||||
class DuckDBLayer:
|
||||
"""Orchestrates silver (external base) and gold (views + materialized) layers via DuckDB."""
|
||||
|
||||
def __init__(self, base_path: str, warehouse_path: str, logger):
|
||||
self.base_path = base_path
|
||||
self.warehouse_path = warehouse_path
|
||||
self.logger = logger
|
||||
self.db = None
|
||||
os.makedirs(self.warehouse_path, exist_ok=True)
|
||||
self._init()
|
||||
|
||||
def _silver_parquet_expr(self, entity: str) -> str:
|
||||
return f"read_parquet('{self.base_path}/{entity}.parquet')"
|
||||
|
||||
def _init(self):
|
||||
self.db = duckdb.connect('/mnt/bulk/data/ynab_data.duckdb')
|
||||
self._setup_schemas()
|
||||
self._create_silver_external_tables()
|
||||
self._drop_gold()
|
||||
self._create_gold_views()
|
||||
self._create_gold_dates()
|
||||
self._create_gold_facts()
|
||||
|
||||
def _setup_schemas(self):
|
||||
self.db.execute("CREATE SCHEMA IF NOT EXISTS silver")
|
||||
self.db.execute("CREATE SCHEMA IF NOT EXISTS gold")
|
||||
|
||||
def _create_silver_external_tables(self):
|
||||
entities = ['accounts', 'categories', 'payees', 'transactions', 'scheduled_transactions']
|
||||
for entity in entities:
|
||||
sql = f"CREATE TABLE IF NOT EXISTS silver.{entity} AS SELECT * FROM {self._silver_parquet_expr(entity)}"
|
||||
try:
|
||||
self.db.execute(sql)
|
||||
self.logger.info(f"Created silver.{entity}")
|
||||
except Exception as e:
|
||||
self.logger.error(f"Failed to create silver.{entity}: {e}")
|
||||
self.logger.info("Silver layer initialized")
|
||||
|
||||
def _drop_gold(self):
|
||||
for name in ['accounts', 'categories', 'payees']:
|
||||
self.db.execute(f"DROP VIEW IF EXISTS gold.{name}")
|
||||
self.db.execute("DROP TABLE IF EXISTS gold.dates")
|
||||
self.db.execute("DROP TABLE IF EXISTS gold.transactions")
|
||||
self.db.execute("DROP TABLE IF EXISTS gold.scheduled_transactions")
|
||||
|
||||
# ── gold views (dimensions) ─────
|
||||
|
||||
def _create_gold_accounts_view(self):
|
||||
self.logger.info("Transforming and creating gold.accounts view")
|
||||
sql = """
|
||||
SELECT
|
||||
id AS account_id,
|
||||
name AS account_name,
|
||||
"type" AS account_type,
|
||||
on_budget,
|
||||
closed,
|
||||
COALESCE(CAST(note AS VARCHAR), 'none') AS note,
|
||||
balance / 1000.0 AS balance,
|
||||
cleared_balance / 1000.0 AS cleared_balance,
|
||||
uncleared_balance / 1000.0 AS uncleared_balance,
|
||||
deleted
|
||||
FROM silver.accounts
|
||||
"""
|
||||
try:
|
||||
self.db.execute(f"CREATE VIEW gold.accounts AS {sql}")
|
||||
self.logger.info("Created gold.accounts view")
|
||||
except Exception as e:
|
||||
self.logger.error(f"Failed to create gold.accounts view: {e}")
|
||||
|
||||
def _create_gold_categories_view(self):
|
||||
self.logger.info("Transforming and creating gold.categories view")
|
||||
sql = """
|
||||
SELECT
|
||||
id AS category_id,
|
||||
name AS category_name,
|
||||
category_group_name,
|
||||
hidden,
|
||||
COALESCE(CAST(note AS VARCHAR), 'none') AS note,
|
||||
budgeted / 1000.0 AS budgeted,
|
||||
activity / 1000.0 AS activity,
|
||||
balance / 1000.0 AS balance,
|
||||
deleted
|
||||
FROM silver.categories
|
||||
"""
|
||||
try:
|
||||
self.db.execute(f"CREATE VIEW gold.categories AS {sql}")
|
||||
self.logger.info("Created gold.categories view")
|
||||
except Exception as e:
|
||||
self.logger.error(f"Failed to create gold.categories view: {e}")
|
||||
|
||||
def _create_gold_payees_view(self):
|
||||
self.logger.info("Transforming and creating gold.payees view")
|
||||
sql = """
|
||||
SELECT
|
||||
id AS payee_id,
|
||||
name AS payee_name,
|
||||
deleted
|
||||
FROM silver.payees
|
||||
"""
|
||||
try:
|
||||
self.db.execute(f"CREATE VIEW gold.payees AS {sql}")
|
||||
self.logger.info("Created gold.payees view")
|
||||
except Exception as e:
|
||||
self.logger.error(f"Failed to create gold.payees view: {e}")
|
||||
|
||||
def _create_gold_views(self):
|
||||
self._create_gold_accounts_view()
|
||||
self._create_gold_categories_view()
|
||||
self._create_gold_payees_view()
|
||||
|
||||
# ── gold dates (materialized as Parquet, loaded into DuckDB) ─────
|
||||
|
||||
def _create_gold_dates(self):
|
||||
dates_path = os.path.join(self.warehouse_path, 'dates.parquet')
|
||||
self.logger.info("Creating gold.dates dimension")
|
||||
try:
|
||||
start = date(2020, 1, 1)
|
||||
end = date(2030, 12, 31)
|
||||
days = (end - start).days + 1
|
||||
rows = []
|
||||
for i in range(days):
|
||||
d = start + timedelta(days=i)
|
||||
month_z = str(d.month).zfill(2)
|
||||
day_z = str(d.day).zfill(2)
|
||||
rows.append({
|
||||
'date_id': f"{d.year}{month_z}{day_z}",
|
||||
'date': d,
|
||||
'year': int(d.year),
|
||||
'month': int(d.month),
|
||||
'day': int(d.day),
|
||||
'weekday': int(d.isoweekday()),
|
||||
'is_weekday': 1 if d.isoweekday() < 6 else 0,
|
||||
})
|
||||
|
||||
import pyarrow as pa
|
||||
import pyarrow.parquet as pq
|
||||
dates_df = pa.Table.from_pylist(rows)
|
||||
dates_df = dates_df.cast(pa.schema([
|
||||
('date_id', pa.string()),
|
||||
('date', pa.date32()),
|
||||
('year', pa.int32()),
|
||||
('month', pa.int8()),
|
||||
('day', pa.int8()),
|
||||
('weekday', pa.int8()),
|
||||
('is_weekday', pa.int8()),
|
||||
]))
|
||||
pq.write_table(dates_df, dates_path)
|
||||
|
||||
self.db.execute(f"CREATE TABLE gold.dates AS SELECT * FROM read_parquet('{dates_path}')")
|
||||
self.logger.info(f"Created gold.dates and persisted to {dates_path}")
|
||||
except Exception as e:
|
||||
self.logger.error(f"Failed to create gold.dates: {e}")
|
||||
|
||||
# ── gold facts (materialized as Parquet) ─────
|
||||
|
||||
def _create_gold_transactions_fact(self):
|
||||
transactions_path = os.path.join(self.warehouse_path, 'transactions.parquet')
|
||||
self.logger.info("Transforming and persisting gold.transactions fact")
|
||||
sql = f"""
|
||||
COPY (
|
||||
SELECT
|
||||
memo,
|
||||
cleared,
|
||||
approved,
|
||||
COALESCE(CAST(flag_color AS VARCHAR), 'none') AS flag_color,
|
||||
account_id,
|
||||
payee_id,
|
||||
category_id,
|
||||
transfer_account_id,
|
||||
id AS transaction_id,
|
||||
CAST(YEAR(CAST(date AS DATE)) AS VARCHAR)
|
||||
|| LPAD(CAST(MONTH(CAST(date AS DATE)) AS VARCHAR), 2, '0')
|
||||
|| LPAD(CAST(DAY(CAST(date AS DATE)) AS VARCHAR), 2, '0') AS transaction_date,
|
||||
amount / 1000.0 AS transaction_amount,
|
||||
deleted
|
||||
FROM silver.transactions
|
||||
) TO '{transactions_path}' (FORMAT PARQUET)
|
||||
"""
|
||||
try:
|
||||
self.db.execute(sql)
|
||||
self.logger.info(f"Persisted transactions.fact to {transactions_path}")
|
||||
except Exception as e:
|
||||
self.logger.error(f"Failed to create transactions.fact: {e}")
|
||||
|
||||
def _create_gold_scheduled_transactions_fact(self):
|
||||
st_path = os.path.join(self.warehouse_path, 'scheduled_transactions.parquet')
|
||||
self.logger.info("Transforming and persisting gold.scheduled_transactions fact")
|
||||
sql = f"""
|
||||
COPY (
|
||||
SELECT
|
||||
CAST(date_first AS DATE) AS date_first,
|
||||
CAST(date_next AS DATE) AS date_next,
|
||||
frequency,
|
||||
COALESCE(memo, 'none') AS memo,
|
||||
COALESCE(CAST(flag_color AS VARCHAR), 'none') AS flag_color,
|
||||
account_id,
|
||||
payee_id,
|
||||
category_id,
|
||||
transfer_account_id,
|
||||
id AS scheduled_transaction_id,
|
||||
amount / 1000.0 AS scheduled_transaction_amount
|
||||
FROM silver.scheduled_transactions
|
||||
) TO '{st_path}' (FORMAT PARQUET)
|
||||
"""
|
||||
try:
|
||||
self.db.execute(sql)
|
||||
self.logger.info(f"Persisted scheduled_transactions.fact to {st_path}")
|
||||
except Exception as e:
|
||||
self.logger.error(f"Failed to create scheduled_transactions.fact: {e}")
|
||||
|
||||
def _create_gold_facts(self):
|
||||
self._create_gold_transactions_fact()
|
||||
self._create_gold_scheduled_transactions_fact()
|
||||
# Create DuckDB tables from the persistent Parquet files so they're queryable
|
||||
self._register_facts()
|
||||
|
||||
def _register_facts(self):
|
||||
"""Register the fact Parquet files as DuckDB tables."""
|
||||
try:
|
||||
self.db.execute(f"CREATE TABLE gold.transactions AS SELECT * FROM read_parquet('{os.path.join(self.warehouse_path, 'transactions.parquet')}')")
|
||||
self.db.execute(f"CREATE TABLE gold.scheduled_transactions AS SELECT * FROM read_parquet('{os.path.join(self.warehouse_path, 'scheduled_transactions.parquet')}')")
|
||||
self.logger.info("Registered fact tables in DuckDB")
|
||||
except Exception as e:
|
||||
self.logger.error(f"Failed to register fact tables: {e}")
|
||||
|
||||
def close(self):
|
||||
if self.db:
|
||||
self.db.close()
|
||||
|
||||
|
||||
def get_duckdb_layer(base_path: str, warehouse_path: str, logger) -> DuckDBLayer:
|
||||
"""Factory function to create a DuckDB layer and perform all transformations."""
|
||||
return DuckDBLayer(base_path, warehouse_path, logger)
|
||||
+112
-76
@@ -1,116 +1,152 @@
|
||||
import polars as pl
|
||||
import logging
|
||||
import os
|
||||
|
||||
import polars as pl
|
||||
|
||||
|
||||
class Facts:
|
||||
def __init__(self, config):
|
||||
def __init__(self, config,logger):
|
||||
self.config = config
|
||||
self.base_file_path = self.config['base_data_path']
|
||||
self.logger = logger
|
||||
os.makedirs(self.config['warehouse_data_path'], exist_ok=True)
|
||||
|
||||
def get_full_file_path(self, file_name):
|
||||
return f"{self.base_file_path}/{file_name}"
|
||||
|
||||
class FactTransactions(Facts):
|
||||
def __init__(self, config):
|
||||
super().__init__(config)
|
||||
def __init__(self, config,logger):
|
||||
super().__init__(config,logger)
|
||||
self.file_path = self.get_full_file_path('transactions.parquet')
|
||||
self.transform()
|
||||
|
||||
def transform(self):
|
||||
# Read the parquet file into a polars DataFrame
|
||||
try:
|
||||
transactions_df = pl.read_parquet(self.file_path)
|
||||
source_transactions = pl.read_parquet(self.file_path)
|
||||
except FileNotFoundError:
|
||||
logging.error("The transactions DataFrame does not exist")
|
||||
self.logger.error("The transactions DataFrame does not exist")
|
||||
return
|
||||
|
||||
# Transform the DataFrame
|
||||
logging.info("Transforming the transactions DataFrame")
|
||||
try:
|
||||
transactions_df = (
|
||||
transactions_df
|
||||
.with_columns([
|
||||
pl.col("id").alias("transaction_id"),
|
||||
pl.col("date").alias("transaction_date"),
|
||||
pl.col("amount").alias("transaction_amount"),
|
||||
pl.col("memo").alias("transaction_memo"),
|
||||
pl.col("cleared").alias("transaction_cleared"),
|
||||
pl.col("approved").alias("transaction_approved"),
|
||||
pl.col("flag_color").alias("transaction_flag_color"),
|
||||
pl.col("account_id").alias("account_id"),
|
||||
pl.col("payee_id").alias("payee_id"),
|
||||
pl.col("category_id").alias("category_id"),
|
||||
pl.col("transfer_account_id").alias("transfer_account_id"),
|
||||
])
|
||||
.with_columns([
|
||||
pl.col("memo").fill_null("unknown"),
|
||||
(pl.col("amount") / 100).alias("transaction_amount"),
|
||||
])
|
||||
.drop([
|
||||
"transfer_transaction_id", "matched_transaction_id", "import_id",
|
||||
"subtransactions", "deleted","flag_name","account_name",
|
||||
"payee_name","category_name","import_payee_name","import_payee_name_original",
|
||||
"debt_transaction_type","ingestion_date"
|
||||
])
|
||||
)
|
||||
base_transactions = source_transactions.select([
|
||||
"id",
|
||||
"date",
|
||||
"amount",
|
||||
"memo",
|
||||
"cleared",
|
||||
"approved",
|
||||
"flag_color",
|
||||
"account_id",
|
||||
"payee_id",
|
||||
"category_id",
|
||||
"transfer_account_id"
|
||||
])
|
||||
except Exception as e:
|
||||
logging.error(f"Failed to transform the transactions DataFrame: {e}")
|
||||
self.logger.error(f"Failed to select columns from the transactions DataFrame: {e}")
|
||||
return
|
||||
|
||||
self.logger.info("Transforming the transactions DataFrame")
|
||||
try:
|
||||
resolve_transaction_dates = base_transactions.with_columns([
|
||||
pl.col("date").str.strptime(pl.Date, format="%Y-%m-%d").alias("date")
|
||||
])
|
||||
except Exception as e:
|
||||
self.logger.error(f"Failed to covert the date to date format: {e}")
|
||||
return
|
||||
|
||||
try:
|
||||
add_transaction_prefix = resolve_transaction_dates.with_columns([
|
||||
pl.col("id").alias("transaction_id"),
|
||||
(pl.col("date").dt.year().cast(pl.Utf8) +
|
||||
pl.col("date").dt.month().cast(pl.Utf8).str.zfill(2) +
|
||||
pl.col("date").dt.day().cast(pl.Utf8).str.zfill(2)).alias("transaction_date"),
|
||||
])
|
||||
fix_transaction_nulls = add_transaction_prefix.with_columns([
|
||||
pl.col("memo").fill_null("none"),
|
||||
pl.col("flag_color").fill_null("none"),
|
||||
pl.col("transfer_account_id").fill_null("none"),
|
||||
pl.col("category_id").fill_null("none"),
|
||||
])
|
||||
fix_transaction_values = fix_transaction_nulls.with_columns([
|
||||
(pl.col("amount") / 1000).alias("transaction_amount")
|
||||
])
|
||||
drop_transaction_columns = fix_transaction_values.drop([
|
||||
"id", "date", "amount"
|
||||
])
|
||||
|
||||
except Exception as e:
|
||||
self.logger.error(f"Failed to transform the transactions DataFrame: {e}")
|
||||
return
|
||||
# Write the DataFrame to a new parquet file
|
||||
logging.info("Writing the transformed transactions DataFrame to parquet file")
|
||||
self.logger.info("Writing the transformed transactions DataFrame to parquet file")
|
||||
try:
|
||||
transactions_df.write_parquet(self.config['warehouse_data_path'] + '/transactions.parquet')
|
||||
drop_transaction_columns.write_parquet(
|
||||
self.config['warehouse_data_path'] + '/transactions.parquet'
|
||||
)
|
||||
except Exception as e:
|
||||
logging.error(f"Failed to write the transformed transactions DataFrame: {e}")
|
||||
self.logger.error(f"Failed to write the transformed transactions DataFrame: {e}")
|
||||
|
||||
class FactScheduledTransactions(Facts):
|
||||
def __init__(self, config):
|
||||
super().__init__(config)
|
||||
def __init__(self, config,logger):
|
||||
super().__init__(config,logger)
|
||||
self.file_path = self.get_full_file_path('scheduled_transactions.parquet')
|
||||
self.transform()
|
||||
|
||||
def transform(self):
|
||||
# Read the parquet file into a polars DataFrame
|
||||
try:
|
||||
scheduled_transactions_df = pl.read_parquet(self.file_path)
|
||||
source_scheduled = pl.read_parquet(self.file_path)
|
||||
except FileNotFoundError:
|
||||
logging.error("The scheduled transactions DataFrame does not exist")
|
||||
self.logger.error("The scheduled transactions DataFrame does not exist")
|
||||
return
|
||||
|
||||
# Transform the DataFrame
|
||||
logging.info("Transforming the scheduled transactions DataFrame")
|
||||
try:
|
||||
scheduled_transactions_df = (
|
||||
scheduled_transactions_df
|
||||
.with_columns([
|
||||
pl.col("id").alias("scheduled_transaction_id"),
|
||||
pl.col("date_first").alias("scheduled_transaction_first_date"),
|
||||
pl.col("date_next").alias("scheduled_transaction_next_date"),
|
||||
pl.col("frequency").alias("scheduled_transaction_frequency"),
|
||||
pl.col("amount").alias("scheduled_transaction_amount"),
|
||||
pl.col("memo").alias("scheduled_transaction_memo"),
|
||||
pl.col("flag_color").alias("scheduled_transaction_flag_color"),
|
||||
pl.col("account_id").alias("account_id"),
|
||||
pl.col("payee_id").alias("payee_id"),
|
||||
pl.col("category_id").alias("category_id"),
|
||||
pl.col("transfer_account_id").alias("transfer_account_id"),
|
||||
])
|
||||
.with_columns([
|
||||
pl.col("memo").fill_null("unknown"),
|
||||
(pl.col("amount") / 100).alias("scheduled_transaction_amount"),
|
||||
])
|
||||
.drop([
|
||||
"subtransactions", "deleted","flag_name","account_name",
|
||||
"payee_name","category_name","ingestion_date"
|
||||
])
|
||||
)
|
||||
base_scheduled = source_scheduled.select([
|
||||
"id",
|
||||
"date_first",
|
||||
"date_next",
|
||||
"frequency",
|
||||
"amount",
|
||||
"memo",
|
||||
"flag_color",
|
||||
"account_id",
|
||||
"payee_id",
|
||||
"category_id",
|
||||
"transfer_account_id"
|
||||
])
|
||||
except Exception as e:
|
||||
logging.error(f"Failed to transform the scheduled transactions DataFrame: {e}")
|
||||
self.logger.error(f"Failed to select columns from the scheduled transactions DataFrame: {e}")
|
||||
return
|
||||
# Write the DataFrame to a new parquet file
|
||||
logging.info("Writing the transformed scheduled transactions DataFrame to parquet file")
|
||||
|
||||
try:
|
||||
scheduled_transactions_df.write_parquet(self.config['warehouse_data_path'] + '/scheduled_transactions.parquet')
|
||||
resolve_scheduled_dates = base_scheduled.with_columns([
|
||||
pl.col("date_first").str.strptime(pl.Date, format="%Y-%m-%d").alias("date_first"),
|
||||
pl.col("date_next").str.strptime(pl.Date, format="%Y-%m-%d").alias("date_next")
|
||||
])
|
||||
except Exception as e:
|
||||
logging.error(f"Failed to write the transformed scheduled transactions DataFrame: {e}")
|
||||
self.logger.error(f"Failed to covert the date to date format: {e}")
|
||||
return
|
||||
|
||||
self.logger.info("Transforming the scheduled transactions DataFrame")
|
||||
try:
|
||||
add_scheduled_prefix = resolve_scheduled_dates.with_columns([
|
||||
pl.col("id").alias("scheduled_transaction_id")
|
||||
])
|
||||
fix_sheduled_nulls = add_scheduled_prefix.with_columns([
|
||||
pl.col("memo").fill_null("none"),
|
||||
pl.col("flag_color").fill_null("none"),
|
||||
pl.col("transfer_account_id").fill_null("none"),
|
||||
pl.col("category_id").fill_null("none"),
|
||||
])
|
||||
fix_scheduled_values = fix_sheduled_nulls.with_columns([
|
||||
(pl.col("amount") / 1000).alias("scheduled_transaction_amount"),
|
||||
])
|
||||
drop_scheduled_columns = fix_scheduled_values.drop([
|
||||
"id", "amount"
|
||||
])
|
||||
except Exception as e:
|
||||
self.logger.error(f"Failed to transform the scheduled transactions DataFrame: {e}")
|
||||
return
|
||||
self.logger.info("Writing the transformed scheduled transactions DataFrame to parquet file")
|
||||
try:
|
||||
drop_scheduled_columns.write_parquet(self.config['warehouse_data_path'] + '/scheduled_transactions.parquet')
|
||||
except Exception as e:
|
||||
self.logger.error(f"Failed to write the transformed scheduled transactions DataFrame: {e}")
|
||||
|
||||
+51
-60
@@ -1,17 +1,17 @@
|
||||
import os
|
||||
import time
|
||||
import json
|
||||
import logging
|
||||
import requests
|
||||
import os
|
||||
import sys
|
||||
import yaml
|
||||
from typing import Dict, Any
|
||||
import time
|
||||
from typing import Any
|
||||
|
||||
import requests
|
||||
|
||||
import config.exit_codes as ec
|
||||
|
||||
|
||||
class Ingest:
|
||||
|
||||
|
||||
def __init__(self, config: Dict[str, Any]):
|
||||
def __init__(self, config: dict[str, Any],logger):
|
||||
"""
|
||||
Initialize the Ingest class with the provided configuration.
|
||||
"""
|
||||
@@ -22,21 +22,12 @@ class Ingest:
|
||||
self.entities = config['entities']
|
||||
self.raw_data_path = config['raw_data_path']
|
||||
self.headers = {'Authorization': f'Bearer {self.api_token}'}
|
||||
self.knowledge_cache = self.load_knowledge_cache()
|
||||
self.MAX_RETRIES = config['REQUESTS_MAX_RETRIES']
|
||||
self.RETRY_DELAY = config['REQUESTS_RETRY_DELAY']
|
||||
self.fetch_and_cache_entity_data()
|
||||
self.logger = logger
|
||||
|
||||
def load_knowledge_cache(self) -> Dict[str, Any]:
|
||||
"""
|
||||
Load the knowledge cache from the file if it exists.
|
||||
"""
|
||||
if os.path.exists(self.knowledge_file):
|
||||
with open(self.knowledge_file, 'r') as f:
|
||||
return json.load(f)
|
||||
return {}
|
||||
|
||||
def save_entity_data_to_raw(self, entity: str, data: Dict[str, Any]):
|
||||
def save_entity_data_to_raw(self, entity: str, data: dict[str, Any]):
|
||||
"""
|
||||
Save the data for a specific entity to a new cache file.
|
||||
"""
|
||||
@@ -45,13 +36,23 @@ class Ingest:
|
||||
if not os.path.exists(directory):
|
||||
os.makedirs(directory)
|
||||
entity_file = f'{directory}/{current_time}.json'
|
||||
logging.info(f"Saving {entity} data to {entity_file}")
|
||||
self.logger.info(f"Saving {entity} data to {entity_file}")
|
||||
try:
|
||||
with open(entity_file, 'w') as f:
|
||||
json.dump(data, f, indent=4)
|
||||
except Exception as e:
|
||||
logging.error(f"Error saving {entity} data: {e}")
|
||||
self.logger.error(f"Failed to save data for {entity} to {entity_file}")
|
||||
raise e
|
||||
|
||||
def load_knowledge_cache(self) -> dict[str, Any]:
|
||||
"""
|
||||
Load the knowledge cache from the file if it exists.
|
||||
"""
|
||||
if not os.path.exists(self.knowledge_file):
|
||||
os.makedirs(os.path.dirname(self.knowledge_file),exist_ok=True)
|
||||
return {}
|
||||
with open(self.knowledge_file, 'r') as f:
|
||||
return json.load(f)
|
||||
|
||||
def update_server_knowledge_cache(self, entity: str, server_knowledge: Any):
|
||||
"""
|
||||
@@ -61,7 +62,7 @@ class Ingest:
|
||||
with open(self.knowledge_file, 'r') as f:
|
||||
knowledge_cache = json.load(f)
|
||||
except FileNotFoundError:
|
||||
logging.info(f"Knowledge file not found. Creating a new one at {self.knowledge_file}. This is normal for the first run.")
|
||||
self.logger.info(f"Knowledge file not found. Creating a new one at {self.knowledge_file}. This is normal for the first run.")
|
||||
os.makedirs(os.path.dirname(self.knowledge_file), exist_ok=True)
|
||||
knowledge_cache = {}
|
||||
|
||||
@@ -70,69 +71,61 @@ class Ingest:
|
||||
with open(self.knowledge_file, 'w') as f:
|
||||
json.dump(knowledge_cache, f, indent=4)
|
||||
|
||||
def check_rate_limit(self, response: requests.Response):
|
||||
"""
|
||||
Check and handle the rate limit based on the response headers.
|
||||
"""
|
||||
rate_limit_header = response.headers.get('X-Rate-Limit')
|
||||
if rate_limit_header:
|
||||
requests_made, limit = map(int, rate_limit_header.split('/'))
|
||||
remaining_requests = limit - requests_made
|
||||
logging.info(f"Rate Limit: {remaining_requests}/{limit} requests remaining.")
|
||||
if remaining_requests < 20:
|
||||
logging.warning("Approaching rate limit. Consider pausing further requests.")
|
||||
# Implement pause or delay logic here if necessary
|
||||
if remaining_requests == 1:
|
||||
logging.error("Rate limit exceeded. ending requests here and moving on with what we have.")
|
||||
return True #returning True here to break out of any more ingestions
|
||||
|
||||
else:
|
||||
logging.warning("X-Rate-Limit header is missing.")
|
||||
knowledge_cache = self.load_knowledge_cache()
|
||||
knowledge_cache[entity] = server_knowledge
|
||||
try:
|
||||
with open(self.knowledge_file, 'w') as f:
|
||||
json.dump(knowledge_cache, f, indent=4)
|
||||
except Exception as e:
|
||||
self.logger.error(f"Failed to update knowledge cache for {entity} in {self.knowledge_file}")
|
||||
raise e
|
||||
|
||||
def handle_response(self, response) -> bool:
|
||||
if response.status_code == 400:
|
||||
logging.error("Bad request. The request could not be understood by the API due to malformed syntax or validation errors.")
|
||||
self.logger.error("Bad request. The request could not be understood by the API due to malformed syntax or validation errors.")
|
||||
sys.exit(ec.BAD_REQUEST)
|
||||
elif response.status_code == 401:
|
||||
logging.error("Unauthorized. Please check your API token.")
|
||||
self.logger.error("Unauthorized. Please check your API token.")
|
||||
sys.exit(ec.UNAUTHORIZED_API_TOKEN)
|
||||
elif response.status_code == 403:
|
||||
logging.error("Forbidden. Access is denied.")
|
||||
self.logger.error("Forbidden. Access is denied.")
|
||||
sys.exit(ec.FORBIDDEN)
|
||||
elif response.status_code == 404:
|
||||
logging.error("Not found. The specified URI does not exist.")
|
||||
self.logger.error("Not found. The specified URL does not exist.")
|
||||
sys.exit(ec.NOT_FOUND)
|
||||
elif response.status_code == 409:
|
||||
logging.error("Conflict. The resource cannot be saved due to a conflict.")
|
||||
self.logger.error("Conflict. The resource cannot be saved due to a conflict.")
|
||||
sys.exit(ec.CONFLICT)
|
||||
elif response.status_code == 429:
|
||||
logging.error("Too many requests. You have made too many requests in a short amount of time.")
|
||||
self.logger.error("Too many requests. You have made too many requests in a short amount of time.")
|
||||
return True
|
||||
elif response.status_code == 500:
|
||||
logging.error("Internal server error. The API experienced an unexpected error.")
|
||||
self.logger.error("Internal server error. The API experienced an unexpected error.")
|
||||
return True
|
||||
elif response.status_code == 503:
|
||||
logging.error("Service unavailable. The API is temporarily disabled or a request timeout occurred.")
|
||||
self.logger.error("Service unavailable. The API is temporarily disabled or a request timeout occurred.")
|
||||
return True
|
||||
else:
|
||||
response.raise_for_status()
|
||||
return False
|
||||
|
||||
def fetch_and_cache_entity_data(self):
|
||||
def start_ingestion(self):
|
||||
"""
|
||||
Fetch and cache data for all entities.
|
||||
"""
|
||||
for entity in self.entities:
|
||||
file_path = f'data/raw/{entity}'
|
||||
if os.path.exists(file_path) and os.listdir(file_path):
|
||||
logging.warning(f"Raw data exists for {entity} processing any raw data we already have.")
|
||||
self.logger.warning(f"Raw data exists for {entity} processing any raw data we already have.")
|
||||
break # break here instead of continue as we dont want to update our server knowledge cache and potentially miss data.
|
||||
|
||||
last_knowledge = self.knowledge_cache.get(entity, 0)
|
||||
knowledge_cache = self.load_knowledge_cache()
|
||||
last_knowledge = knowledge_cache.get(entity, 0)
|
||||
#logging.debug(f'Last Knowledge of {entity}: {last_knowledge}')
|
||||
logging.info(f'Fetching {entity} data since last knowledge: {last_knowledge}')
|
||||
url = f'{self.base_url}/{self.budget_id}/{entity}?last_knowledge_of_server={last_knowledge}'
|
||||
|
||||
self.logger.info(f'Fetching {entity} data since last knowledge: {last_knowledge}')
|
||||
url = f'{self.base_url}/{self.budget_id}/{entity}?last_knowledge_of_server={last_knowledge}'
|
||||
response = None
|
||||
for attempt in range(self.MAX_RETRIES):
|
||||
try:
|
||||
response = requests.get(url, headers=self.headers)
|
||||
@@ -140,16 +133,17 @@ class Ingest:
|
||||
if not should_retry:
|
||||
break # Exit the loop if the request is successful
|
||||
except requests.exceptions.RequestException as e:
|
||||
logging.error(f"Error fetching {entity} data (attempt {attempt + 1}/{self.MAX_RETRIES}): {e}")
|
||||
self.logger.error(f"Error fetching {entity} data (attempt {attempt + 1}/{self.MAX_RETRIES}): {e}")
|
||||
if attempt < self.MAX_RETRIES - 1:
|
||||
time.sleep(self.RETRY_DELAY) # Wait before retrying
|
||||
else:
|
||||
logging.error("Max retries reached. Exiting.")
|
||||
self.logger.error("Max retries reached. Exiting.")
|
||||
sys.exit(ec.REQUESTS_ERROR)
|
||||
|
||||
data = response.json()
|
||||
self.logger.debug(f'response data: {data}')
|
||||
server_knowledge = data['data'].get('server_knowledge')
|
||||
logging.debug(f'{entity} new server knowledge: {server_knowledge}')
|
||||
self.logger.debug(f'{entity} new server knowledge: {server_knowledge}')
|
||||
|
||||
if server_knowledge is not None and server_knowledge != last_knowledge:
|
||||
self.update_server_knowledge_cache(entity, server_knowledge)
|
||||
@@ -157,7 +151,4 @@ class Ingest:
|
||||
entity_data.pop('server_knowledge', None)
|
||||
self.save_entity_data_to_raw(entity, entity_data)
|
||||
else:
|
||||
logging.info(f"No new data for {entity}. Skipping cache update.")
|
||||
|
||||
if self.check_rate_limit(response):
|
||||
break # break out here and continue processing the data we have.
|
||||
self.logger.info(f"No new data for {entity}. Skipping cache update.")
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
'''Module to run the data pipeline'''
|
||||
|
||||
from pipeline import duckdb_layer, ingest, raw_to_base
|
||||
|
||||
|
||||
def pipeline_main(config, logger):
|
||||
'''Run the data pipeline'''
|
||||
logger.info('Starting data pipeline')
|
||||
|
||||
ingest.Ingest(config, logger).start_ingestion()
|
||||
raw_to_base.RawToBase(config, logger)
|
||||
|
||||
duckdb_layer.get_duckdb_layer(
|
||||
config['base_data_path'],
|
||||
config['warehouse_data_path'],
|
||||
logger
|
||||
)
|
||||
|
||||
logger.info('Data pipeline completed successfully')
|
||||
+48
-39
@@ -1,14 +1,16 @@
|
||||
import os
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import sys
|
||||
from datetime import datetime
|
||||
from typing import Dict, Any
|
||||
import config.exit_codes as ec
|
||||
from typing import Any
|
||||
|
||||
import polars as pl
|
||||
|
||||
import config.exit_codes as ec
|
||||
|
||||
|
||||
class RawToBase:
|
||||
def __init__(self, config: Dict[str, Any]):
|
||||
def __init__(self, config: dict[str, Any],logger):
|
||||
self.entities = config['entities']
|
||||
self.primary_keys = config['primary_keys']
|
||||
self.raw_data_path = config['raw_data_path']
|
||||
@@ -16,39 +18,40 @@ class RawToBase:
|
||||
self.base_data_path = config['base_data_path']
|
||||
self.data = {}
|
||||
self.base_data = {}
|
||||
self.logger = logger
|
||||
self.process_entities()
|
||||
|
||||
def process_entities(self):
|
||||
for entity in self.entities:
|
||||
logging.info(f"Processing entity: {entity}")
|
||||
self.logger.info(f"Processing entity: {entity}")
|
||||
# check the file is in the raw data path, if not skip the entity
|
||||
folder_path = os.path.join(self.raw_data_path, entity)
|
||||
folder_contents = os.listdir(folder_path)
|
||||
if not folder_contents:
|
||||
logging.warning(f"The folder {folder_path} is empty skipping {entity}.")
|
||||
self.logger.warning(f"The folder {folder_path} is empty skipping {entity}.")
|
||||
continue
|
||||
if not self._load_raw_data(entity):
|
||||
logging.warning(f"Skipping processing for entity: {entity} due to empty data.")
|
||||
self.logger.warning(f"Skipping processing for entity: {entity} due to empty data.")
|
||||
continue
|
||||
self._load_existing_base_data(entity)
|
||||
self._combine_data(entity)
|
||||
if not self._save_base_data(entity):
|
||||
logging.error(f"Skipping processing for entity: {entity} due to failed saving base data.")
|
||||
self.logger.error(f"Skipping processing for entity: {entity} due to failed saving base data.")
|
||||
continue
|
||||
if not self._move_raw_to_processed(entity):
|
||||
logging.error(f"entity: {entity} has been processed, but we could not move the file out of the raw folder, please clear the raw folder for {entity}.")
|
||||
self.logger.error(f"entity: {entity} has been processed, but we could not move the file out of the raw folder, please clear the raw folder for {entity}.")
|
||||
sys.exit(ec.MOVE_FILE_ERROR)
|
||||
logging.info(f"Successfully processed entity: {entity}")
|
||||
self.logger.info(f"Successfully processed entity: {entity}")
|
||||
|
||||
def _load_raw_data(self, entity):
|
||||
entity_path = os.path.join(self.raw_data_path, entity)
|
||||
self.data[entity] = []
|
||||
logging.debug(f"Loading data for entity: {entity} from path: {entity_path}")
|
||||
self.logger.debug(f"Loading data for entity: {entity} from path: {entity_path}")
|
||||
|
||||
files = [f for f in os.listdir(entity_path) if f.endswith('.json')]
|
||||
|
||||
if len(files) > 1:
|
||||
logging.error(f"""More than one file found in path: {entity_path}. Skipping processing for entity: {entity}.
|
||||
self.logger.error(f"""More than one file found in path: {entity_path}. Skipping processing for entity: {entity}.
|
||||
recommended actions is to move the newest file(s) out, re-run main.py.
|
||||
Then move the files back in one at a time oldest to newest and run again for each file""")
|
||||
return False
|
||||
@@ -56,44 +59,50 @@ Then move the files back in one at a time oldest to newest and run again for eac
|
||||
if len(files) == 1:
|
||||
file_name = files[0]
|
||||
file_path = os.path.join(entity_path, file_name)
|
||||
logging.debug(f"Reading file: {file_path}")
|
||||
self.logger.debug(f"Reading file: {file_path}")
|
||||
try:
|
||||
with open(file_path, 'r') as f:
|
||||
data = json.load(f)
|
||||
except Exception as e:
|
||||
logging.error(f"Failed to load data from file: {file_path}, error: {e}")
|
||||
self.logger.error(f"Failed to load data from file: {file_path}, error: {e}")
|
||||
return False
|
||||
|
||||
if self._is_data_empty(entity, data, file_path):
|
||||
return False
|
||||
|
||||
modified_data = self._add_ingestion_date(entity, data, file_name)
|
||||
for index, record in enumerate(modified_data):
|
||||
self.logger.debug(f"processing record: {record}")
|
||||
filtered_record = {k: v for k, v in record.items() if not k.startswith('debt_')}
|
||||
modified_data[index] = filtered_record
|
||||
self.logger.debug(f"filtered record: {filtered_record}")
|
||||
self.logger.debug(f"modified data: {modified_data}")
|
||||
|
||||
self.data[entity].append(modified_data)
|
||||
logging.debug(f"Successfully loaded data from file: {file_path}")
|
||||
self.logger.debug(f"Successfully loaded data from file: {file_path}")
|
||||
return True
|
||||
|
||||
def _is_data_empty(self, entity, data, file_path):
|
||||
logging.debug(f"Checking if data is empty for entity: {entity}")
|
||||
self.logger.debug(f"Checking if data is empty for entity: {entity}")
|
||||
if entity == "categories":
|
||||
has_categories = any(group.get("categories") for group in data.get("category_groups", []))
|
||||
if not has_categories:
|
||||
logging.warning(f"Received empty data for entity: {entity} in file: {file_path}, deleting file.")
|
||||
self.logger.warning(f"Received empty data for entity: {entity} in file: {file_path}, deleting file.")
|
||||
os.remove(file_path)
|
||||
return True
|
||||
else:
|
||||
if not data.get(entity, []):
|
||||
logging.warning(f"Received empty data for entity: {entity} in file: {file_path}, deleting file.")
|
||||
self.logger.warning(f"Received empty data for entity: {entity} in file: {file_path}, deleting file.")
|
||||
os.remove(file_path)
|
||||
return True
|
||||
logging.debug(f"Data is not empty for entity: {entity}")
|
||||
self.logger.debug(f"Data is not empty for entity: {entity}")
|
||||
return False
|
||||
|
||||
def _add_ingestion_date(self, entity, data, file_name):
|
||||
modified_data = []
|
||||
ingestion_date = datetime.strptime(file_name.split('.')[0], '%Y%m%d%H%M%S').date()
|
||||
|
||||
logging.debug(f"Adding ingestion date to data for entity: {entity}")
|
||||
self.logger.debug(f"Adding ingestion date to data for entity: {entity}")
|
||||
if entity == 'categories':
|
||||
for group in data.get('category_groups', []):
|
||||
for category in group.get('categories', []):
|
||||
@@ -106,22 +115,22 @@ Then move the files back in one at a time oldest to newest and run again for eac
|
||||
modified_data.append(record)
|
||||
else:
|
||||
modified_data.append({'record': record, 'ingestion_date': ingestion_date})
|
||||
logging.debug(f"Successfully added ingestion date to data for entity: {entity}")
|
||||
self.logger.debug(f"Successfully added ingestion date to data for entity: {entity}")
|
||||
return modified_data
|
||||
|
||||
def _load_existing_base_data(self, entity):
|
||||
base_path = os.path.join(self.base_data_path, f'{entity}.parquet')
|
||||
if os.path.exists(base_path):
|
||||
logging.debug(f"Loading existing base data for entity: {entity} from path: {base_path}")
|
||||
self.logger.debug(f"Loading existing base data for entity: {entity} from path: {base_path}")
|
||||
try:
|
||||
self.base_data[entity] = pl.read_parquet(base_path)
|
||||
except Exception as e:
|
||||
logging.error(f"Failed to load existing base data for entity: {entity}, error: {e}, Creating an empty DataFrame")
|
||||
self.logger.error(f"Failed to load existing base data for entity: {entity}, error: {e}, Creating an empty DataFrame")
|
||||
self.base_data[entity] = pl.DataFrame()
|
||||
logging.debug(f"Successfully loaded existing base data for entity: {entity}")
|
||||
self.logger.debug(f"Successfully loaded existing base data for entity: {entity}")
|
||||
else:
|
||||
self.base_data[entity] = pl.DataFrame()
|
||||
logging.debug(f"No existing base data found for entity: {entity}, starting with an empty DataFrame")
|
||||
self.logger.debug(f"No existing base data found for entity: {entity}, starting with an empty DataFrame")
|
||||
|
||||
#Function to cast null Struct({'': Null}) columns to String
|
||||
def _cast_struct_to_string(self,df):
|
||||
@@ -130,13 +139,13 @@ Then move the files back in one at a time oldest to newest and run again for eac
|
||||
df = df.with_columns(
|
||||
pl.when(pl.col(col).is_null())
|
||||
.then(pl.lit("null"))
|
||||
.otherwise(pl.col(col).map_elements(lambda x: str(x) if x is not None else "null"))
|
||||
.otherwise(pl.col(col).map_elements(lambda x: str(x) if x is not None else "null", return_dtype=pl.Utf8))
|
||||
.alias(col)
|
||||
)
|
||||
return df
|
||||
|
||||
def _combine_data(self, entity):
|
||||
logging.debug(f"Combining data for entity: {entity}")
|
||||
self.logger.debug(f"Combining data for entity: {entity}")
|
||||
combined_data = []
|
||||
|
||||
# Combine data from the entity
|
||||
@@ -153,8 +162,8 @@ Then move the files back in one at a time oldest to newest and run again for eac
|
||||
# Ensure the unique id column is preserved
|
||||
unique_id = self.primary_keys[entity]['unique_id']
|
||||
if unique_id not in new_data_df.columns:
|
||||
logging.error(f"Unique ID column '{unique_id}' not found in the combined data for entity: {entity}")
|
||||
exit(ec.UNIQUE_ID_NOT_FOUND)
|
||||
self.logger.error(f"Unique ID column '{unique_id}' not found in the combined data for entity: {entity}")
|
||||
sys.exit(ec.UNIQUE_ID_NOT_FOUND)
|
||||
|
||||
# Cast columns in new_data_df
|
||||
new_data_df = self._cast_struct_to_string(new_data_df)
|
||||
@@ -182,7 +191,7 @@ Then move the files back in one at a time oldest to newest and run again for eac
|
||||
else:
|
||||
self.base_data[entity] = new_data_df
|
||||
|
||||
logging.debug(f"Successfully combined data for entity: {entity}")
|
||||
self.logger.debug(f"Successfully combined data for entity: {entity}")
|
||||
|
||||
def _save_base_data(self, entity):
|
||||
os.makedirs(self.base_data_path, exist_ok=True)
|
||||
@@ -190,9 +199,9 @@ Then move the files back in one at a time oldest to newest and run again for eac
|
||||
try:
|
||||
self.base_data[entity].write_parquet(file_path)
|
||||
except Exception as e:
|
||||
logging.error(f"Failed to save base data for entity: {entity}, error: {e}")
|
||||
self.logger.error(f"Failed to save base data for entity: {entity}, error: {e}")
|
||||
return False
|
||||
logging.debug(f"Saved base data for entity: {entity} to path: {file_path}")
|
||||
self.logger.debug(f"Saved base data for entity: {entity} to path: {file_path}")
|
||||
return True
|
||||
|
||||
def _move_raw_to_processed(self, entity):
|
||||
@@ -204,24 +213,24 @@ Then move the files back in one at a time oldest to newest and run again for eac
|
||||
try:
|
||||
files = [f for f in os.listdir(raw_entity_path) if f.endswith('.json')]
|
||||
if len(files) != 1:
|
||||
logging.error(f"Expected exactly one file in path: {raw_entity_path}, but found {len(files)}")
|
||||
self.logger.error(f"Expected exactly one file in path: {raw_entity_path}, but found {len(files)}")
|
||||
return False
|
||||
|
||||
file_name = files[0]
|
||||
raw_file_path = os.path.join(raw_entity_path, file_name)
|
||||
processed_file_path = os.path.join(processed_path, file_name)
|
||||
|
||||
logging.debug(f"Moving file: {raw_file_path} to {processed_file_path}")
|
||||
self.logger.debug(f"Moving file: {raw_file_path} to {processed_file_path}")
|
||||
|
||||
os.rename(raw_file_path, processed_file_path)
|
||||
logging.debug(f"Moved file: {file_name} to processed")
|
||||
self.logger.debug(f"Moved file: {file_name} to processed")
|
||||
|
||||
except FileNotFoundError as e:
|
||||
logging.error(f"File not found: {e}")
|
||||
self.logger.error(f"File not found: {e}")
|
||||
return False
|
||||
except Exception as e:
|
||||
logging.error(f"Failed to move file for entity: {entity}, error: {e}")
|
||||
self.logger.error(f"Failed to move file for entity: {entity}, error: {e}")
|
||||
return False
|
||||
|
||||
logging.debug(f"Moved processed file for entity: {entity} to path: {processed_path}")
|
||||
self.logger.debug(f"Moved processed file for entity: {entity} to path: {processed_path}")
|
||||
return True
|
||||
@@ -0,0 +1,17 @@
|
||||
[project]
|
||||
name = "data-pipeline-for-ynab"
|
||||
version = "0.1.0"
|
||||
description = "Add your description here"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.13"
|
||||
dependencies = [
|
||||
"duckdb>=1.5.5",
|
||||
"pandas>=3.0.5",
|
||||
"polars>=1.43.0",
|
||||
"pyarrow>=25.0.0",
|
||||
"pytest>=9.1.1",
|
||||
"python-dotenv>=1.2.2",
|
||||
"pyyaml>=6.0.3",
|
||||
"requests>=2.34.2",
|
||||
"ruff>=0.16.0",
|
||||
]
|
||||
@@ -1,4 +0,0 @@
|
||||
python-dotenv
|
||||
polars
|
||||
requests
|
||||
pyyaml
|
||||
@@ -0,0 +1,306 @@
|
||||
import json
|
||||
import os
|
||||
from unittest.mock import MagicMock, mock_open, patch
|
||||
|
||||
import pytest
|
||||
|
||||
import config.exit_codes as ec
|
||||
from pipeline.ingest import Ingest
|
||||
|
||||
# Mock configuration for initializing the Ingest class
|
||||
mock_config = {
|
||||
'API_TOKEN': 'test_token',
|
||||
'BUDGET_ID': 'test_budget_id',
|
||||
'base_url': 'http://test_base_url',
|
||||
'knowledge_file': 'data/test_knowledge_file.json',
|
||||
'entities': ['entity1', 'entity2'],
|
||||
'raw_data_path': 'test_raw_data_path',
|
||||
'REQUESTS_MAX_RETRIES': 3,
|
||||
'REQUESTS_RETRY_DELAY': 1
|
||||
}
|
||||
|
||||
# Test for load_knowledge_cache method
|
||||
def test_load_knowledge_cache_file_exists():
|
||||
mock_data = {"key": "value"}
|
||||
with patch('os.path.exists', return_value=True), \
|
||||
patch('builtins.open', mock_open(read_data=json.dumps(mock_data))) as mock_file:
|
||||
|
||||
ingest_instance = Ingest(mock_config)
|
||||
result = ingest_instance.load_knowledge_cache()
|
||||
|
||||
mock_file.assert_called_once_with(mock_config['knowledge_file'], 'r')
|
||||
assert result == mock_data
|
||||
|
||||
def test_load_knowledge_cache_file_not_exists():
|
||||
with patch('os.path.exists', return_value=False):
|
||||
|
||||
ingest_instance = Ingest(mock_config)
|
||||
result = ingest_instance.load_knowledge_cache()
|
||||
|
||||
assert result == {}
|
||||
|
||||
# Test for save_entity_data_to_raw method
|
||||
def test_save_entity_data_to_raw_success():
|
||||
entity = 'entity1'
|
||||
data = {"key": "value"}
|
||||
current_time = '20230101123000'
|
||||
directory = os.path.join(mock_config['raw_data_path'], entity)
|
||||
entity_file = f'{directory}/{current_time}.json'
|
||||
|
||||
with patch('os.path.exists', return_value=False), \
|
||||
patch('os.makedirs') as mock_makedirs, \
|
||||
patch('builtins.open', mock_open()) as mock_file, \
|
||||
patch('time.strftime', return_value=current_time), \
|
||||
patch('logging.info') as mock_logging_info:
|
||||
|
||||
ingest_instance = Ingest(mock_config)
|
||||
ingest_instance.save_entity_data_to_raw(entity, data)
|
||||
|
||||
mock_makedirs.assert_called_once_with(directory)
|
||||
mock_file.assert_called_once_with(entity_file, 'w')
|
||||
|
||||
# Get the file handle and check the written content
|
||||
handle = mock_file()
|
||||
handle.write.assert_called()
|
||||
written_content = ''.join(call.args[0] for call in handle.write.call_args_list)
|
||||
assert written_content == json.dumps(data, indent=4)
|
||||
|
||||
mock_logging_info.assert_called_once_with(f"Saving {entity} data to {entity_file}")
|
||||
|
||||
def test_save_entity_data_to_raw_existing_directory():
|
||||
entity = 'entity1'
|
||||
data = {"key": "value"}
|
||||
current_time = '20230101123000'
|
||||
directory = os.path.join(mock_config['raw_data_path'], entity)
|
||||
entity_file = f'{directory}/{current_time}.json'
|
||||
|
||||
with patch('os.path.exists', return_value=True), \
|
||||
patch('os.makedirs') as mock_makedirs, \
|
||||
patch('builtins.open', mock_open()) as mock_file, \
|
||||
patch('time.strftime', return_value=current_time), \
|
||||
patch('logging.info') as mock_logging_info:
|
||||
|
||||
ingest_instance = Ingest(mock_config)
|
||||
ingest_instance.save_entity_data_to_raw(entity, data)
|
||||
|
||||
mock_makedirs.assert_not_called()
|
||||
mock_file.assert_called_once_with(entity_file, 'w')
|
||||
|
||||
# Get the file handle and check the written content
|
||||
handle = mock_file()
|
||||
handle.write.assert_called()
|
||||
written_content = ''.join(call.args[0] for call in handle.write.call_args_list)
|
||||
assert written_content == json.dumps(data, indent=4)
|
||||
|
||||
mock_logging_info.assert_called_once_with(f"Saving {entity} data to {entity_file}")
|
||||
|
||||
def test_save_entity_data_to_raw_error():
|
||||
entity = 'entity1'
|
||||
data = {"key": "value"}
|
||||
current_time = '20230101123000'
|
||||
directory = os.path.join(mock_config['raw_data_path'], entity)
|
||||
entity_file = f'{directory}/{current_time}.json'
|
||||
|
||||
with patch('os.path.exists', return_value=True), \
|
||||
patch('builtins.open', mock_open()) as mock_file, \
|
||||
patch('time.strftime', return_value=current_time), \
|
||||
patch('logging.info') as mock_logging_info, \
|
||||
patch('logging.error') as mock_logging_error:
|
||||
|
||||
mock_file.side_effect = Exception("Test error")
|
||||
|
||||
ingest_instance = Ingest(mock_config)
|
||||
|
||||
with pytest.raises(Exception, match="Test error"):
|
||||
ingest_instance.save_entity_data_to_raw(entity, data)
|
||||
|
||||
mock_logging_error.assert_called_once_with(f"Failed to save data for {entity} to {entity_file}")
|
||||
|
||||
def test_update_server_knowledge_cache_file_exists():
|
||||
entity = 'entity1'
|
||||
server_knowledge = {"key": "value"}
|
||||
existing_cache = {"entity2": {"key": "old_value"}}
|
||||
updated_cache = {"entity2": {"key": "old_value"}, "entity1": {"key": "value"}}
|
||||
|
||||
with patch('builtins.open', mock_open(read_data=json.dumps(existing_cache))) as mock_file, \
|
||||
patch('os.path.exists', return_value=True), \
|
||||
patch('logging.error') as mock_logging_error:
|
||||
|
||||
ingest_instance = Ingest(mock_config)
|
||||
ingest_instance.update_server_knowledge_cache(entity, server_knowledge)
|
||||
|
||||
mock_file.assert_called_with(mock_config['knowledge_file'], 'w')
|
||||
handle = mock_file()
|
||||
handle.write.assert_called()
|
||||
written_content = ''.join(call.args[0] for call in handle.write.call_args_list)
|
||||
assert json.loads(written_content) == updated_cache
|
||||
mock_logging_error.assert_not_called()
|
||||
|
||||
def test_update_server_knowledge_cache_file_not_exists():
|
||||
entity = 'entity1'
|
||||
server_knowledge = {"key": "value"}
|
||||
updated_cache = {"entity1": {"key": "value"}}
|
||||
|
||||
with patch('builtins.open', mock_open()) as mock_file, \
|
||||
patch('os.path.exists', return_value=False), \
|
||||
patch('os.makedirs') as mock_makedirs, \
|
||||
patch('logging.info') as mock_logging_info, \
|
||||
patch('logging.error') as mock_logging_error:
|
||||
|
||||
# Ensure the side_effect list has enough elements to cover all calls to open
|
||||
mock_file.side_effect = [FileNotFoundError(), mock_open().return_value]
|
||||
|
||||
ingest_instance = Ingest(mock_config)
|
||||
|
||||
with pytest.raises(FileNotFoundError):
|
||||
ingest_instance.update_server_knowledge_cache(entity, server_knowledge)
|
||||
|
||||
mock_makedirs.assert_called_once_with(os.path.dirname(mock_config['knowledge_file']), exist_ok=True)
|
||||
mock_file.assert_called_with(mock_config['knowledge_file'], 'w')
|
||||
mock_logging_error.assert_called_once_with(f"Failed to update knowledge cache for {entity} in {mock_config['knowledge_file']}")
|
||||
|
||||
def test_update_server_knowledge_cache_write_error():
|
||||
entity = 'entity1'
|
||||
server_knowledge = {"key": "value"}
|
||||
|
||||
with patch('builtins.open', mock_open()) as mock_file, \
|
||||
patch('logging.error') as mock_logging_error:
|
||||
|
||||
mock_file.side_effect = Exception("Test error")
|
||||
|
||||
ingest_instance = Ingest(mock_config)
|
||||
|
||||
with pytest.raises(Exception, match="Test error"):
|
||||
ingest_instance.update_server_knowledge_cache(entity, server_knowledge)
|
||||
|
||||
mock_logging_error.assert_called_once_with(f"Failed to update knowledge cache for {entity} in {mock_config['knowledge_file']}")
|
||||
|
||||
def test_check_rate_limit_above_threshold():
|
||||
response = MagicMock()
|
||||
response.headers = {'X-Rate-Limit': '10/100'}
|
||||
|
||||
ingest_instance = Ingest(mock_config)
|
||||
result = ingest_instance.check_rate_limit(response)
|
||||
|
||||
assert result is None
|
||||
|
||||
def test_check_rate_limit_below_threshold():
|
||||
response = MagicMock()
|
||||
response.headers = {'X-Rate-Limit': '90/100'}
|
||||
|
||||
ingest_instance = Ingest(mock_config)
|
||||
result = ingest_instance.check_rate_limit(response)
|
||||
|
||||
assert result is None
|
||||
|
||||
def test_check_rate_limit_exceeded():
|
||||
response = MagicMock()
|
||||
response.headers = {'X-Rate-Limit': '100/100'}
|
||||
|
||||
ingest_instance = Ingest(mock_config)
|
||||
result = ingest_instance.check_rate_limit(response)
|
||||
|
||||
assert result is True
|
||||
|
||||
def test_check_rate_limit_header_missing():
|
||||
response = MagicMock()
|
||||
response.headers = {}
|
||||
|
||||
ingest_instance = Ingest(mock_config)
|
||||
result = ingest_instance.check_rate_limit(response)
|
||||
|
||||
assert result is None
|
||||
|
||||
def test_handle_response_bad_request():
|
||||
response = MagicMock()
|
||||
response.status_code = 400
|
||||
|
||||
ingest_instance = Ingest(mock_config)
|
||||
|
||||
with pytest.raises(SystemExit) as e:
|
||||
ingest_instance.handle_response(response)
|
||||
assert e.type == SystemExit
|
||||
assert e.value.code == ec.BAD_REQUEST
|
||||
|
||||
def test_handle_response_unauthorized():
|
||||
response = MagicMock()
|
||||
response.status_code = 401
|
||||
|
||||
ingest_instance = Ingest(mock_config)
|
||||
|
||||
with pytest.raises(SystemExit) as e:
|
||||
ingest_instance.handle_response(response)
|
||||
assert e.type == SystemExit
|
||||
assert e.value.code == ec.UNAUTHORIZED_API_TOKEN
|
||||
|
||||
def test_handle_response_forbidden():
|
||||
response = MagicMock()
|
||||
response.status_code = 403
|
||||
|
||||
ingest_instance = Ingest(mock_config)
|
||||
|
||||
with pytest.raises(SystemExit) as e:
|
||||
ingest_instance.handle_response(response)
|
||||
assert e.type == SystemExit
|
||||
assert e.value.code == ec.FORBIDDEN
|
||||
|
||||
def test_handle_response_not_found():
|
||||
response = MagicMock()
|
||||
response.status_code = 404
|
||||
|
||||
ingest_instance = Ingest(mock_config)
|
||||
|
||||
with pytest.raises(SystemExit) as e:
|
||||
ingest_instance.handle_response(response)
|
||||
assert e.type == SystemExit
|
||||
assert e.value.code == ec.NOT_FOUND
|
||||
|
||||
def test_handle_response_conflict():
|
||||
response = MagicMock()
|
||||
response.status_code = 409
|
||||
|
||||
ingest_instance = Ingest(mock_config)
|
||||
|
||||
with pytest.raises(SystemExit) as e:
|
||||
ingest_instance.handle_response(response)
|
||||
assert e.type == SystemExit
|
||||
assert e.value.code == ec.CONFLICT
|
||||
|
||||
def test_handle_response_too_many_requests():
|
||||
response = MagicMock()
|
||||
response.status_code = 429
|
||||
|
||||
ingest_instance = Ingest(mock_config)
|
||||
|
||||
result = ingest_instance.handle_response(response)
|
||||
assert result is True
|
||||
|
||||
def test_handle_response_internal_server_error():
|
||||
response = MagicMock()
|
||||
response.status_code = 500
|
||||
|
||||
ingest_instance = Ingest(mock_config)
|
||||
|
||||
result = ingest_instance.handle_response(response)
|
||||
assert result is True
|
||||
|
||||
def test_handle_response_service_unavailable():
|
||||
response = MagicMock()
|
||||
response.status_code = 503
|
||||
|
||||
ingest_instance = Ingest(mock_config)
|
||||
|
||||
result = ingest_instance.handle_response(response)
|
||||
assert result is True
|
||||
|
||||
def test_handle_response_ok():
|
||||
response = MagicMock()
|
||||
response.status_code = 200
|
||||
|
||||
ingest_instance = Ingest(mock_config)
|
||||
|
||||
result = ingest_instance.handle_response(response)
|
||||
assert result is False
|
||||
|
||||
if __name__ == "__main__":
|
||||
pytest.main()
|
||||
@@ -0,0 +1,70 @@
|
||||
import logging
|
||||
from unittest.mock import MagicMock, mock_open, patch
|
||||
|
||||
import yaml
|
||||
|
||||
import config.exit_codes as ec
|
||||
from main import load_config, set_up_logging
|
||||
|
||||
|
||||
# Test for set_up_logging function
|
||||
def test_set_up_logging_success():
|
||||
with patch('builtins.open', mock_open(read_data="handlers:\n queue_handler:\n class: logging.handlers.QueueHandler")), \
|
||||
patch('yaml.safe_load', return_value={"handlers": {"queue_handler": {"class": "logging.handlers.QueueHandler"}}}), \
|
||||
patch('logging.config.dictConfig') as mock_dict_config, \
|
||||
patch('logging.getHandlerByName', return_value=MagicMock(listener=MagicMock(start=MagicMock(), stop=MagicMock()))), \
|
||||
patch('atexit.register') as mock_atexit_register:
|
||||
|
||||
set_up_logging()
|
||||
|
||||
mock_dict_config.assert_called_once_with({"handlers": {"queue_handler": {"class": "logging.handlers.QueueHandler"}}})
|
||||
mock_atexit_register.assert_called_once()
|
||||
|
||||
def test_set_up_logging_yaml_error():
|
||||
with patch('builtins.open', mock_open(read_data="invalid_yaml")), \
|
||||
patch('yaml.safe_load', side_effect=yaml.YAMLError("Error")), \
|
||||
patch('logging.basicConfig') as mock_basic_config:
|
||||
|
||||
set_up_logging()
|
||||
|
||||
mock_basic_config.assert_called_once_with(level=logging.INFO)
|
||||
|
||||
def test_set_up_logging_no_queue_handler():
|
||||
with patch('builtins.open', mock_open(read_data="handlers:\n queue_handler:\n class: logging.handlers.QueueHandler")), \
|
||||
patch('yaml.safe_load', return_value={"handlers": {"queue_handler": {"class": "logging.handlers.QueueHandler"}}}), \
|
||||
patch('logging.config.dictConfig') as mock_dict_config, \
|
||||
patch('logging.getHandlerByName', return_value=None):
|
||||
|
||||
set_up_logging()
|
||||
|
||||
mock_dict_config.assert_called_once_with({"handlers": {"queue_handler": {"class": "logging.handlers.QueueHandler"}}})
|
||||
|
||||
# Test for load_config function
|
||||
def test_load_config_success():
|
||||
with patch('builtins.open', mock_open(read_data="key: value")), \
|
||||
patch('yaml.safe_load', return_value={"key": "value"}):
|
||||
|
||||
config = load_config()
|
||||
|
||||
assert config == {"key": "value"}
|
||||
|
||||
def test_load_config_file_not_found():
|
||||
with patch('builtins.open', side_effect=FileNotFoundError), \
|
||||
patch('logging.error') as mock_logging_error, \
|
||||
patch('sys.exit') as mock_sys_exit:
|
||||
|
||||
load_config()
|
||||
|
||||
mock_logging_error.assert_called_once_with('config.yaml file not found')
|
||||
mock_sys_exit.assert_called_once_with(ec.MISSING_CONFIG_FILE)
|
||||
|
||||
def test_load_config_yaml_error():
|
||||
with patch('builtins.open', mock_open(read_data="invalid_yaml")), \
|
||||
patch('yaml.safe_load', side_effect=yaml.YAMLError("Error")), \
|
||||
patch('logging.error') as mock_logging_error, \
|
||||
patch('sys.exit') as mock_sys_exit:
|
||||
|
||||
load_config()
|
||||
|
||||
mock_logging_error.assert_called_once()
|
||||
mock_sys_exit.assert_called_once_with(ec.CORRUPTED_CONFIG_FILE)
|
||||
@@ -0,0 +1,447 @@
|
||||
version = 1
|
||||
revision = 3
|
||||
requires-python = ">=3.13"
|
||||
resolution-markers = [
|
||||
"python_full_version >= '3.14' and sys_platform == 'win32'",
|
||||
"python_full_version >= '3.14' and sys_platform == 'emscripten'",
|
||||
"python_full_version >= '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32'",
|
||||
"python_full_version < '3.14' and sys_platform == 'win32'",
|
||||
"python_full_version < '3.14' and sys_platform == 'emscripten'",
|
||||
"python_full_version < '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32'",
|
||||
]
|
||||
|
||||
[[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.4.9"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/bd/2a/23f34ec9d04624958e137efdc394888716353190e75f25dd22c7a2c7a8aa/charset_normalizer-3.4.9.tar.gz", hash = "sha256:673611bbd43f0810bec0b0f028ddeaaa501190339cac411f347ac76917c3ae7b", size = 152439, upload-time = "2026-07-07T14:34:58.454Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/b2/06/97ec2aeae780b31d742b6352218b43841a6871e2564578ca522dce4a45c3/charset_normalizer-3.4.9-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:440eede837960000d74978f0eba527be106b5b9aee0daf779d395276ed0b0614", size = 317688, upload-time = "2026-07-07T14:33:35.408Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d0/39/8ff066c672434225f8d25f8b739f992af250944392173dcc88362681c9bf/charset_normalizer-3.4.9-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:21e764fd1e70b6a3e205a0e46f3051701f98a8cb3fad66eeb80e48bb502f8698", size = 214982, upload-time = "2026-07-07T14:33:36.996Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/92/8f/3a47a3667c83c2df9483d91644c6c107de3bf8874aa1793da9d3012eb986/charset_normalizer-3.4.9-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e4fd89cc178bced6ad29cb3e6dd4aa63fa5017c3524dbd0b25998fb64a87cc8b", size = 236460, upload-time = "2026-07-07T14:33:38.536Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f1/60/b22cdbee7e4013dab8b0d7647fc6181120fbbbc8f7025c226d15bd5a47fc/charset_normalizer-3.4.9-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:bd47ba7fc3ca94896759ea0109775132d3e7ab921fbf54038e1bab2e46c313c9", size = 232003, upload-time = "2026-07-07T14:33:40.059Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ea/f8/72eb13dcabe7257035cea8aefd922caad2f110d252bf9f67c4c2ca763aee/charset_normalizer-3.4.9-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:84fd18bcc17526fc2b3c1af7d2b9217d32c9c04448c16ec693b9b4f1985c3d33", size = 223149, upload-time = "2026-07-07T14:33:41.631Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b0/3e/faee8f9de92b14ee1198e9163252bb15efee7301b31256a3b6d9ebfdd0dd/charset_normalizer-3.4.9-cp313-cp313-manylinux_2_31_armv7l.whl", hash = "sha256:5b10cd92fc5c498b35a8635df6d5a100207f88b63a4dc1de7ef9a548e1e2cd63", size = 207901, upload-time = "2026-07-07T14:33:43.209Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3a/25/45f30093ae27dd7b92a793b61882a38685f993700113ca36e0c9c14965e1/charset_normalizer-3.4.9-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a4fbdde9dd4a9ce5fd52c2b3a347bb50cc89483ef783f1cb00d408c13f7a96c0", size = 219176, upload-time = "2026-07-07T14:33:44.725Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/48/18/c8f397329c35e32f6a837e488986f4ae03bd2abebc453b48714991630c2f/charset_normalizer-3.4.9-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:416c229f77e5ea25b3dfd4b582f8d73d7e43c22320302b9ab128a2d3a0b38efe", size = 217356, upload-time = "2026-07-07T14:33:46.192Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/86/7e/5ce0bba863470fd1902d5e5843968951bddf38abe4742fc97116ef4598b3/charset_normalizer-3.4.9-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:75286256590a6320cf106a0d28970d3560aad9ee09aa7b34fb40524792436d35", size = 209614, upload-time = "2026-07-07T14:33:47.705Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6c/ef/2473d3c4d869155be4af1191111d59c4d5c4e0173026f7e85b176e23bf65/charset_normalizer-3.4.9-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:69b157c5d3292bcd443faca052f3096f637f1e074b98212a933c074ae23dc3b8", size = 224991, upload-time = "2026-07-07T14:33:49.238Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d0/a3/53ddae3db108a088156aa8ddfafd411ebbc1340f48c5573f697b27f69a39/charset_normalizer-3.4.9-cp313-cp313-win32.whl", hash = "sha256:51307f5c71007673a2bf8232ad973483d281e74cb99c8c5a990af1eefa6277d9", size = 150622, upload-time = "2026-07-07T14:33:50.711Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e8/ef/6953a77c7cf2c2ff9998e6f575ab3e380119f100223381565a4f94c1f836/charset_normalizer-3.4.9-cp313-cp313-win_amd64.whl", hash = "sha256:fe2c7201c642b7c308f1675355ad7ff7b66acfe3541625efe5a3ad38f29d6115", size = 161947, upload-time = "2026-07-07T14:33:52.197Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6e/fb/d560d1d1555debbfe7849d9cac6145c1b537709d79576bf22557ed803b82/charset_normalizer-3.4.9-cp313-cp313-win_arm64.whl", hash = "sha256:611057cc5d5c0afc743ba8be6bd828c17e0aaa8643f9d0a9b9bb7dea80eb8012", size = 152594, upload-time = "2026-07-07T14:33:53.486Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7e/8d/496817fa0944239ecae662dd57ea765cfeaec6a735f9f025d4b7b72e7143/charset_normalizer-3.4.9-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:0327fcd59a935777d83410750c50600ee9571af2846f71ce40f25b13da1ef380", size = 317253, upload-time = "2026-07-07T14:33:54.994Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2b/f9/ef4a69ea338ad3c0deceea0f5f7d2380ae8b52132b06d652cb0d2cd86706/charset_normalizer-3.4.9-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8a79d9f4d8001473a30c163556b3c3bfebec837495a412dde78b51672f6134f9", size = 215898, upload-time = "2026-07-07T14:33:56.334Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8c/e7/5ddfd76fc061eb52de219658a4aa431cbacadf0a0219c8854f00da50d289/charset_normalizer-3.4.9-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:33bdcc2a32c0a0e861f60841a512c8acc658c87c2ac59d89e3a46dacf7d866e4", size = 236718, upload-time = "2026-07-07T14:33:57.9Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/49/ba/768fa3f36048d81c477a0ce61f813bc1454d80917ccfe550abd9f44f5e24/charset_normalizer-3.4.9-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f840ed6d8ecba8255df8c42b87fadeda98ddfc6eeec05e2dc66e26d46dd6f58a", size = 232519, upload-time = "2026-07-07T14:33:59.811Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f4/c4/b3e049d2aa3766180c78507110543d9d50894cc97f57de543f1be521dcdc/charset_normalizer-3.4.9-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c25fe15c70c59eb7c5ce8c06a1f3fa1da0ecc5ea1e7a5922c40fd2fa9b0d5046", size = 223143, upload-time = "2026-07-07T14:34:01.517Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/19/79/55c32d06d76ae4feafe053f061f3e3ab70bcf19f4007797ce8c3efda7830/charset_normalizer-3.4.9-cp314-cp314-manylinux_2_31_armv7l.whl", hash = "sha256:f7fb7d750cfa0a070d2c24e831fd3481019a60dd317ea2b39acbcebc08b6ed81", size = 206742, upload-time = "2026-07-07T14:34:03.04Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/10/e0/47c079dd82d217c807479cd59ffd30af56307ea31c108b75758970459ad3/charset_normalizer-3.4.9-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:4d1c96a7a18b9690a4d46df09e3e3382406ae3213727cd1019ebade1c4a81917", size = 219191, upload-time = "2026-07-07T14:34:04.657Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/42/ab/b9bc2e77d6b44a7e46ef62ec5cac1c9a6ba7b9135a5d560f002696ec9995/charset_normalizer-3.4.9-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:a4cfde78a9f2880208d16a93b795726a3017d5977e08d1e162a7a31322479c41", size = 218328, upload-time = "2026-07-07T14:34:06.115Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f1/78/c9c71d599f5aa2d42bcdd35cbbd46d7f535351a57e40ff7d8e5a7e219401/charset_normalizer-3.4.9-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:d4d6fcde76f94f5cb9e43e9e9a61f16dacefd228cbbf6f1a09bd9b219a92f1a1", size = 207406, upload-time = "2026-07-07T14:34:07.554Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f6/39/c914445c321a845097ce4f6ac7de9a18228a77b766272125a1ce00d851eb/charset_normalizer-3.4.9-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:898f0e9068ca27d37f8e83a5b962821df851532e6c4a7d615c1c033f9da6eedf", size = 225157, upload-time = "2026-07-07T14:34:09.061Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9b/f2/c0d4b8508565a36bc5c624e88ed297f5b0b1095011034d7f5b83a69908b5/charset_normalizer-3.4.9-cp314-cp314-win32.whl", hash = "sha256:c1c948747b03be832dceed96ca815cef7360de9aa19d37c730f8e3f6101aca48", size = 151095, upload-time = "2026-07-07T14:34:10.901Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/49/fd/a1d26144398c67486422a72bf5812cda22cb4ccfcd95a290fb41ceb4b8e2/charset_normalizer-3.4.9-cp314-cp314-win_amd64.whl", hash = "sha256:16b65ea0f2465b6fb52aa22de5eca612aa964ddfec00a912e26f4656cbef890b", size = 162796, upload-time = "2026-07-07T14:34:12.47Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/20/95/d75e82f8ce9fd323ebf059c16c9aadefb22a1ecde13b7840b35835e4886c/charset_normalizer-3.4.9-cp314-cp314-win_arm64.whl", hash = "sha256:40a126142a56b2dfc0aacbad1de8310cbf60da7656db0e6b16eebd48e3e93519", size = 153334, upload-time = "2026-07-07T14:34:14.044Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/00/5e/17398df3a139985ba9d11ed072531986f408c8fca952835ef1ab1820c02b/charset_normalizer-3.4.9-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:609b3ba8fcc0fb5ab7af00719d0fb6ad0cb518e48e7712d12fd68f1327951198", size = 338848, upload-time = "2026-07-07T14:34:15.688Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/cd/91/7253a32e86b7e1d1239b1b36ba6dd0f021a21107ab33054b53119cc083b9/charset_normalizer-3.4.9-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:51447e9aa2684679af07ca5021c3db526e0284347ebf4ffcec1154c3350cfe32", size = 223022, upload-time = "2026-07-07T14:34:17.248Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/cb/32/2e64bd2be10e89c61e57ebe6a93fd98ae88eb7ebe414b5121f22c96c69eb/charset_normalizer-3.4.9-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:cc1b0fff8ead343dae06305f954eb8468ba0ec1a97881f42489d198e4ce3c632", size = 241590, upload-time = "2026-07-07T14:34:18.813Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3d/ef/d96ec496cfea0c21db43b0ad03891308b02388d054cc902cf0e5a1ad6a88/charset_normalizer-3.4.9-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:fa36ec09ef71d158186bc79e359ff5fdd6e7996fe8ab638f00d6b93139ba4fcf", size = 239584, upload-time = "2026-07-07T14:34:20.52Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d4/ce/9af95f7876194bd7a14e3dfe4a4de2e0bff02666a3910d72beafd06cc297/charset_normalizer-3.4.9-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:df115d4d83168fdf2cae48ef1ff6d1cb4c466364e30861b37121de0f3bf1b990", size = 230224, upload-time = "2026-07-07T14:34:22.189Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/52/94/af74dde74a3996bd959c350709bfe50e297823d70a8c1cbd54b838880863/charset_normalizer-3.4.9-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:f86c6358749bd4fda175388691e3ba8c46e24c5347d0afd20f9b7edfc9faf07d", size = 212667, upload-time = "2026-07-07T14:34:23.857Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ee/f0/f1c4fe746c395922961b5916ed1d7d6e7d4c84851d19ed43cc89980ec953/charset_normalizer-3.4.9-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:32286a2c8d167e897177b673176c1e3e00d4057caf5d2b64eef9a3666b03018e", size = 227179, upload-time = "2026-07-07T14:34:25.586Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e4/56/6c745619ac397e8871e2bcd3cea1eec86b877488f33888b3aef5c3ed506e/charset_normalizer-3.4.9-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:83aed2c10721ddd90f68140685391b50811a880af20654c59af6b6c66c40513c", size = 225372, upload-time = "2026-07-07T14:34:27.212Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/78/ad/98aae8630ac71f16711968e38a5acfecce41b778bf2f0312851020f565a8/charset_normalizer-3.4.9-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:cd6c3d4b783c556fa00bf540854e42f135e2f256abd29669fcd0da0f2dec79c2", size = 215222, upload-time = "2026-07-07T14:34:28.774Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f7/40/9593d54209765207a7f11073c06494c1721e4ca4a0a426c597679bf7f91e/charset_normalizer-3.4.9-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ee2f2a527e3c1a6e6411eb4209642e138b544a2d72fe5d0d76daf77b24063534", size = 231958, upload-time = "2026-07-07T14:34:30.345Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b1/27/693ee5e8a18191eb38647360c51cd505013e2bd3b366aa43fd5344c21e3c/charset_normalizer-3.4.9-cp314-cp314t-win32.whl", hash = "sha256:0d861473f743244d349b50f850d10eb87aeb22bbdcc8e64f79273c94af5a8226", size = 155580, upload-time = "2026-07-07T14:34:31.884Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/80/3f/bd97d3d9c613013d07cb7733d299385b41df37f0471310f5a73dc359f0b8/charset_normalizer-3.4.9-cp314-cp314t-win_amd64.whl", hash = "sha256:9b8e0f3107e2200b76f6054de99016eac3ee6762713587b36baaa7e4bd2ae177", size = 167620, upload-time = "2026-07-07T14:34:33.438Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3d/c6/eee9dca4439b1061f76373f06ea855678cc4a64c1c3c90b50e479edbb8eb/charset_normalizer-3.4.9-cp314-cp314t-win_arm64.whl", hash = "sha256:19ac87f93086ce37b86e098888555c4b4bc48102279bae3350098c0ed664b501", size = 158037, upload-time = "2026-07-07T14:34:35.018Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/98/2b/f97f1c193fb855c345d678f5077d6926034db0722df74c8f057020e05a25/charset_normalizer-3.4.9-py3-none-any.whl", hash = "sha256:68e5f26a1ad57ded6d1cfb85331d1c1a195314756471d97758c48498bb4dcdf5", size = 64538, upload-time = "2026-07-07T14:34:56.993Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "colorama"
|
||||
version = "0.4.6"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "data-pipeline-for-ynab"
|
||||
version = "0.1.0"
|
||||
source = { virtual = "." }
|
||||
dependencies = [
|
||||
{ name = "duckdb" },
|
||||
{ name = "pandas" },
|
||||
{ name = "polars" },
|
||||
{ name = "pyarrow" },
|
||||
{ name = "pytest" },
|
||||
{ name = "python-dotenv" },
|
||||
{ name = "pyyaml" },
|
||||
{ name = "requests" },
|
||||
{ name = "ruff" },
|
||||
]
|
||||
|
||||
[package.metadata]
|
||||
requires-dist = [
|
||||
{ name = "duckdb", specifier = ">=1.5.5" },
|
||||
{ name = "pandas", specifier = ">=3.0.5" },
|
||||
{ name = "polars", specifier = ">=1.43.0" },
|
||||
{ name = "pyarrow", specifier = ">=25.0.0" },
|
||||
{ name = "pytest", specifier = ">=9.1.1" },
|
||||
{ name = "python-dotenv", specifier = ">=1.2.2" },
|
||||
{ name = "pyyaml", specifier = ">=6.0.3" },
|
||||
{ name = "requests", specifier = ">=2.34.2" },
|
||||
{ name = "ruff", specifier = ">=0.16.0" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "duckdb"
|
||||
version = "1.5.5"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/7d/19/e57151753576373c6696a12022648546cca6038e8833fda2908ee2342d9b/duckdb-1.5.5.tar.gz", hash = "sha256:72f33ee57ca7595b23957671a2cc7f7fe2be0ecc2d68f63abedcfcaa3a5c1238", size = 18066741, upload-time = "2026-07-22T10:55:17.819Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/47/37/4a38116e7700720fd152c666292214fd3abdf916496991296d8d1f66efbf/duckdb-1.5.5-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:cd98829b67788609017e65c761bd42a5dd0f9129441bed8bda4d6881ccf819f0", size = 32754294, upload-time = "2026-07-22T10:54:29.822Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/66/42/7d392f1ba1eee0eaf4ab4c8c7a604bfe3536cd63f979cf5c98798664f807/duckdb-1.5.5-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:feead93c56679b79592d437c62975d39cb67adedffa7592c763baf8160ac7366", size = 17368211, upload-time = "2026-07-22T10:54:33.359Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9f/a5/0a6f4fa60562faa615e55e15bd1953a2f2b17a8edd8105e5cda215e43457/duckdb-1.5.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:49c963d9469373d7aba8d750d9ea565ab823e94166efed953f184dd9b169b98c", size = 15509136, upload-time = "2026-07-22T10:54:36.369Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e4/cb/023c89f51978545b9fab318581bba0c457a58e7530d2d933e54ae7d8647c/duckdb-1.5.5-cp313-cp313-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a736217825461732b5442d05a220f3da2e23a0dae114efbf08c9bf171b53098a", size = 19392147, upload-time = "2026-07-22T10:54:39.551Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3e/c5/41bef391fb8b23dbc133c9f2ba016e7a7a8124513d2cc1b430f1897d87e4/duckdb-1.5.5-cp313-cp313-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:078e6a60dd8eedde5832f45422ca5c4a6b8c837aeabd8a56ca0b7d933f588053", size = 21511060, upload-time = "2026-07-22T10:54:42.788Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/07/9f/c44dfc1f924ac29b3252dc1b91393c01d009dbfe9f8ed33f10b986151bd1/duckdb-1.5.5-cp313-cp313-win_amd64.whl", hash = "sha256:6826504277dba513c0c5d71d828456c94d729c9d2482f94b2e289f90a9167e28", size = 13168028, upload-time = "2026-07-22T10:54:46.127Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ca/88/591384b2cd59abddd6f5dc175e60374f9abae6064429f0c4402854c10f44/duckdb-1.5.5-cp313-cp313-win_arm64.whl", hash = "sha256:baa9c5702002fabb559ded2a39008f9f421fcbc7237d388b8213eff1e08858de", size = 13989955, upload-time = "2026-07-22T10:54:49.262Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3e/56/12c65bfa2d2605b81981b264788891bcf11ec72227889554cead5d8d13b9/duckdb-1.5.5-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:8e6413dd40facb7b8ab21bd844450cd8f549b29e138635be9cf090ef4d2049e2", size = 32761946, upload-time = "2026-07-22T10:54:53.412Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b9/46/682ce155f17e0d2822d4f13ee3db9ca4b5b7c2da61b841b2629035e1f4bc/duckdb-1.5.5-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:64078acfd16541132ac6e191eb81b2845554444a0305cc1aa581ba107e514aa8", size = 17375069, upload-time = "2026-07-22T10:54:57.269Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/39/ce/a24bcbd3289c8f305a430759c5fc12242740b4af3e17f7593f3a34e333d2/duckdb-1.5.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:8c11775cc99a447618d5f1840126db17f2652f3eae05529df4f81f40e2df7151", size = 15519791, upload-time = "2026-07-22T10:55:00.681Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d9/76/3a01afbc615c1d418c0de58a6b68ac5ce2a8563232c0464bfbc2ce552398/duckdb-1.5.5-cp314-cp314-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:77bbc1e6ba12e1e06f9020117bdf848627ecfdf36f907550e62e008e6109dece", size = 19398251, upload-time = "2026-07-22T10:55:04.168Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a1/43/3a5e81d1728f4d234c79bfe385808ee7c04834f7c37a4b5c257459c25614/duckdb-1.5.5-cp314-cp314-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fbf0f2d48b43c6c304d00463b463c27ead6c4b01c3c1816b750f728decf71afe", size = 21513851, upload-time = "2026-07-22T10:55:07.864Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/91/41/fc7c829172c60ca22485251eab285f4f1a0d87b486a024c726f21471d86e/duckdb-1.5.5-cp314-cp314-win_amd64.whl", hash = "sha256:9dc826c4b50e64f6c4e4d07a3a9cb075ef70ba3899dc43ec5493dc3d7b04b353", size = 13691858, upload-time = "2026-07-22T10:55:11.181Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e1/2c/95d9216b79e9273689d7ebce125a54503ed0c9bd7da931f0265888e99779/duckdb-1.5.5-cp314-cp314-win_arm64.whl", hash = "sha256:63e48d4b74b15aeacd688976432a7225163df8c226eddeb8536bba2d4d4ff433", size = 14470180, upload-time = "2026-07-22T10:55:14.445Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "idna"
|
||||
version = "3.18"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/cd/63/9496c57188a2ee585e0f1db071d75089a11e98aa86eb99d9d7618fc1edce/idna-3.18.tar.gz", hash = "sha256:ffb385a7e039654cef1ab9ef32c6fafe283c0c0467bba1d9029738ce4a14a848", size = 196711, upload-time = "2026-06-02T14:34:07.794Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/1e/5e/d4e9f1a599fb8e573b7b87160658329fbf28d19eac2718f51fc3def3aa5a/idna-3.18-py3-none-any.whl", hash = "sha256:7f952cbe720b688055e3f87de14f5c3e5fdaa8bc3928985c4077ca689de849a2", size = 65455, upload-time = "2026-06-02T14:34:06.319Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "iniconfig"
|
||||
version = "2.3.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "numpy"
|
||||
version = "2.5.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/22/fd/89965aa4ac08c74998539fcbf24fa3540f3e15237fbeb6bcf9c908f4aade/numpy-2.5.1.tar.gz", hash = "sha256:a48a113e6afea91f5608793bafa7ef2ad481fefbda87ec5069f483de61cb9fa3", size = 20755553, upload-time = "2026-07-04T17:08:00.933Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/eb/07/ec2a3f0c91761581d4b7104a740791800025983f9a4dc4e73f91a99aeac4/numpy-2.5.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:0bfebd8695f9863592fe744be833a258120b14a9f39da255e8aa8fade2c0ddd1", size = 16796419, upload-time = "2026-07-04T17:06:40.37Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ab/ab/ddb499fc4f8780354395face5b65c7fd107bcd6e1d667a5f07d046956f6f/numpy-2.5.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:30b44a6b53a7ae63c54c089a8726e5563ed302716c5b7ccc85afade40b0e7ff6", size = 11765832, upload-time = "2026-07-04T17:06:42.768Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/88/b3/3c28c558a09fc72100c646dac6d2fce8e834c471b0edca01a29996706117/numpy-2.5.1-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:6165343f81b56ef8f514f396989e529b61d9dc709b99421b07e9f3e698e2287d", size = 5325143, upload-time = "2026-07-04T17:06:45.466Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5e/0e/ce19b985bb15c596f4f05954e76cccc77c845083b3b8f938a6c68e523128/numpy-2.5.1-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:4939237038ada79308dda3204ac6462df056b5672b2e25db1149cf873668b3e1", size = 6659749, upload-time = "2026-07-04T17:06:47.288Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2e/20/1ee6614d64332a1bba6411f38e68cb79eec1b2459e20a623777c5c5492a2/numpy-2.5.1-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1c6759f538fb912fc46de0a6b1758ccf7b57bc7c7ebebc23974fdac3de8db0cd", size = 15164716, upload-time = "2026-07-04T17:06:49.494Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ed/a7/2bcd3fdbb87804755c35b729bf8709d62025c5f4cfd7d5b2415997097515/numpy-2.5.1-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9726558e8db4a5bf7929a70ae50f63abda4daf0efe810e3bfbab95976f75fc1a", size = 16661440, upload-time = "2026-07-04T17:06:52.061Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fc/d7/a41e3310c886fe457d36e670bbf24fae411aca8a7b6ad92a32afd924077c/numpy-2.5.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:3935f3b419b244a02732676fa5317a9193cc596a4c0646db07e5b421229ac9f7", size = 16526305, upload-time = "2026-07-04T17:06:54.605Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/53/75/4333a9a707c1edd3a4e1a0c58eca52c0f31e55089fa80db02b5565b24df7/numpy-2.5.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:dc932a65ded7ce9013d120845a2514dcccb1a67bfc8deb8d37633762951904a6", size = 18423008, upload-time = "2026-07-04T17:06:57.54Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ee/90/e314a32b1c11a2ffe818ddad3a57b50b4b6e1b6c487192eb50cdef0415d0/numpy-2.5.1-cp313-cp313-win32.whl", hash = "sha256:4b4ff1608417eb7a59da7b967bbb798cacfe071d2caf526a24281cd562072ed9", size = 6063885, upload-time = "2026-07-04T17:07:00.14Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/10/70/800b3fca480af32df9e8ea9f3d4a0c8feb4b32d7f195d174eabbda4829ad/numpy-2.5.1-cp313-cp313-win_amd64.whl", hash = "sha256:6c3fe51bc6a16453d452997053454f309e8e0ed7b42d6b361ce4ac8c32913d74", size = 12425674, upload-time = "2026-07-04T17:07:02.387Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8b/0b/196350c122f50f6ca56846f2d71efd5e0d24b7b2e07355e019b2e2c7a11e/numpy-2.5.1-cp313-cp313-win_arm64.whl", hash = "sha256:f7feb014281029e628ba2d5a007407443b06e418b6fe451d1e2adcbc8eba0107", size = 10350256, upload-time = "2026-07-04T17:07:04.878Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/db/f4/731b6085a83faf6ca843394cbd5e217280c214399f7e8b21b9f552af0ae2/numpy-2.5.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:7c786fe9a5bbe360022e584c5a34cf6b54265c71bd7ec8ac3d8fec38968071f8", size = 16795063, upload-time = "2026-07-04T17:07:07.374Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bf/64/0e215f2048dd11a55bb989ed41b3585ef57452404e638d703a211a3e4157/numpy-2.5.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:32985c896d897419ef8da6917872d80b78ad0ea26d85b23245c7366ffde76d75", size = 11776652, upload-time = "2026-07-04T17:07:09.907Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b5/59/2b844c7a6e9deff69b404a66221e1542937734f65d5e6e39411876053862/numpy-2.5.1-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:efd736408cc97c79b9e6917338dfc8f06013b2274f992e96b1d9a81a71e2a2c2", size = 5335944, upload-time = "2026-07-04T17:07:12.227Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/86/51/9bf7cb2cabcebc9e017e4ec7e6322b378317a542c08b4cb68479c1efc716/numpy-2.5.1-cp314-cp314-macosx_14_0_x86_64.whl", hash = "sha256:ab84dc6b074fa881cae55bea94cc4f68e285181ba7f32497bf7dee6b1496165b", size = 6656266, upload-time = "2026-07-04T17:07:14.368Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/83/3e/fb7615b211b82a32f44d5180a6d421b61f84d4fadd578b48ba4ac34e189f/numpy-2.5.1-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:caf3e317d33d60c37986b452613f4ab51246d0691350c03d0cb4a898627f4a95", size = 15179720, upload-time = "2026-07-04T17:07:16.272Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/41/5f/0f992cb24560673496c5d68de61913b57166ce530ffda07c1f280e0cc464/numpy-2.5.1-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:54ad769f17bc2d833b620851989f62054fb9ab93c969d9e1dc3c8e3d56beea21", size = 16664835, upload-time = "2026-07-04T17:07:19.021Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a2/2f/97d6475ee91afe2587797d09446f9d3e475ad4cb681662d824809327b75a/numpy-2.5.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:c12afb53450fa976d4c681c50a7423729a4c51c0465ed9f32b8a9cabbc472373", size = 16539135, upload-time = "2026-07-04T17:07:22.015Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c4/5b/4db81e4ba0be7e2776b1de68c82aa862c7f8ec27e1b4927d4ae075e20678/numpy-2.5.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:e8c11c405efc5ff6816d5983c96cdfa215bab3428961243af3ff59b228490438", size = 18426684, upload-time = "2026-07-04T17:07:24.941Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1f/64/c0ba2d90724d450279a7df8f32057241070250a26a7e2b5337d77347f481/numpy-2.5.1-cp314-cp314-win32.whl", hash = "sha256:f2479a47f8d5932d1718168a681ad6e536a9df484c83cfcf9de365e164537ace", size = 6116103, upload-time = "2026-07-04T17:07:27.622Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c1/1a/837f9ed7405adcd7a40538792eb169eddd8fa5630c16a1ef49dae71a30f4/numpy-2.5.1-cp314-cp314-win_amd64.whl", hash = "sha256:24d0eb82c0541d3415a33425db64ae439dffccd7b4dbcb30e7c35120205c506a", size = 12562177, upload-time = "2026-07-04T17:07:29.887Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/22/ed/49707938b6dd0a78a9178dd93227dc89e4c11af47f5c798d70366e8d0483/numpy-2.5.1-cp314-cp314-win_arm64.whl", hash = "sha256:5a4c988b38d261deeeaad9954e3deb091ad905c94e8bb6708654ef1d97f286b0", size = 10627739, upload-time = "2026-07-04T17:07:32.568Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a6/c7/bb4b882cfe7f299cbc8b66e42e7dd78cf9d14e40f9469fc5e3db7e15b3bd/numpy-2.5.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:a33276be12fa045805f477f22482088b66bb758ffbe89a9d21457de863a32e22", size = 11894709, upload-time = "2026-07-04T17:07:34.941Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/40/3f/5af7f4a7f6224aef48017aa82bb6174c7a659d724be0c75017b7e64a55b4/numpy-2.5.1-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:f089d7b00756190aacf1f5d34bdf38c3c430ac82b4f868f8cede73380460fce7", size = 5453810, upload-time = "2026-07-04T17:07:37.495Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/20/c9/3474309bc94d634d3f9c3eddf03250ecb8c22cd948ef16fef69a77cc5d7b/numpy-2.5.1-cp314-cp314t-macosx_14_0_x86_64.whl", hash = "sha256:09e9bfd8d2cf479c7d174804fb3811c53a8e9f20a37444008606b57d6b7a826d", size = 6761189, upload-time = "2026-07-04T17:07:39.563Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/90/8a/558ae39fdd55d7e7f7fef9a84a6e964ac6b23edbd2a07e52bb084500507d/numpy-2.5.1-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e68d8dd1e7eba712948f2053a29ec86917bc70ba1358df869d9f06649ef9cf09", size = 15225039, upload-time = "2026-07-04T17:07:41.682Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/63/27/ca7392b2d030277bdf0273e7d23255b3ee57d57a7c170a6f4fb3981e1e5d/numpy-2.5.1-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:99d5095fa265a0c4152e7bb12759e14381ef5496152f1ce58f44bdf55c44beb4", size = 16701306, upload-time = "2026-07-04T17:07:44.611Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/02/42/03d53ae7996c44d4374a8262e9dc41671fd56cbb98f7d47ef85cf5da4c6b/numpy-2.5.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:ab87a91b3cc3382b8956095bd8f95e00cf679bb81554339be1a2ba404a1473c1", size = 16589955, upload-time = "2026-07-04T17:07:47.694Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7b/15/6c1784ae469640e65db111e9a34b3d0f14d91e8a38b9ce34810ced370dbb/numpy-2.5.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:224ca51130ef7da85bea2191625181cb4f337f9cb64b471f10c1a12aa8b60077", size = 18464252, upload-time = "2026-07-04T17:07:50.684Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/94/a8/f98e50356cf167df656c526c2dfeec2d7dde182f2a3da4b458a5938e2776/numpy-2.5.1-cp314-cp314t-win32.whl", hash = "sha256:6eab239876581b2b3c5a242281b6007bbdbcd1c7085d7709bb57c5929b11e6bf", size = 6263298, upload-time = "2026-07-04T17:07:53.445Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/72/ac/96ae880cdecad0b3275d9359fcec72667b49a4863c9f12942e43679dda02/numpy-2.5.1-cp314-cp314t-win_amd64.whl", hash = "sha256:83ce9c80d5b521b0d77ddcbe5447c218d247929b6cc056ca5351342accfff0af", size = 12748623, upload-time = "2026-07-04T17:07:55.384Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a1/5a/4d2b1601df3602dba7a14f3348ba9bfe94a18adb428e693df6154c293831/numpy-2.5.1-cp314-cp314t-win_arm64.whl", hash = "sha256:5a6db61f9aaa57e369905c67d852045d3c4f7126405b29d09b19dec118e9c9cb", size = 10697674, upload-time = "2026-07-04T17:07:58.506Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "packaging"
|
||||
version = "26.2"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/d7/f1/e7a6dd94a8d4a5626c03e4e99c87f241ba9e350cd9e6d75123f992427270/packaging-26.2.tar.gz", hash = "sha256:ff452ff5a3e828ce110190feff1178bb1f2ea2281fa2075aadb987c2fb221661", size = 228134, upload-time = "2026-04-24T20:15:23.917Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/df/b2/87e62e8c3e2f4b32e5fe99e0b86d576da1312593b39f47d8ceef365e95ed/packaging-26.2-py3-none-any.whl", hash = "sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e", size = 100195, upload-time = "2026-04-24T20:15:22.081Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pandas"
|
||||
version = "3.0.5"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "numpy" },
|
||||
{ name = "python-dateutil" },
|
||||
{ name = "tzdata", marker = "sys_platform == 'emscripten' or sys_platform == 'win32'" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/be/4f/5f3422a2afec5ffc46308b79e53291365a93748b498ac2e58bead0197916/pandas-3.0.5.tar.gz", hash = "sha256:dca3734d6ab7c906e6730f0788b0a1dbb9f2467731f9711f77995c8e9d62d712", size = 4658219, upload-time = "2026-07-22T22:19:28.819Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/bf/09/7b95c4a0025227d6f118c4039b423412ac6a982db02864166185d812fbc7/pandas-3.0.5-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:c1c05a767fe8e5b4fe9e1c29806829c582052eaedb9120a3da83ba3f69e24a5b", size = 10385742, upload-time = "2026-07-22T22:18:29.346Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8d/0c/dc78fd8c4da477b4b5e8ad37295af352190d21ef63a9ee1bc071753074cc/pandas-3.0.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:b86765f268b56f7e665b93bce9d5df69dee7f99e595cf8fb839483ab315942a3", size = 9932067, upload-time = "2026-07-22T22:18:31.833Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3e/71/3592c055cf44df9808550f9368ceda80ff2b224d355ef73fe251dcda1802/pandas-3.0.5-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c597ecf5616b5c420372c1d4d4c00dbbfba7398bea857dcc984347e1ea48417b", size = 10466756, upload-time = "2026-07-22T22:18:34.195Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e3/70/4363150359f95b4cb4bcbb34ca23572bb5495749a621a8f3d5a1ddfd293c/pandas-3.0.5-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4b11c36e218331d0387cbe3a0a5f75162357a1d92d57b2b08a336ff94b19b2be", size = 10938525, upload-time = "2026-07-22T22:18:36.81Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f7/d0/317e7a0c67c0e69fa905a0161409397a7dc2d46ff611f6ca4803352c042b/pandas-3.0.5-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:cf52e1f61d229496da17dc7ab54acdee627357e7008fd4fecba3d0ba2937fa58", size = 11489303, upload-time = "2026-07-22T22:18:39.287Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f1/8d/36dade89b49e4f9d5cbdbe863772581f98c0c6d78fc39ad4c557f6f2e17e/pandas-3.0.5-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:db172144bb56422bd157812f3b021eacc255451470b31e2c633c349490a1cfee", size = 11989004, upload-time = "2026-07-22T22:18:42.208Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9c/ba/18c4ec8a746e177da05a9e7a7963781d8ea195780724f854601b6ebd6b78/pandas-3.0.5-cp313-cp313-win_amd64.whl", hash = "sha256:0d298e951f23016ce4699951d044ae6418dbc91bf68cefca0f77666fcbb4e5c6", size = 9826896, upload-time = "2026-07-22T22:18:44.539Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/de/ec/28a57266b753799a87b8bc79e7887ac6fd981b8c6d2978a0b7e7b6bd708c/pandas-3.0.5-cp313-cp313-win_arm64.whl", hash = "sha256:66266d3442a5e8b3c90274c2b8b230bee42dd1c286bc822cc2f9f2c7e12b883e", size = 9094790, upload-time = "2026-07-22T22:18:47.468Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/51/2f/cf6aae281264f4463f0875bcbb15fd2bb6d291cc535187dad1732475e4a9/pandas-3.0.5-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:2f264fc46911cc8131a7322a16199bbf8e353d27c10bb211f5bd0c814324dc36", size = 10390034, upload-time = "2026-07-22T22:18:49.818Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/06/ec/5189518c7a7659c4bdcc6b1eb32c46c6f3c86b0661ffd84143d1112c7732/pandas-3.0.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:53730687fcd161883b24e10411c06d6a4c0f2275d2faf3bb2bc25deb4ba8007c", size = 9980065, upload-time = "2026-07-22T22:18:52.249Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ea/f1/598503ce8d7e3c35601e0747ba288c7864baae66380725bc12f13f884dfe/pandas-3.0.5-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:960d3ebcf249f75206899fcd2c6de53f736b7265759ced0d3e559df0b8b709b0", size = 10545532, upload-time = "2026-07-22T22:18:54.813Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fa/de/ceae2adf7034e07e9910299fe412e1819c4f0dd520700a888bcb03625448/pandas-3.0.5-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9e94c2c5ca43bd3ca32bf64d32308887b65e5f9bfd8023ea52755107a999f93b", size = 10963120, upload-time = "2026-07-22T22:18:57.42Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/66/25/86e0f4451874eb79e688deeebe3c451fec4557f8952005818d800ee8ac7e/pandas-3.0.5-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:e819dd5f62966b481a8cb649d3299ebd886a1ea91ed5a99bf7ce77c98d18ab94", size = 11563178, upload-time = "2026-07-22T22:18:59.729Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f3/45/8643daa3b4147e433adfcccefdd0380d3aad79d86b15d8999730fe1944d5/pandas-3.0.5-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:3c5ed2e7c06e91d340dfd091d7934f9bc82e4a36b95f647f090b9d1c9ac649da", size = 12028708, upload-time = "2026-07-22T22:19:02.164Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/96/58/ad979ae617615576e8aafd569c9d4b62f1191d896e38f51d66ba06f3b89a/pandas-3.0.5-cp314-cp314-win_amd64.whl", hash = "sha256:cd8f7c6dc98527058ee6264219343f5392240a6f1bfa654fc5d79023020d0c92", size = 9951806, upload-time = "2026-07-22T22:19:04.596Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/69/32/7ac03886b304049a9d2625ee88f59af760d8a93bd30ed9239bce7b9869a8/pandas-3.0.5-cp314-cp314-win_arm64.whl", hash = "sha256:5183427f5a8156d480f30333777bc978be93650a49a7c01db26adffe95b31e85", size = 9238297, upload-time = "2026-07-22T22:19:06.836Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/be/ed/1d1f2ee5547d5167face2376d11c8b2a4c7bfff5a416ee7a9046891fab1e/pandas-3.0.5-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:303da736987d481074ca720ada325f8bd80c64ebc2d45ed79b29df3aaa4a26ca", size = 10849690, upload-time = "2026-07-22T22:19:09.391Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/57/55/17e17152e98fbb0c4b1e562bc65387a2f20a80db0f4a86bf8d3a0e4248d4/pandas-3.0.5-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:3b2801bbb049d0136f6c213eae02b5fca969384fc2064dd728d8620552aa49da", size = 10509945, upload-time = "2026-07-22T22:19:11.773Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/88/90/817d44dbf83facf9556f33576d9af0a241981e7bb5c00606c0bcb5df8dda/pandas-3.0.5-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:cce3a9d11d2b1f82c69a27ec1f4948a170e2c403c4bbfa8cca62e3fdebe2ef3a", size = 10392197, upload-time = "2026-07-22T22:19:14.024Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f1/da/889f00c0a6f5aa1545add70abbf01502dff87ab577adb855bd631c54d2f2/pandas-3.0.5-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ef01af4d8dc6cd2c8d6c7736f149574ef93fe043811eeb5e445f2647154b5040", size = 10862726, upload-time = "2026-07-22T22:19:16.351Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bc/98/f1e934fb3c98fce859c6147c6785816c7b5b9ab7821115c5d8c4de9842b9/pandas-3.0.5-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e2759e890db96dfcffdbd9b86c3c2cb6afaf58def482820317e06163ec1066cd", size = 11414864, upload-time = "2026-07-22T22:19:18.981Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fe/be/d448af7d657d82e1888dd8551f79c6d6fb161080b5b9752d84d910ec2319/pandas-3.0.5-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:b58b1b39d46a5862e3fb18f50d1a201398619d16a0f9f73f57eea5583cf0e63c", size = 11925105, upload-time = "2026-07-22T22:19:21.515Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/29/c1/ccb4238212c8c4f496c584f3044d94e0c030ed8e1d68999db46c91c2242f/pandas-3.0.5-cp314-cp314t-win_amd64.whl", hash = "sha256:1c10461f6eeb35d8f05b6184c65c8b9991663b66c46b1d559b682cb34ae7c6ea", size = 10387612, upload-time = "2026-07-22T22:19:24.257Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d2/cf/6a51b2c38980e04c279fd2fa908a1b0982064e860444acfca4ec2e2c8359/pandas-3.0.5-cp314-cp314t-win_arm64.whl", hash = "sha256:3c5015fd1730fbf883647e88068176c839c102cea883ba1769a6f4593bfc1f8c", size = 9509776, upload-time = "2026-07-22T22:19:26.694Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pluggy"
|
||||
version = "1.6.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "polars"
|
||||
version = "1.43.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "polars-runtime-32" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/66/5b/5d0f0aa53c6e9a8ecbc99ff502edcf9584e5d08ab34ea407c086999103d5/polars-1.43.0.tar.gz", hash = "sha256:bb2c67553e4968c18dfe268a88ff9a5790d5c2e0b7ea7efe97640b9a90438c88", size = 749537, upload-time = "2026-07-21T04:30:25.966Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/76/28/a8eac2c1d1b2d2a4ba2eb745921616d863185d94b1afd91cbb07af9ef21a/polars-1.43.0-py3-none-any.whl", hash = "sha256:c49078b14e2d6b8ff5cc5b78b6d9638603ea5dffafb889d9204818822f55b813", size = 846493, upload-time = "2026-07-21T04:29:06.68Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "polars-runtime-32"
|
||||
version = "1.43.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/86/96/7e714cad082e9e6aaebb8886fcb1b0220d5c35149d4c8d3466bd7e7d581e/polars_runtime_32-1.43.0.tar.gz", hash = "sha256:5fb47a3a883402e62eab2fde5922f78c531d037aeece3640c15225f39228621e", size = 3090044, upload-time = "2026-07-21T04:30:27.185Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/0f/14/9b1f5eb1c5104ba1ceb380a7308ffcd979f3ce1df86e76293062698f2ff1/polars_runtime_32-1.43.0-cp310-abi3-macosx_10_12_x86_64.whl", hash = "sha256:6707193d30a7135bce0424304f76d8145270527444097548e794ad8d26823b70", size = 53059463, upload-time = "2026-07-21T04:29:09.194Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ca/14/73d77d1c0c928eb599d9516d874af0cd2b6225201e1327a6c4857e6776d0/polars_runtime_32-1.43.0-cp310-abi3-macosx_11_0_arm64.whl", hash = "sha256:78ca2f97740b2a6beb36eb112749280b5e08750c60f53c08d2feffebdba9d35a", size = 47499586, upload-time = "2026-07-21T04:29:13.229Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/80/b1/98278fa796f93d0975fd3fe1d4ab4031707d4a9f1da44c21c29996b62c73/polars_runtime_32-1.43.0-cp310-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4ffa99bb2c7ee0a9392ae50b350af0ed17acf0519d15c75fe223021798566174", size = 51326702, upload-time = "2026-07-21T04:29:16.084Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ab/7d/24ae73389aac03296925973c4e2cbe2e4982e859b9fad69eb1a72b9026fa/polars_runtime_32-1.43.0-cp310-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3ecc8feaf04de5989a29245885921db612ef1ce9065e5cb6ec37495acfa55bba", size = 57266705, upload-time = "2026-07-21T04:29:19.288Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5c/49/2026be1f7b51242ad62e08b20728462558e161fb84b564e5b986f2b38664/polars_runtime_32-1.43.0-cp310-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:01e1471a5ee161a969c7a96991d8e5d20b97a0b7fe075df9c7a01f52a49c5ac2", size = 51484129, upload-time = "2026-07-21T04:29:22.639Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/db/e2/047c7695f08a9b18614c0e1ec5e70a754455542a21a6d52955f7f05c6268/polars_runtime_32-1.43.0-cp310-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:9cd8b813afe67d59e87027dedcaf5c6a06fe602472fd74e1a49f4bafea47259c", size = 55169401, upload-time = "2026-07-21T04:29:25.902Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/92/dc/bfd2533c487563c7a21ab7d7af3d78f820b0236e14dc5ee63d46188cd275/polars_runtime_32-1.43.0-cp310-abi3-win_amd64.whl", hash = "sha256:41a75fb3cb4cc574eb21801383578f75cfc374597c22322ef457ab7bac8a3301", size = 52541527, upload-time = "2026-07-21T04:29:29.162Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/46/d7/5c47f1bf57479d1671669af9c421f9abf4986b2ddaa64c177244ff811de0/polars_runtime_32-1.43.0-cp310-abi3-win_arm64.whl", hash = "sha256:c285e598dd91e08560e519275b8b8108adbafb438d218a175ebe073dbc2027fb", size = 46552281, upload-time = "2026-07-21T04:29:32.224Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pyarrow"
|
||||
version = "25.0.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/27/f3/95428098d1fa7d04432fb750eed06b41304c2f6a5d3319985e64db2d9d41/pyarrow-25.0.0.tar.gz", hash = "sha256:d2d697008b5ec06d75952ef260c2e9a8a0f6ccfce24266c04c9c8ade927cb3b4", size = 1199181, upload-time = "2026-07-10T08:29:50.116Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/6c/c8/098ce17d778fd9d29e40bb8c5f19a40cc90c3f0b46c9057b0d7993f42f54/pyarrow-25.0.0-cp313-cp313-macosx_12_0_arm64.whl", hash = "sha256:8831a3ba52fa7cdb78d368d968b1dcd06171e6dff5461e16d90de91d371e47bc", size = 35844549, upload-time = "2026-07-10T08:27:37.956Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bc/66/24c28877219abf6263d909b1592c97ff82c59f13a59acbed11fc87c0654f/pyarrow-25.0.0-cp313-cp313-macosx_12_0_x86_64.whl", hash = "sha256:5f4bacb60f91dd2fca6c52f1b9a0012cd090e0294f1f781dc1881a247a352f8e", size = 37610397, upload-time = "2026-07-10T08:27:43.803Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/53/55/6d1d5f5aff317ec5de9421594679ed51ed828fe7e2ce209327f819d801e4/pyarrow-25.0.0-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:59516c822d5fd8e544aaa0dfe72f36fed5d4c24ea8390aab1bcd31d7e959c6be", size = 46841701, upload-time = "2026-07-10T08:27:49.741Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b5/5d/f790fb6965ab54c9da0dda7856abc75fd0d7648d865f8d603c111d203a64/pyarrow-25.0.0-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:6f9dbd83e91c239a1f5ee7ce13f108b5f6c0efbe40a4375260d8f08b43ad05e9", size = 50090118, upload-time = "2026-07-10T08:27:56.051Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0c/8c/faf025357ebf31bc96777f234277aa31e2aeca6dd4ecaa391f29085473c2/pyarrow-25.0.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:18dcc8cc50b5e72eae6fcbfc6c8776c21a007176b27a3cdec5c2f5bcf126708d", size = 49945559, upload-time = "2026-07-10T08:28:01.927Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/07/a1/bd051871708ea99a5e0fc711926c26c6f2c6d0130c7aaac8093e34998af6/pyarrow-25.0.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4ec1895a87aa834c3b99b7a1e758747eb8bb57f922b32c0e0fa04afb8d6998b1", size = 53114238, upload-time = "2026-07-10T08:28:08.594Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7c/31/737f0c3cffcd6af647849477d1dd68045deac2e3963c3f9f211bedc48540/pyarrow-25.0.0-cp313-cp313-win_amd64.whl", hash = "sha256:77c8d1ae46a44b4006e8db1cc977bbcc6ce4873c92f74137d68e45503b97fb18", size = 27861162, upload-time = "2026-07-10T08:28:12.975Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/55/c7/581ccbcdb3d897eb2893328d68db3d52eca373bf2a7e964d0a6276b8e85b/pyarrow-25.0.0-cp314-cp314-macosx_12_0_arm64.whl", hash = "sha256:72132b9a8a0a1840197794d4dea26080069b6b0981c116bc078762dc9691b21b", size = 35878945, upload-time = "2026-07-10T08:28:18.222Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/64/d1/ccb01db7329ea0411ef4fbd9b62a04d3268b36777d4e758d5e39b91ddeab/pyarrow-25.0.0-cp314-cp314-macosx_12_0_x86_64.whl", hash = "sha256:e009ef945e498dca2f050ea10d2e9764cb44017254826fc4574fdb8d2530173b", size = 37630854, upload-time = "2026-07-10T08:28:23.452Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/af/9f/2d81ba89d1e4198d0cb25fe7529de936830fdaec0db926bb52a1ef7080d4/pyarrow-25.0.0-cp314-cp314-manylinux_2_28_aarch64.whl", hash = "sha256:f57a39dbcb416345401c2e77a4373669b45fd111a1768e6cf267a7a0607ff0ec", size = 46905617, upload-time = "2026-07-10T08:28:29.376Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6a/29/0ed312ec800fb536f93783215126cee4b8977dcfeccba6f0f44df0cc87d7/pyarrow-25.0.0-cp314-cp314-manylinux_2_28_x86_64.whl", hash = "sha256:447df764beb07c544f0178a5f6b70ef44b9ecf382b3cdfad4c2d7867353c3887", size = 50119765, upload-time = "2026-07-10T08:28:35.826Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ca/88/cab5063ba0c4d46a9f6b4b7eb1c9029dc0302d65cd5ab3510c949a386568/pyarrow-25.0.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ac5dfeee59f9ceb4d45ba76e83b026c38c24334135bb329d8274baa49cec3c62", size = 50027563, upload-time = "2026-07-10T08:28:43.848Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7b/fb/4d24f1b7fe2e042dc4ef315ef75e4e702d8e46fe10c37e63caff00502b03/pyarrow-25.0.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:f0f100dacf2c0f400601664a79d1a907ced4740514bb2b00917341038e2ce76f", size = 53162437, upload-time = "2026-07-10T08:28:52.819Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fa/65/da20806de93ca6ee91e72cb6a9b08b3ac890b46efc8d94a7326c651c4c81/pyarrow-25.0.0-cp314-cp314-win_amd64.whl", hash = "sha256:2e093efbecb5317372f819228fa4b4e6157eee48d3f0a7b0303705ebf81a7104", size = 28613262, upload-time = "2026-07-10T08:29:47.544Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/86/9f/c632afb1d3ef4a7814cee236718235f3a47eac46e97eb87df40f550b6b48/pyarrow-25.0.0-cp314-cp314t-macosx_12_0_arm64.whl", hash = "sha256:26be35b80780d2d21f4bae3d568b1666337c3a89722cc1794c956a77017cb24e", size = 36120702, upload-time = "2026-07-10T08:28:59.577Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/36/0a/093d53a0e72ad06e45d6443e00651bbc2d21af4211295086cbf4d873d3b9/pyarrow-25.0.0-cp314-cp314t-macosx_12_0_x86_64.whl", hash = "sha256:6f4812bfbf11ca7d8faf59eb8fff8bf4dd25ce3a38b62baa010cc17a0926d1b2", size = 37750674, upload-time = "2026-07-10T08:29:06.916Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8a/18/b37fc31a69cff4bdfb8842683def5612f551b93fff6f44375e4a4a6a5535/pyarrow-25.0.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:b8af8ceedf0c9c160fd2b63440f2d205b9404db85866c1217bfea601de7cfb50", size = 46912304, upload-time = "2026-07-10T08:29:14.656Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/32/35/5cae19ba72493e5598022468b56f6a5571f399f485bf412f157356476caa/pyarrow-25.0.0-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:c70a5fd9a82bd1a702fd482bdc62d38dcb672fb2b449b1d7c0d7d1f4be7b7bfe", size = 50073652, upload-time = "2026-07-10T08:29:22.467Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2e/a5/ddd508424bdfd5e6945765e9e2ffc687e2f6115972badc8ecf423076c407/pyarrow-25.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:0490a7f8b38ffe11cc26526b50c65d111cb54ddac3717cec781806793f1244dc", size = 50058654, upload-time = "2026-07-10T08:29:29.689Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5f/a4/324d0db203ff5eebe8694ec2d6ec5a23f9aaa5d02e5b8c692914c518c33c/pyarrow-25.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:e83916bbcf380866b4e14255850b33323ff678dc9758411d0409cdd2523880b0", size = 53140153, upload-time = "2026-07-10T08:29:36.041Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bd/8d/d236e9c82fe315f9128885c8be3ec719f41965a1eb6b6f4b42470904cd41/pyarrow-25.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:13240f0d3dc5932ccd0bfa90cd76d835680b9d94a7661c635df4b703d40ce849", size = 28743657, upload-time = "2026-07-10T08:29:42.742Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pygments"
|
||||
version = "2.20.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/c3/b2/bc9c9196916376152d655522fdcebac55e66de6603a76a02bca1b6414f6c/pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f", size = 4955991, upload-time = "2026-03-29T13:29:33.898Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pytest"
|
||||
version = "9.1.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "colorama", marker = "sys_platform == 'win32'" },
|
||||
{ name = "iniconfig" },
|
||||
{ name = "packaging" },
|
||||
{ name = "pluggy" },
|
||||
{ name = "pygments" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/e4/47/b9efed96c114afcfa3c9d3fe98a76a1d14c74a9e266d397cf6eb64be5e01/pytest-9.1.1.tar.gz", hash = "sha256:1088fbde8f2b49d95a549a195707afa7a76a3ce9bcadc26b6d71f0ffda5fe313", size = 1636369, upload-time = "2026-06-19T10:58:32.857Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/24/25/1de2678b631f5a49215c6c96fff41ba892b0a34df68d6d80292b1b48aa7f/pytest-9.1.1-py3-none-any.whl", hash = "sha256:37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c", size = 386536, upload-time = "2026-06-19T10:58:31.347Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "python-dateutil"
|
||||
version = "2.9.0.post0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "six" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/66/c0/0c8b6ad9f17a802ee498c46e004a0eb49bc148f2fd230864601a86dcf6db/python-dateutil-2.9.0.post0.tar.gz", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3", size = 342432, upload-time = "2024-03-01T18:36:20.211Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/ec/57/56b9bcc3c9c6a792fcbaf139543cee77261f3651ca9da0c93f5c1221264b/python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427", size = 229892, upload-time = "2024-03-01T18:36:18.57Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "python-dotenv"
|
||||
version = "1.2.2"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/82/ed/0301aeeac3e5353ef3d94b6ec08bbcabd04a72018415dcb29e588514bba8/python_dotenv-1.2.2.tar.gz", hash = "sha256:2c371a91fbd7ba082c2c1dc1f8bf89ca22564a087c2c287cd9b662adde799cf3", size = 50135, upload-time = "2026-03-01T16:00:26.196Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/0b/d7/1959b9648791274998a9c3526f6d0ec8fd2233e4d4acce81bbae76b44b2a/python_dotenv-1.2.2-py3-none-any.whl", hash = "sha256:1d8214789a24de455a8b8bd8ae6fe3c6b69a5e3d64aa8a8e5d68e694bbcb285a", size = 22101, upload-time = "2026-03-01T16:00:25.09Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pyyaml"
|
||||
version = "6.0.3"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960, upload-time = "2025-09-25T21:33:16.546Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/d1/11/0fd08f8192109f7169db964b5707a2f1e8b745d4e239b784a5a1dd80d1db/pyyaml-6.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8", size = 181669, upload-time = "2025-09-25T21:32:23.673Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b1/16/95309993f1d3748cd644e02e38b75d50cbc0d9561d21f390a76242ce073f/pyyaml-6.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1", size = 173252, upload-time = "2025-09-25T21:32:25.149Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/50/31/b20f376d3f810b9b2371e72ef5adb33879b25edb7a6d072cb7ca0c486398/pyyaml-6.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c", size = 767081, upload-time = "2025-09-25T21:32:26.575Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/49/1e/a55ca81e949270d5d4432fbbd19dfea5321eda7c41a849d443dc92fd1ff7/pyyaml-6.0.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5", size = 841159, upload-time = "2025-09-25T21:32:27.727Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/74/27/e5b8f34d02d9995b80abcef563ea1f8b56d20134d8f4e5e81733b1feceb2/pyyaml-6.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6", size = 801626, upload-time = "2025-09-25T21:32:28.878Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f9/11/ba845c23988798f40e52ba45f34849aa8a1f2d4af4b798588010792ebad6/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6", size = 753613, upload-time = "2025-09-25T21:32:30.178Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3d/e0/7966e1a7bfc0a45bf0a7fb6b98ea03fc9b8d84fa7f2229e9659680b69ee3/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be", size = 794115, upload-time = "2025-09-25T21:32:31.353Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/de/94/980b50a6531b3019e45ddeada0626d45fa85cbe22300844a7983285bed3b/pyyaml-6.0.3-cp313-cp313-win32.whl", hash = "sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26", size = 137427, upload-time = "2025-09-25T21:32:32.58Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/97/c9/39d5b874e8b28845e4ec2202b5da735d0199dbe5b8fb85f91398814a9a46/pyyaml-6.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c", size = 154090, upload-time = "2025-09-25T21:32:33.659Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/73/e8/2bdf3ca2090f68bb3d75b44da7bbc71843b19c9f2b9cb9b0f4ab7a5a4329/pyyaml-6.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb", size = 140246, upload-time = "2025-09-25T21:32:34.663Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9d/8c/f4bd7f6465179953d3ac9bc44ac1a8a3e6122cf8ada906b4f96c60172d43/pyyaml-6.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac", size = 181814, upload-time = "2025-09-25T21:32:35.712Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bd/9c/4d95bb87eb2063d20db7b60faa3840c1b18025517ae857371c4dd55a6b3a/pyyaml-6.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310", size = 173809, upload-time = "2025-09-25T21:32:36.789Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/92/b5/47e807c2623074914e29dabd16cbbdd4bf5e9b2db9f8090fa64411fc5382/pyyaml-6.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7", size = 766454, upload-time = "2025-09-25T21:32:37.966Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/02/9e/e5e9b168be58564121efb3de6859c452fccde0ab093d8438905899a3a483/pyyaml-6.0.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788", size = 836355, upload-time = "2025-09-25T21:32:39.178Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/88/f9/16491d7ed2a919954993e48aa941b200f38040928474c9e85ea9e64222c3/pyyaml-6.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5", size = 794175, upload-time = "2025-09-25T21:32:40.865Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/dd/3f/5989debef34dc6397317802b527dbbafb2b4760878a53d4166579111411e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764", size = 755228, upload-time = "2025-09-25T21:32:42.084Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d7/ce/af88a49043cd2e265be63d083fc75b27b6ed062f5f9fd6cdc223ad62f03e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35", size = 789194, upload-time = "2025-09-25T21:32:43.362Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/23/20/bb6982b26a40bb43951265ba29d4c246ef0ff59c9fdcdf0ed04e0687de4d/pyyaml-6.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac", size = 156429, upload-time = "2025-09-25T21:32:57.844Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f4/f4/a4541072bb9422c8a883ab55255f918fa378ecf083f5b85e87fc2b4eda1b/pyyaml-6.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3", size = 143912, upload-time = "2025-09-25T21:32:59.247Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7c/f9/07dd09ae774e4616edf6cda684ee78f97777bdd15847253637a6f052a62f/pyyaml-6.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3", size = 189108, upload-time = "2025-09-25T21:32:44.377Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4e/78/8d08c9fb7ce09ad8c38ad533c1191cf27f7ae1effe5bb9400a46d9437fcf/pyyaml-6.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba", size = 183641, upload-time = "2025-09-25T21:32:45.407Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7b/5b/3babb19104a46945cf816d047db2788bcaf8c94527a805610b0289a01c6b/pyyaml-6.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c", size = 831901, upload-time = "2025-09-25T21:32:48.83Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8b/cc/dff0684d8dc44da4d22a13f35f073d558c268780ce3c6ba1b87055bb0b87/pyyaml-6.0.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702", size = 861132, upload-time = "2025-09-25T21:32:50.149Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b1/5e/f77dc6b9036943e285ba76b49e118d9ea929885becb0a29ba8a7c75e29fe/pyyaml-6.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c", size = 839261, upload-time = "2025-09-25T21:32:51.808Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ce/88/a9db1376aa2a228197c58b37302f284b5617f56a5d959fd1763fb1675ce6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065", size = 805272, upload-time = "2025-09-25T21:32:52.941Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/da/92/1446574745d74df0c92e6aa4a7b0b3130706a4142b2d1a5869f2eaa423c6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65", size = 829923, upload-time = "2025-09-25T21:32:54.537Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f0/7a/1c7270340330e575b92f397352af856a8c06f230aa3e76f86b39d01b416a/pyyaml-6.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9", size = 174062, upload-time = "2025-09-25T21:32:55.767Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" },
|
||||
]
|
||||
|
||||
[[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 = "ruff"
|
||||
version = "0.16.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/4d/94/1e5e4967626faf12fa56999cd6222dff6992ceb086ad7945756baf70c7a7/ruff-0.16.0.tar.gz", hash = "sha256:e460aafd5495ec89efaa6ced2e4a9a581116451e1c88b9d37ef497e0f8e93982", size = 4790557, upload-time = "2026-07-23T19:11:30.981Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/4b/81/1c8818fee7ce1a04cd7d1b3172e0a8f8e4f1dc4feb7fc390e16daa8af323/ruff-0.16.0-py3-none-linux_armv6l.whl", hash = "sha256:e5115729eb08c585e5121978ba5d5b60caeae394ce21b9fb5e6cd33a1c6c9b1e", size = 10754633, upload-time = "2026-07-23T19:10:46.415Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/23/df/beaf59c09d68db84304d555f188b276a77132a5d5b0b67a5c762aa143628/ruff-0.16.0-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:3c954b1d580bfa035b41654f7858cc7e71d5fc3ac5b723dd62bd9133830ed522", size = 10969164, upload-time = "2026-07-23T19:10:50.271Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/42/ce/741cd197496a1abbf51352710fd15ed995d2a2be87189c1da26a450d6e83/ruff-0.16.0-py3-none-macosx_11_0_arm64.whl", hash = "sha256:e01c21d10eb1b29f47b7454e1f4056db9a3f0260c646aa88457c610291db9f81", size = 10488846, upload-time = "2026-07-23T19:10:52.639Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/52/2a/a2db8e88cade358f5cdcb05674a917751074109315d014eb6352d9a893f7/ruff-0.16.0-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6e364e5ed22ed8dc05082fd78e35308618260907ac2d3c1d637b2e682415b6c9", size = 10889729, upload-time = "2026-07-23T19:10:54.89Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/42/65/62a771694ebd63029dc953e27dbad40e1588bd4860ff9fe881018fddaa49/ruff-0.16.0-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:d327b8fc113a1d4421a04f3839d3752057c8dd1ee320223a6f3f52d04ada462a", size = 10568275, upload-time = "2026-07-23T19:10:56.993Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3f/e2/ced249fe8af5f086c5c58cc21cc3356d50f32f7401c5df87050c999620a7/ruff-0.16.0-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a9b50c55e263103586b3dcf5f73d479eb8cb5fdb6098fec59a62891dab653717", size = 11385112, upload-time = "2026-07-23T19:10:59.615Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/87/0b/05154977a8fd69eeb6c103271f55403bfd8711f5c0f8ed07489d95a504e7/ruff-0.16.0-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0ff4a79ce3ec0172f3241943835de1c4cb4e2dcd07f0f8c2d02603dbbbee4b17", size = 12207008, upload-time = "2026-07-23T19:11:02.154Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fb/29/98225831a3a1eab0e02f4acc6ca6559a98611dcc68b6965ff4b7234627c1/ruff-0.16.0-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e95c448fca1fb2a18372a9440926c5a6ee789639bb975c72e7ae6d0b04218ab4", size = 11650842, upload-time = "2026-07-23T19:11:04.557Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/91/66/6bd3cf90500653d55dc0ffc8507aa8300bd49d0214b2e8cb4d3fef2943ba/ruff-0.16.0-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4f11a8d11010301d0a398a2fdef67691feca7294da6aef55e2150e8fa2cd520b", size = 11400718, upload-time = "2026-07-23T19:11:09.233Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8e/a2/a54eb4eae05d66364050a5d3b8a9c5ef88196531b3cbe7109d873f87f819/ruff-0.16.0-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:48044c678e9cb8698246c99b14aaccfa6601dea7379eb48a6f8f73f7a6d86cd0", size = 11426177, upload-time = "2026-07-23T19:11:11.994Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1a/be/16e3eea4b2a478a496919f5e36f17c4559e54620bd3bbac5d6affa068006/ruff-0.16.0-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:7aa0959bad8eb8bef50340154fc9b58678dae31fa4293afa38b44b6e552c0213", size = 10856126, upload-time = "2026-07-23T19:11:14.221Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a2/84/252eb8b868a16eec7257c14f504f77537e734b2d69c762e639e588e304a3/ruff-0.16.0-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:28ea2b7df8ebf7f9da6b7d47b230ab48f387c0a29be3b474c4d0740e197bb9af", size = 10571208, upload-time = "2026-07-23T19:11:16.378Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/21/09/817a482f542f7570cbb4554b26e896610c7114f539b1d9e2d2145bf6bef6/ruff-0.16.0-py3-none-musllinux_1_2_i686.whl", hash = "sha256:33a3dfac8c35f81498dea9181bccc2f4c4bc8f1521a1dd9406e77643e0f0fb09", size = 11063329, upload-time = "2026-07-23T19:11:19.173Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2e/23/9403c180ca1cb9b1f7335f5c3e5305c09d49ea5b345196682a36028bde4a/ruff-0.16.0-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:a5237a0bda500d30d81b8e07a6973a5cbc772864cbf746ae2f4e8a2e01c9f4ed", size = 11489751, upload-time = "2026-07-23T19:11:21.74Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b2/1d/1b2ef7bcde851c78d7f17f1cca13fd6dc695fc4b3d6197941e72cae5b132/ruff-0.16.0-py3-none-win32.whl", hash = "sha256:7fab76fa065c873f41ff744347c6e77bcc3dfec4bcc754dc26b63d23c0f7f5fb", size = 10785885, upload-time = "2026-07-23T19:11:23.947Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b2/a3/d5e4ef7a56be3f928ffb90b94c25ba7d3cb9c7fe0736aeaaedf361770712/ruff-0.16.0-py3-none-win_amd64.whl", hash = "sha256:429c117f022bf481fabd9d551e7a3952b24c65e6ef44337ea09d90bebef14472", size = 11923141, upload-time = "2026-07-23T19:11:26.409Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/cb/9a/8415f2657cbe200f41a4531ccededf135505a92d4a012229121f885b26f9/ruff-0.16.0-py3-none-win_arm64.whl", hash = "sha256:14296fedcd2705c77ab8235439278bbb38f285cf7da5528b00b3e330c3d4872d", size = 11273407, upload-time = "2026-07-23T19:11:28.705Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "six"
|
||||
version = "1.17.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/94/e7/b2c673351809dca68a0e064b6af791aa332cf192da575fd474ed7d6f16a2/six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81", size = 34031, upload-time = "2024-12-04T17:35:28.174Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050, upload-time = "2024-12-04T17:35:26.475Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tzdata"
|
||||
version = "2026.3"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/92/ff/5a28bdfd8c3ebec42564ac7d0e54ca3db65044a9314a97f9564fa7a1e926/tzdata-2026.3.tar.gz", hash = "sha256:4a1518b8993086a7982523e071643f3c0e5f213e75b21318e78bcabfff9d1415", size = 198674, upload-time = "2026-07-10T08:50:37.887Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/e5/6d/b53b99a9f2766d095985947a5782f1702cabb129a34f7a802d7197af832f/tzdata-2026.3-py2.py3-none-any.whl", hash = "sha256:dc096730c87af6cab1b171c9d532be840741ff5d459015e7f6947bd7d7e54931", size = 348168, upload-time = "2026-07-10T08:50:36.46Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "urllib3"
|
||||
version = "2.7.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/53/0c/06f8b233b8fd13b9e5ee11424ef85419ba0d8ba0b3138bf360be2ff56953/urllib3-2.7.0.tar.gz", hash = "sha256:231e0ec3b63ceb14667c67be60f2f2c40a518cb38b03af60abc813da26505f4c", size = 433602, upload-time = "2026-05-07T16:13:18.596Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/7f/3e/5db95bcf282c52709639744ca2a8b149baccf648e39c8cc87553df9eae0c/urllib3-2.7.0-py3-none-any.whl", hash = "sha256:9fb4c81ebbb1ce9531cce37674bbc6f1360472bc18ca9a553ede278ef7276897", size = 131087, upload-time = "2026-05-07T16:13:17.151Z" },
|
||||
]
|
||||
Reference in New Issue
Block a user