Add ledger management service and API integration
- Introduced `LedgerService` to handle ledger file operations, queries, and snapshots. - Added API routes for journal entry retrieval, account balances, and custom queries using FastAPI. - Updated project dependencies to include `ruff` for linting, along with its configuration. - Integrated `lifespan` for managing the lifecycle of `LedgerService`.
This commit is contained in:
3
.idea/misc.xml
generated
3
.idea/misc.xml
generated
@@ -4,4 +4,7 @@
|
||||
<option name="sdkName" value="uv (ledger)" />
|
||||
</component>
|
||||
<component name="ProjectRootManager" version="2" project-jdk-name="uv (ledger)" project-jdk-type="Python SDK" />
|
||||
<component name="RuffConfiguration">
|
||||
<option name="enabled" value="true" />
|
||||
</component>
|
||||
</project>
|
||||
0
ledger.beancount
Normal file
0
ledger.beancount
Normal file
77
main.py
77
main.py
@@ -1,13 +1,78 @@
|
||||
from fastapi import FastAPI
|
||||
import datetime
|
||||
import os
|
||||
from contextlib import asynccontextmanager
|
||||
from pathlib import Path
|
||||
|
||||
app = FastAPI()
|
||||
from fastapi import FastAPI, HTTPException
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from services.ledger import LedgerService
|
||||
|
||||
|
||||
def _ledger_path() -> Path:
|
||||
env_path = os.getenv("LEDGER_FILE")
|
||||
if env_path:
|
||||
return Path(env_path)
|
||||
|
||||
return Path.cwd() / "ledger.beancount"
|
||||
|
||||
|
||||
class QueryRequest(BaseModel):
|
||||
query: str = Field(min_length=1)
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(application: FastAPI):
|
||||
ledger_path = _ledger_path()
|
||||
service = LedgerService(ledger_path)
|
||||
application.state.ledger_service = service
|
||||
|
||||
await service.start()
|
||||
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
await service.stop()
|
||||
|
||||
|
||||
app = FastAPI(lifespan=lifespan)
|
||||
|
||||
|
||||
@app.get("/")
|
||||
async def root():
|
||||
return {"message": "Hello World"}
|
||||
return {"message": "Ledger API"}
|
||||
|
||||
|
||||
@app.get("/hello/{name}")
|
||||
async def say_hello(name: str):
|
||||
return {"message": f"Hello {name}"}
|
||||
@app.get("/status")
|
||||
async def status():
|
||||
service: LedgerService = app.state.ledger_service
|
||||
snapshot = await service.snapshot()
|
||||
|
||||
return {
|
||||
"ledger_path": str(service.ledger_path),
|
||||
"last_loaded_at": snapshot.last_loaded_at.isoformat() + "Z",
|
||||
"error_count": len(snapshot.errors),
|
||||
"errors": [str(err) for err in snapshot.errors],
|
||||
}
|
||||
|
||||
|
||||
@app.get("/accounts")
|
||||
async def accounts(on_date: datetime.date | None = None):
|
||||
service: LedgerService = app.state.ledger_service
|
||||
return await service.account_balances(on_date)
|
||||
|
||||
|
||||
@app.get("/journal")
|
||||
async def journal(limit: int | None = None):
|
||||
service: LedgerService = app.state.ledger_service
|
||||
return await service.journal_entries(limit)
|
||||
|
||||
|
||||
@app.post("/query")
|
||||
async def query(payload: QueryRequest):
|
||||
service: LedgerService = app.state.ledger_service
|
||||
|
||||
try:
|
||||
return await service.run_query(payload.query)
|
||||
except Exception as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||
|
||||
@@ -7,5 +7,11 @@ dependencies = [
|
||||
"beancount>=3.2.0",
|
||||
"beanquery>=0.2.0",
|
||||
"fastapi>=0.128.0",
|
||||
"ruff>=0.14.14",
|
||||
"uvicorn>=0.40.0",
|
||||
]
|
||||
|
||||
[dependency-groups]
|
||||
dev = [
|
||||
"ruff>=0.14.14",
|
||||
]
|
||||
|
||||
0
services/__init__.py
Normal file
0
services/__init__.py
Normal file
333
services/ledger.py
Normal file
333
services/ledger.py
Normal file
@@ -0,0 +1,333 @@
|
||||
import asyncio
|
||||
import datetime
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Any, Iterable
|
||||
|
||||
from beancount import loader
|
||||
from beancount.core import amount, convert, data, inventory
|
||||
from beancount.ops import summarize
|
||||
from beanquery import query as bql
|
||||
|
||||
|
||||
def _amount_to_dict(amt: amount.Amount) -> dict[str, Any]:
|
||||
return {"number": str(amt.number), "currency": amt.currency}
|
||||
|
||||
|
||||
def _inventory_to_list(inv: inventory.Inventory) -> list[dict[str, str]]:
|
||||
units_inv = inv.reduce(convert.get_units)
|
||||
return [
|
||||
_amount_to_dict(position.units)
|
||||
for position in sorted(units_inv, key=lambda p: p.units.currency)
|
||||
]
|
||||
|
||||
|
||||
def _date_to_str(value: datetime.date | None = None) -> str | None:
|
||||
return value.isoformat() if value else None
|
||||
|
||||
|
||||
def _entry_base(entry: data.Directive) -> dict[str, Any]:
|
||||
return {
|
||||
"type": entry.__class__.__name__,
|
||||
"date": _date_to_str(entry.date),
|
||||
"meta": dict(entry.meta or {}),
|
||||
}
|
||||
|
||||
|
||||
def _posting_to_dict(posting: data.Posting) -> dict[str, Any]:
|
||||
cost = None
|
||||
|
||||
if posting.cost:
|
||||
cost = {
|
||||
"number": str(posting.cost.number),
|
||||
"currency": posting.cost.currency,
|
||||
"date": _date_to_str(posting.cost.date),
|
||||
"label": posting.cost.label,
|
||||
}
|
||||
|
||||
return {
|
||||
"account": posting.account,
|
||||
"units": _amount_to_dict(posting.units) if posting.units else None,
|
||||
"cost": cost,
|
||||
"price": _amount_to_dict(posting.price) if posting.price else None,
|
||||
"flag": posting.flag,
|
||||
"meta": dict(posting.meta or {}),
|
||||
}
|
||||
|
||||
|
||||
def entry_to_dict(entry: data.Directive) -> dict[str, Any]:
|
||||
"""
|
||||
Converts a financial entry object to its dictionary representation. This function
|
||||
handles various types of entries (e.g., Transaction, Balance, Open, Close, Note,
|
||||
Document, Price, and Event) by extracting relevant attributes and formatting them
|
||||
appropriately. The dictionary returned serves as a structured representation of the
|
||||
entry.
|
||||
|
||||
:param entry: The financial entry object to be converted. It can be an instance of
|
||||
the following types:
|
||||
- data.Transaction: Represents a financial transaction with attributes like flag,
|
||||
payee, narration, tags, links, and postings.
|
||||
- data.Balance: Represents a balance directive with attributes like account and
|
||||
amount.
|
||||
- data.Open: Represents an account opening directive with attributes such as
|
||||
account, currencies, and booking.
|
||||
- data.Close: Represents an account closing directive with attributes like account.
|
||||
- data.Note: Represents an account note with attributes like account and comment.
|
||||
- data.Document: Represents a document associated with an account, including
|
||||
account and filename attributes.
|
||||
- data.Price: Represents a price directive with attributes such as currency and
|
||||
amount.
|
||||
- data.Event: Represents an event directive with attributes like type and
|
||||
description.
|
||||
|
||||
:type entry: data.Directive
|
||||
|
||||
:return: A dictionary containing the extracted and formatted attributes of the input
|
||||
entry, structured according to its type.
|
||||
:rtype: dict[str, Any]
|
||||
"""
|
||||
base = _entry_base(entry)
|
||||
|
||||
if isinstance(entry, data.Transaction):
|
||||
base.update(
|
||||
{
|
||||
"flag": entry.flag,
|
||||
"payee": entry.payee,
|
||||
"narration": entry.narration,
|
||||
"tags": entry.tags,
|
||||
"links": entry.links,
|
||||
"postings": [_posting_to_dict(posting) for posting in entry.postings],
|
||||
}
|
||||
)
|
||||
|
||||
elif isinstance(entry, data.Balance):
|
||||
base.update({"account": entry.account, "amount": _amount_to_dict(entry.amount)})
|
||||
|
||||
elif isinstance(entry, data.Open):
|
||||
base.update(
|
||||
{
|
||||
"account": entry.account,
|
||||
"currencies": sorted(entry.currencies or []),
|
||||
"booking": str(entry.booking) if entry.booking else None,
|
||||
}
|
||||
)
|
||||
|
||||
elif isinstance(entry, data.Close):
|
||||
base.update({"account": entry.account})
|
||||
|
||||
elif isinstance(entry, data.Note):
|
||||
base.update({"account": entry.account, "comment": entry.comment})
|
||||
|
||||
elif isinstance(entry, data.Document):
|
||||
base.update({"account": entry.account, "filename": entry.filename})
|
||||
|
||||
elif isinstance(entry, data.Price):
|
||||
base.update(
|
||||
{"currency": entry.currency, "amount": _amount_to_dict(entry.amount)}
|
||||
)
|
||||
|
||||
elif isinstance(entry, data.Event):
|
||||
base.update({"type": entry.type, "description": entry.description})
|
||||
|
||||
return base
|
||||
|
||||
|
||||
def query_to_dict(
|
||||
types: Iterable[Any], rows: Iterable[Iterable[Any]]
|
||||
) -> dict[str, Any]:
|
||||
"""
|
||||
Converts the given types and rows into a dictionary with column names and their corresponding
|
||||
row data. This function takes types to determine the column names and rows to populate the
|
||||
data for each column. Conversion is applied for specific data types if identified.
|
||||
|
||||
:param types: Iterable of column type descriptors. Each descriptor could be a tuple where
|
||||
the first element represents the column name or a string representing the column name directly.
|
||||
:param rows: Iterable of rows, where each row is itself an iterable of values. Each row corresponds
|
||||
to the data for one record, with each value mapped to a corresponding column name.
|
||||
:return: A dictionary with two keys: "columns" and "rows".
|
||||
- The "columns" key maps to an iterable of column names as strings.
|
||||
- The "rows" key maps to a list of dictionaries, where each dictionary represents a row of values,
|
||||
with keys corresponding to column names.
|
||||
:rtype: dict[str, Any]
|
||||
"""
|
||||
column_names = []
|
||||
|
||||
for entry in types:
|
||||
if isinstance(entry, tuple) and entry:
|
||||
column_names.append(entry[0])
|
||||
else:
|
||||
column_names.append(str(entry))
|
||||
|
||||
result_rows = []
|
||||
for row in rows:
|
||||
row_dict = {}
|
||||
for name, value in zip(column_names, row):
|
||||
if isinstance(value, amount.Amount):
|
||||
row_dict[name] = _amount_to_dict(value)
|
||||
elif isinstance(value, inventory.Inventory):
|
||||
row_dict[name] = _inventory_to_list(value)
|
||||
elif isinstance(value, datetime.date):
|
||||
row_dict[name] = value.isoformat()
|
||||
else:
|
||||
row_dict[name] = value
|
||||
|
||||
result_rows.append(row_dict)
|
||||
|
||||
return {"columns": column_names, "rows": result_rows}
|
||||
|
||||
|
||||
@dataclass
|
||||
class LedgerSnapshot:
|
||||
entries: list[data.Directive]
|
||||
options_map: dict[str, Any]
|
||||
errors: list[data.BeancountError]
|
||||
last_loaded_at: datetime.datetime
|
||||
mtime: float
|
||||
|
||||
|
||||
class LedgerService:
|
||||
def __init__(self, ledger_path: Path, reload_interval: float = 2.0) -> None:
|
||||
self.ledger_path = ledger_path
|
||||
self.reload_interval = reload_interval
|
||||
self._lock = asyncio.Lock()
|
||||
self._snapshot: LedgerSnapshot | None = None
|
||||
self._stop_event = asyncio.Event()
|
||||
|
||||
async def start(self) -> None:
|
||||
await self.refresh(force=True)
|
||||
asyncio.create_task(self._refresh_loop())
|
||||
|
||||
async def stop(self) -> None:
|
||||
self._stop_event.set()
|
||||
|
||||
async def _refresh_loop(self) -> None:
|
||||
while not self._stop_event.is_set():
|
||||
await self.refresh(force=False)
|
||||
|
||||
try:
|
||||
await asyncio.wait_for(
|
||||
self._stop_event.wait(), timeout=self.reload_interval
|
||||
)
|
||||
except asyncio.TimeoutError:
|
||||
continue
|
||||
|
||||
async def refresh(self, force: bool = False) -> None:
|
||||
"""
|
||||
Asynchronously refreshes the ledger data by checking for updates on the file's last
|
||||
modified time and reloading the file data if necessary. A force-refresh option is
|
||||
available to reload the data irrespective of modifications to the file.
|
||||
|
||||
:param force: A flag indicating whether to force a refresh, even if no updates are
|
||||
present in the file. Defaults to False.
|
||||
:return: None
|
||||
"""
|
||||
async with self._lock:
|
||||
mtime = self._get_mtime()
|
||||
if not force and self._snapshot and mtime == self._snapshot.mtime:
|
||||
return
|
||||
|
||||
entries, errors, options_map = await asyncio.to_thread(
|
||||
loader.load_file, str(self.ledger_path)
|
||||
)
|
||||
self._snapshot = LedgerSnapshot(
|
||||
entries,
|
||||
options_map,
|
||||
errors,
|
||||
last_loaded_at=datetime.datetime.now(datetime.UTC),
|
||||
mtime=mtime,
|
||||
)
|
||||
|
||||
async def snapshot(self) -> LedgerSnapshot:
|
||||
"""
|
||||
Captures the current state of the ledger as a snapshot. The snapshot
|
||||
is created after refreshing the ledger to ensure the data is up-to-date.
|
||||
If the snapshot is not available or has not been loaded,
|
||||
an exception will be raised.
|
||||
|
||||
:raises RuntimeError: If the ledger snapshot is not loaded yet.
|
||||
:return: A ledger snapshot representing the current state.
|
||||
:rtype: LedgerSnapshot
|
||||
"""
|
||||
await self.refresh()
|
||||
|
||||
if not self._snapshot:
|
||||
raise RuntimeError("Ledger snapshot not loaded yet")
|
||||
return self._snapshot
|
||||
|
||||
async def run_query(self, query: str) -> dict[str, Any]:
|
||||
"""
|
||||
Executes a Beancount Query Language (BQL) query asynchronously and returns the results in a
|
||||
dictionary format.
|
||||
|
||||
:param query: The BQL query to be executed.
|
||||
:type query: str
|
||||
:return: A dictionary containing the query's results, with column names mapped to
|
||||
their respective values.
|
||||
:rtype: dict[str, Any]
|
||||
"""
|
||||
snapshot = await self.snapshot()
|
||||
types, rows = await asyncio.to_thread(
|
||||
bql.run_query, snapshot.entries, snapshot.options_map, query
|
||||
)
|
||||
return query_to_dict(types, rows)
|
||||
|
||||
async def account_balances(
|
||||
self, on_date: datetime.date | None = None
|
||||
) -> list[dict[str, Any]]:
|
||||
"""
|
||||
Get the account balances as of a specific date or the latest snapshot if no date is provided.
|
||||
|
||||
This method retrieves a summary of account balances, either on a specified date or
|
||||
based on the latest available snapshot. The data is organized and returned in the form
|
||||
of a list of dictionaries, each representing an account and its corresponding balance.
|
||||
|
||||
:param on_date: The date for which to compute account balances. If None, the latest
|
||||
snapshot is used for computation of balances.
|
||||
:type on_date: datetime.date or None
|
||||
:return: A list of dictionaries, where each dictionary contains an account name and its
|
||||
associated balance information.
|
||||
:rtype: list[dict[str, Any]]
|
||||
"""
|
||||
snapshot = await self.snapshot()
|
||||
balances, _ = summarize.balance_by_account(snapshot.entries, on_date)
|
||||
results = []
|
||||
|
||||
for account_name in sorted(balances.keys()):
|
||||
results.append(
|
||||
{
|
||||
"account": account_name,
|
||||
"balance": _inventory_to_list(balances[account_name]),
|
||||
}
|
||||
)
|
||||
|
||||
return results
|
||||
|
||||
async def journal_entries(self, limit: int | None = None) -> list[dict[str, Any]]:
|
||||
"""
|
||||
Fetches a list of journal entries in dictionary format, with an optional limit on the number
|
||||
of recent transactions to return. This function retrieves all journal entries from the
|
||||
snapshot, filters transactions specifically of type `Transaction`, and then converts each
|
||||
entry into a dictionary representation.
|
||||
|
||||
:param limit: An optional integer specifying the maximum number of recent transactions to
|
||||
include in the returned list. If not provided, all transactions will be returned.
|
||||
:return: A list of dictionaries where each dictionary represents a journal entry of type
|
||||
`Transaction`.
|
||||
:rtype: list[dict[str, Any]]
|
||||
"""
|
||||
snapshot = await self.snapshot()
|
||||
transactions = [
|
||||
entry for entry in snapshot.entries if isinstance(entry, data.Transaction)
|
||||
]
|
||||
|
||||
if limit:
|
||||
transactions = transactions[-limit:]
|
||||
|
||||
return [entry_to_dict(entry) for entry in transactions]
|
||||
|
||||
def _get_mtime(self) -> float:
|
||||
if not self.ledger_path.exists():
|
||||
self.ledger_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
self.ledger_path.touch()
|
||||
|
||||
return self.ledger_path.stat().st_mtime
|
||||
36
uv.lock
generated
36
uv.lock
generated
@@ -128,17 +128,27 @@ dependencies = [
|
||||
{ name = "beancount" },
|
||||
{ name = "beanquery" },
|
||||
{ name = "fastapi" },
|
||||
{ name = "ruff" },
|
||||
{ name = "uvicorn" },
|
||||
]
|
||||
|
||||
[package.dev-dependencies]
|
||||
dev = [
|
||||
{ name = "ruff" },
|
||||
]
|
||||
|
||||
[package.metadata]
|
||||
requires-dist = [
|
||||
{ name = "beancount", specifier = ">=3.2.0" },
|
||||
{ name = "beanquery", specifier = ">=0.2.0" },
|
||||
{ name = "fastapi", specifier = ">=0.128.0" },
|
||||
{ name = "ruff", specifier = ">=0.14.14" },
|
||||
{ name = "uvicorn", specifier = ">=0.40.0" },
|
||||
]
|
||||
|
||||
[package.metadata.requires-dev]
|
||||
dev = [{ name = "ruff", specifier = ">=0.14.14" }]
|
||||
|
||||
[[package]]
|
||||
name = "pydantic"
|
||||
version = "2.12.5"
|
||||
@@ -291,6 +301,32 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/95/e4/a3b9480c78cf8ee86626cb06f8d931d74d775897d44201ccb813097ae697/regex-2026.1.15-cp314-cp314t-win_arm64.whl", hash = "sha256:ca89c5e596fc05b015f27561b3793dc2fa0917ea0d7507eebb448efd35274a70", size = 274837, upload-time = "2026-01-14T23:17:23.146Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ruff"
|
||||
version = "0.14.14"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/2e/06/f71e3a86b2df0dfa2d2f72195941cd09b44f87711cb7fa5193732cb9a5fc/ruff-0.14.14.tar.gz", hash = "sha256:2d0f819c9a90205f3a867dbbd0be083bee9912e170fd7d9704cc8ae45824896b", size = 4515732, upload-time = "2026-01-22T22:30:17.527Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/d2/89/20a12e97bc6b9f9f68343952da08a8099c57237aef953a56b82711d55edd/ruff-0.14.14-py3-none-linux_armv6l.whl", hash = "sha256:7cfe36b56e8489dee8fbc777c61959f60ec0f1f11817e8f2415f429552846aed", size = 10467650, upload-time = "2026-01-22T22:30:08.578Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a3/b1/c5de3fd2d5a831fcae21beda5e3589c0ba67eec8202e992388e4b17a6040/ruff-0.14.14-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:6006a0082336e7920b9573ef8a7f52eec837add1265cc74e04ea8a4368cd704c", size = 10883245, upload-time = "2026-01-22T22:30:04.155Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b8/7c/3c1db59a10e7490f8f6f8559d1db8636cbb13dccebf18686f4e3c9d7c772/ruff-0.14.14-py3-none-macosx_11_0_arm64.whl", hash = "sha256:026c1d25996818f0bf498636686199d9bd0d9d6341c9c2c3b62e2a0198b758de", size = 10231273, upload-time = "2026-01-22T22:30:34.642Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a1/6e/5e0e0d9674be0f8581d1f5e0f0a04761203affce3232c1a1189d0e3b4dad/ruff-0.14.14-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f666445819d31210b71e0a6d1c01e24447a20b85458eea25a25fe8142210ae0e", size = 10585753, upload-time = "2026-01-22T22:30:31.781Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/23/09/754ab09f46ff1884d422dc26d59ba18b4e5d355be147721bb2518aa2a014/ruff-0.14.14-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3c0f18b922c6d2ff9a5e6c3ee16259adc513ca775bcf82c67ebab7cbd9da5bc8", size = 10286052, upload-time = "2026-01-22T22:30:24.827Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c8/cc/e71f88dd2a12afb5f50733851729d6b571a7c3a35bfdb16c3035132675a0/ruff-0.14.14-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1629e67489c2dea43e8658c3dba659edbfd87361624b4040d1df04c9740ae906", size = 11043637, upload-time = "2026-01-22T22:30:13.239Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/67/b2/397245026352494497dac935d7f00f1468c03a23a0c5db6ad8fc49ca3fb2/ruff-0.14.14-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:27493a2131ea0f899057d49d303e4292b2cae2bb57253c1ed1f256fbcd1da480", size = 12194761, upload-time = "2026-01-22T22:30:22.542Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5b/06/06ef271459f778323112c51b7587ce85230785cd64e91772034ddb88f200/ruff-0.14.14-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:01ff589aab3f5b539e35db38425da31a57521efd1e4ad1ae08fc34dbe30bd7df", size = 12005701, upload-time = "2026-01-22T22:30:20.499Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/41/d6/99364514541cf811ccc5ac44362f88df66373e9fec1b9d1c4cc830593fe7/ruff-0.14.14-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:1cc12d74eef0f29f51775f5b755913eb523546b88e2d733e1d701fe65144e89b", size = 11282455, upload-time = "2026-01-22T22:29:59.679Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ca/71/37daa46f89475f8582b7762ecd2722492df26421714a33e72ccc9a84d7a5/ruff-0.14.14-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bb8481604b7a9e75eff53772496201690ce2687067e038b3cc31aaf16aa0b974", size = 11215882, upload-time = "2026-01-22T22:29:57.032Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2c/10/a31f86169ec91c0705e618443ee74ede0bdd94da0a57b28e72db68b2dbac/ruff-0.14.14-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:14649acb1cf7b5d2d283ebd2f58d56b75836ed8c6f329664fa91cdea19e76e66", size = 11180549, upload-time = "2026-01-22T22:30:27.175Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fd/1e/c723f20536b5163adf79bdd10c5f093414293cdf567eed9bdb7b83940f3f/ruff-0.14.14-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:e8058d2145566510790eab4e2fad186002e288dec5e0d343a92fe7b0bc1b3e13", size = 10543416, upload-time = "2026-01-22T22:30:01.964Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3e/34/8a84cea7e42c2d94ba5bde1d7a4fae164d6318f13f933d92da6d7c2041ff/ruff-0.14.14-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:e651e977a79e4c758eb807f0481d673a67ffe53cfa92209781dfa3a996cf8412", size = 10285491, upload-time = "2026-01-22T22:30:29.51Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/55/ef/b7c5ea0be82518906c978e365e56a77f8de7678c8bb6651ccfbdc178c29f/ruff-0.14.14-py3-none-musllinux_1_2_i686.whl", hash = "sha256:cc8b22da8d9d6fdd844a68ae937e2a0adf9b16514e9a97cc60355e2d4b219fc3", size = 10733525, upload-time = "2026-01-22T22:30:06.499Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6a/5b/aaf1dfbcc53a2811f6cc0a1759de24e4b03e02ba8762daabd9b6bd8c59e3/ruff-0.14.14-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:16bc890fb4cc9781bb05beb5ab4cd51be9e7cb376bf1dd3580512b24eb3fda2b", size = 11315626, upload-time = "2026-01-22T22:30:36.848Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2c/aa/9f89c719c467dfaf8ad799b9bae0df494513fb21d31a6059cb5870e57e74/ruff-0.14.14-py3-none-win32.whl", hash = "sha256:b530c191970b143375b6a68e6f743800b2b786bbcf03a7965b06c4bf04568167", size = 10502442, upload-time = "2026-01-22T22:30:38.93Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/87/44/90fa543014c45560cae1fffc63ea059fb3575ee6e1cb654562197e5d16fb/ruff-0.14.14-py3-none-win_amd64.whl", hash = "sha256:3dde1435e6b6fe5b66506c1dff67a421d0b7f6488d466f651c07f4cab3bf20fd", size = 11630486, upload-time = "2026-01-22T22:30:10.852Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9e/6a/40fee331a52339926a92e17ae748827270b288a35ef4a15c9c8f2ec54715/ruff-0.14.14-py3-none-win_arm64.whl", hash = "sha256:56e6981a98b13a32236a72a8da421d7839221fa308b223b9283312312e5ac76c", size = 10920448, upload-time = "2026-01-22T22:30:15.417Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "six"
|
||||
version = "1.17.0"
|
||||
|
||||
Reference in New Issue
Block a user