Compare commits

...

9 commits
v0.1.0 ... main

13 changed files with 366 additions and 101 deletions

8
.idea/.gitignore generated vendored Normal file
View file

@ -0,0 +1,8 @@
# Default ignored files
/shelf/
/workspace.xml
# Editor-based HTTP Client requests
/httpRequests/
# Datasource local storage ignored files
/dataSources/
/dataSources.local.xml

View file

@ -0,0 +1,36 @@
<component name="InspectionProjectProfileManager">
<profile version="1.0">
<option name="myName" value="Project Default" />
<inspection_tool class="HttpUrlsUsage" enabled="true" level="WEAK WARNING" enabled_by_default="true">
<option name="ignoredUrls">
<list>
<option value="http://0.0.0.0" />
<option value="http://127.0.0.1" />
<option value="http://activemq.apache.org/schema/" />
<option value="http://cxf.apache.org/schemas/" />
<option value="http://java.sun.com/" />
<option value="http://javafx.com/fxml" />
<option value="http://javafx.com/javafx/" />
<option value="http://json-schema.org/draft" />
<option value="http://localhost" />
<option value="http://maven.apache.org/POM/" />
<option value="http://maven.apache.org/xsd/" />
<option value="http://primefaces.org/ui" />
<option value="http://schema.cloudfoundry.org/spring/" />
<option value="http://schemas.xmlsoap.org/" />
<option value="http://tiles.apache.org/" />
<option value="http://unlicense.org" />
<option value="http://www.ibm.com/webservices/xsd" />
<option value="http://www.jboss.com/xml/ns/" />
<option value="http://www.jboss.org/j2ee/schema/" />
<option value="http://www.springframework.org/schema/" />
<option value="http://www.springframework.org/security/tags" />
<option value="http://www.springframework.org/tags" />
<option value="http://www.thymeleaf.org" />
<option value="http://www.w3.org/" />
<option value="http://xmlns.jcp.org/" />
</list>
</option>
</inspection_tool>
</profile>
</component>

View file

@ -0,0 +1,6 @@
<component name="InspectionProjectProfileManager">
<settings>
<option name="USE_PROJECT_PROFILE" value="false" />
<version value="1.0" />
</settings>
</component>

12
.idea/lfcircle.iml generated Normal file
View file

@ -0,0 +1,12 @@
<?xml version="1.0" encoding="UTF-8"?>
<module type="PYTHON_MODULE" version="4">
<component name="NewModuleRootManager">
<content url="file://$MODULE_DIR$" />
<orderEntry type="jdk" jdkName="Poetry (lfcircle)" jdkType="Python SDK" />
<orderEntry type="sourceFolder" forTests="false" />
</component>
<component name="PyDocumentationSettings">
<option name="format" value="PLAIN" />
<option name="myDocStringFormat" value="Plain" />
</component>
</module>

7
.idea/misc.xml generated Normal file
View file

@ -0,0 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="Black">
<option name="sdkName" value="Poetry (lfcircle)" />
</component>
<component name="ProjectRootManager" version="2" project-jdk-name="Poetry (lfcircle)" project-jdk-type="Python SDK" />
</project>

8
.idea/modules.xml generated Normal file
View file

@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="ProjectModuleManager">
<modules>
<module fileurl="file://$PROJECT_DIR$/.idea/lfcircle.iml" filepath="$PROJECT_DIR$/.idea/lfcircle.iml" />
</modules>
</component>
</project>

10
.idea/poetry.xml generated Normal file
View file

@ -0,0 +1,10 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="PoetryConfigService">
<option name="poetryVirtualenvPaths">
<set>
<option value="$USER_HOME$/AppData/Local/pypoetry/Cache/virtualenvs/lfcircle-XN0g8hyi-py3.12/Scripts/python.exe" />
</set>
</option>
</component>
</project>

6
.idea/ruff.xml generated Normal file
View file

@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="RuffConfigService">
<option name="projectRuffExecutablePath" value="C:\Users\m\AppData\Local\pypoetry\Cache\virtualenvs\lfcircle-XN0g8hyi-py3.12\Scripts\ruff.exe" />
</component>
</project>

6
.idea/vcs.xml generated Normal file
View file

@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="VcsDirectoryMappings">
<mapping directory="" vcs="Git" />
</component>
</project>

View file

@ -31,23 +31,29 @@ For more information, please refer to <http://unlicense.org/>
"""
from argparse import ArgumentParser
from bisect import insort
from collections import Counter
from datetime import datetime, timedelta
from enum import Enum
from functools import wraps
from sys import stderr
from sys import stderr, argv
from sys import exit as sysexit
from textwrap import indent
from time import sleep
from traceback import format_exception
from typing import Callable, NamedTuple, ParamSpec, TypeVar
from typing import Callable, Final, NamedTuple, ParamSpec, TypeVar
from urllib.parse import unquote
from bs4 import BeautifulSoup
from requests import Response
from requests import get as _get
from requests import Response, get
USER_AGENT = (
"Mozilla/5.0 (compatible; lfcircle; https://github.com/markjoshwel/lfcircle)"
FORMAT_TELEGRAM_PREFIX: Final[str] = " "
USER_AGENT: Final[str] = (
"Mozilla/5.0 " "(compatible; lfcircle; https://github.com/markjoshwel/lfcircle)"
)
__version__: Final[str] = "0.2.1"
GlobalTagCounter = dict[str, Counter[str]]
class FormatTypeEnum(Enum):
@ -102,6 +108,10 @@ def handle_args() -> Behaviour:
info = __doc__.strip().split("\n", maxsplit=1)[0].split(":", maxsplit=1)
default_behaviour = Behaviour()
if "--version" in argv:
print(__version__)
sysexit(0)
parser = ArgumentParser(
prog=info[0].strip(),
description=info[-1].strip(),
@ -176,7 +186,6 @@ class Limiter:
"""helper to class to not bomb last.hq"""
max_per_second: int = 1
user_agent: str = USER_AGENT
last_call: datetime | None = None
def limit(
@ -239,6 +248,7 @@ class ListeningReport(NamedTuple):
albums_top_new: ThingWithScrobbles
tracks_top_new: ThingWithScrobbles
listening_time_hours: int
tags: dict[str, tuple[int, ...]]
def to_str(
self,
@ -249,13 +259,19 @@ class ListeningReport(NamedTuple):
leaderboard_albums_pos: int,
leaderboard_tracks_pos: int,
leaderboard_n: int,
global_tag_counter: GlobalTagCounter,
) -> str:
basket: list[str] = []
prefix: str = ""
text: str = ""
tag_counter: Counter[str]
tags: list[str]
tv: str
tc: int | float
match behaviour.format:
case FormatTypeEnum.ASCII:
basket: list[str] = []
# intro
basket.append(
(_prefix := f"{leaderboard_pos}. ")
@ -307,7 +323,7 @@ class ListeningReport(NamedTuple):
# detail 4: total period tracks count
d4_l = indent(
f"{self.artists_count} tracks".ljust(len(ls))
f"{self.tracks_count} tracks".ljust(len(ls))
+ " ("
+ f"#{leaderboard_tracks_pos}".rjust(rmax)
+ ") : ",
@ -321,6 +337,49 @@ class ListeningReport(NamedTuple):
)
basket.append(d4_l + d4_r + d4_url)
# detail 5: top five tags
if len(self.tags) > 0:
tag_counter = Counter[str]()
for tag_name, tag_values in self.tags.items():
tag_counter.update(
{tag_name: calculate_tag_score(tag_values, tags=self.tags)}
)
tags = []
for tn, _tc in tag_counter.most_common(5):
tv = ""
tc = round(_tc)
if tn in global_tag_counter:
counter = global_tag_counter[tn]
if len(counter) > 1:
_tv = sum(
[
i if (user == self.user) else 0
for i, (user, _) in enumerate(
counter.most_common(), start=1
)
]
)
tv = f": #{_tv}" if (_tv > 0) else ""
_flt_ranks = [val for _, val in counter.most_common()]
_int_ranks = [round(val) for val in _flt_ranks]
if (tc in _int_ranks) and (_int_ranks.count(tc) > 1): # type: ignore
tc = round(_tc, 2)
tags.append(f"{tn} ({tc}%{tv})")
basket.append(
indent(
("Top tags".ljust(len(d4_l) - 6) + " : " + ", ".join(tags)),
prefix=prefix,
)
)
if not behaviour.lowercase:
text = "\n".join(basket)
@ -328,8 +387,7 @@ class ListeningReport(NamedTuple):
text = "\n".join(basket[:3] + [s.lower() for s in basket[3:]])
case FormatTypeEnum.TELEGRAM:
basket: list[str] = []
prefix: str = " "
prefix = FORMAT_TELEGRAM_PREFIX
# intro
basket.append(
@ -342,7 +400,7 @@ class ListeningReport(NamedTuple):
# detail 2: total period artist count
basket.append(
f"{prefix}{self.artists_count} artists (#{leaderboard_artists_pos}): "
f"\n{prefix}{self.artists_count} artists (#{leaderboard_artists_pos}): "
+ (
f"[{self.artists[0].name}]({self.artists[0].url})"
if behaviour.all_the_links
@ -372,6 +430,44 @@ class ListeningReport(NamedTuple):
)
)
# detail 5: top tags
if len(self.tags) > 0:
tag_counter = Counter()
for tag_name, tag_values in self.tags.items():
tag_counter.update(
{tag_name: calculate_tag_score(tag_values, tags=self.tags)}
)
tags = []
for tn, _tc in tag_counter.most_common(5):
tv = ""
tc = round(_tc)
if tn in global_tag_counter:
counter = global_tag_counter[tn]
if len(counter) > 1:
_tv = sum(
[
i if (user == self.user) else 0
for i, (user, _) in enumerate(
counter.most_common(), start=1
)
]
)
tv = f": #{_tv}" if (_tv > 0) else ""
_flt_ranks = [val for _, val in counter.most_common()]
_int_ranks = [round(val) for val in _flt_ranks]
if (tc in _int_ranks) and (_int_ranks.count(tc) > 1): # type: ignore
tc = round(_tc, 2)
tags.append(f"{tn} ({tc}%{tv})")
basket.append(f"\n{prefix}Top tags" + ": " + ", ".join(tags))
if not behaviour.lowercase:
text = "\n".join(basket)
@ -395,7 +491,11 @@ def get_listening_report(
behaviour: Behaviour,
) -> ListeningReport:
target_url: str = f"https://www.last.fm/user/{target}/listening-report/week"
page_res: Response = limiter.limit(_get)(target_url)
page_res: Response = limiter.limit(get)(
target_url,
headers={"User-Agent": USER_AGENT},
)
if page_res.status_code != 200:
raise Exception(
@ -419,12 +519,30 @@ def get_listening_report(
albums_top_new=_get_albums_top_new(page),
tracks_top_new=_get_tracks_top_new(page),
listening_time_hours=_get_listening_time_hours(page),
tags=_get_tags(page),
)
def _int(number: str, failable: bool = False) -> int:
n = (
number.replace(",", "")
.replace("scrobbles", "")
.strip()
.lstrip("days,")
.lstrip("days")
.rstrip("hours")
.strip()
)
if failable and n == "":
return 0
else:
assert n.isnumeric()
return int(n)
def _get_scrobbles_count(page: BeautifulSoup) -> int:
assert (_1 := page.select_one(".report-headline-total")) is not None
return int(_1.text.strip().replace(",", ""))
return _int(_1.text)
def _get_scrobbles_daily_avg(page: BeautifulSoup) -> int:
@ -434,7 +552,7 @@ def _get_scrobbles_daily_avg(page: BeautifulSoup) -> int:
continue
assert (_1 := fact.select_one(".quick-fact-data-value")) is not None
return int(_1.text.strip().replace(",", ""))
return _int(_1.text)
else:
raise Exception(f"could not find '{needle}' fact, {len(facts)=}")
@ -447,10 +565,10 @@ def _get_listening_time_hours(page: BeautifulSoup) -> int:
continue
assert (_d1 := fact.select_one(".quick-fact-data-value")) is not None
days: int = int(_d1.text.strip().replace(",", ""))
days: int = _int(_d1.text)
assert (_h1 := fact.select_one(".quick-fact-data-detail")) is not None
hours: int = int(_h1.text.strip().lstrip("days,").rstrip("hours").strip())
hours: int = _int(_h1.text, failable=True)
return (days * 24) + hours
@ -461,7 +579,7 @@ def _get_listening_time_hours(page: BeautifulSoup) -> int:
def _get_overview_scrobbles(page: BeautifulSoup, needle: str) -> int:
assert (_1 := page.select_one(needle)) is not None
assert (_2 := _1.select_one(".top-item-overview__scrobbles")) is not None
return int(_2.text.strip().replace(",", ""))
return _int(_2.text)
def _get_artists_count(page: BeautifulSoup) -> int:
@ -494,7 +612,7 @@ def _get_top_overview(
things.append(
ThingWithScrobbles(
name=_12.text.strip(),
scrobbles=int(_13.text.strip().replace(",", "")),
scrobbles=_int(_13.text),
url=f"https://www.last.fm{_14.attrs.get('href', '/')}",
)
)
@ -514,7 +632,7 @@ def _get_top_overview(
for n, v in zip(
[x.text.strip() for x in _n],
[int(y.text.strip()) for y in _v],
[_int(y.text) for y in _v],
):
things.append(ThingWithScrobbles(name=n, scrobbles=v))
@ -583,8 +701,60 @@ def _get_tracks_top_new(page: BeautifulSoup) -> ThingWithScrobbles:
return _get_top_new_thing(page=page, select_needle=".top-new-item-type__track")
def _sorter(r: ListeningReport) -> int:
return r.listening_time_hours + r.scrobbles_count
def _get_tags(page: BeautifulSoup) -> dict[str, tuple[int, ...]]:
tags: dict[str, tuple[int, ...]] = {}
assert (_1 := page.select_one("#top-tags-over-time")) is not None
assert (_2 := _1.select_one(".js-top-tags-over-time-table")) is not None
assert (_3 := _2.select_one("tbody")) is not None
for tr in _3.select("tr"):
tag_name: str = ""
tag_counts: list[int] = []
for i, td in enumerate(tr.select("td")):
if i == 0:
tag_name = td.text.strip()
else:
assert (
num := td.text.strip()
).isnumeric(), (
"second tag table td onwards should be numeric, but isn't"
)
tag_counts.append(int(num))
tags.update({tag_name: tuple(tag_counts)})
return tags
def _rank(
r: ListeningReport, rs: list[ListeningReport], k: Callable[[ListeningReport], int]
):
ranking: list[ListeningReport] = []
for _r in rs:
insort(ranking, _r, key=k)
for i, _r in enumerate(reversed(ranking), start=1):
if _r == r:
return i
else:
return 0
def calculate_tag_score(
tag_values: tuple[int, ...], tags: dict[str, tuple[int, ...]]
) -> float:
v_cum_sum = sum([sum(tv) for tv in tags.values()])
v_cum_avg = sum([(sum(tv) / len(tv)) for tv in tags.values()])
v_avg = (v_sum := sum(tag_values)) / len(tag_values)
tag_obj_score = (0.5 * v_avg) + (0.5 * v_sum)
tag_cum_score = (0.5 * v_cum_sum) + (0.5 * v_cum_avg)
return 100 * (tag_obj_score / tag_cum_score)
def make_circle_report(
@ -603,71 +773,53 @@ def make_circle_report(
case FormatTypeEnum.TELEGRAM:
text.append(behaviour.header + "\n")
# pre-string building global tag versus calculation
global_tag_counter: GlobalTagCounter = {}
for report in listening_reports:
for tag_name, tag_value in report.tags.items():
if tag_name not in global_tag_counter:
global_tag_counter[tag_name] = Counter()
global_tag_counter[tag_name][report.user] += calculate_tag_score(
tag_value, tags=report.tags
) # type: ignore
for leaderboard_pos, report in enumerate(
reversed(sorted(listening_reports, key=_sorter)),
reversed(
sorted(
listening_reports,
key=lambda r: r.listening_time_hours + r.scrobbles_count,
)
),
start=1,
):
leaderboard_scrobble_pos: int = 0
for leaderboard_scrobble_pos, _report in enumerate(
reversed(
sorted(
listening_reports,
key=lambda r: r.scrobbles_count,
)
),
start=1,
):
if report == _report:
break
leaderboard_artists_pos: int = 0
for leaderboard_artists_pos, _report in enumerate(
reversed(
sorted(
listening_reports,
key=lambda r: r.artists_count,
)
),
start=1,
):
if report == _report:
break
leaderboard_albums_pos: int = 0
for leaderboard_albums_pos, _report in enumerate(
reversed(
sorted(
listening_reports,
key=lambda r: r.albums_count,
)
),
start=1,
):
if report == _report:
break
leaderboard_tracks_pos: int = 0
for leaderboard_tracks_pos, _report in enumerate(
reversed(
sorted(
listening_reports,
key=lambda r: r.tracks_count,
)
),
start=1,
):
if report == _report:
break
text.append(
report.to_str(
behaviour=behaviour,
leaderboard_pos=leaderboard_pos,
leaderboard_scrobble_pos=leaderboard_scrobble_pos,
leaderboard_artists_pos=leaderboard_artists_pos,
leaderboard_albums_pos=leaderboard_albums_pos,
leaderboard_tracks_pos=leaderboard_tracks_pos,
leaderboard_scrobble_pos=_rank(
r=report,
rs=listening_reports,
k=lambda r: r.scrobbles_count,
),
leaderboard_artists_pos=_rank(
r=report,
rs=listening_reports,
k=lambda r: r.artists_count,
),
leaderboard_albums_pos=_rank(
r=report,
rs=listening_reports,
k=lambda r: r.albums_count,
),
leaderboard_tracks_pos=_rank(
r=report,
rs=listening_reports,
k=lambda r: r.tracks_count,
),
leaderboard_n=len(listening_reports),
global_tag_counter=global_tag_counter,
)
+ "\n"
)
@ -681,7 +833,7 @@ def cli() -> None:
reports: list[ListeningReport] = []
print(behaviour, file=stderr) if behaviour.verbose else ...
for i, target in enumerate(behaviour.targets):
for i, target in enumerate(set(behaviour.targets)):
try:
reports.append(
get_listening_report(
@ -702,12 +854,12 @@ def cli() -> None:
else:
print(
f"{i + 1}/{len(behaviour.targets)}",
f"got {target}'s reports... ({i + 1}/{len(behaviour.targets)})",
file=stderr,
end="\r",
)
print(reports[-1], file=stderr) if behaviour.verbose else ...
print(file=stderr)
print(make_circle_report(listening_reports=reports, behaviour=behaviour))

34
poetry.lock generated
View file

@ -1,4 +1,4 @@
# This file is automatically @generated by Poetry 1.8.2 and should not be changed by hand.
# This file is automatically @generated by Poetry 1.6.1 and should not be changed by hand.
[[package]]
name = "beautifulsoup4"
@ -330,13 +330,13 @@ files = [
[[package]]
name = "platformdirs"
version = "4.2.1"
version = "4.2.2"
description = "A small Python package for determining appropriate platform-specific dirs, e.g. a `user data dir`."
optional = false
python-versions = ">=3.8"
files = [
{file = "platformdirs-4.2.1-py3-none-any.whl", hash = "sha256:17d5a1161b3fd67b390023cb2d3b026bbd40abde6fdb052dfbd3a29c3ba22ee1"},
{file = "platformdirs-4.2.1.tar.gz", hash = "sha256:031cd18d4ec63ec53e82dceaac0417d218a6863f7745dfcc9efe7793b7039bdf"},
{file = "platformdirs-4.2.2-py3-none-any.whl", hash = "sha256:2d7a1657e36a80ea911db832a8a6ece5ee53d8de21edd5cc5879af6530b1bfee"},
{file = "platformdirs-4.2.2.tar.gz", hash = "sha256:38b7b51f512eed9e84a22788b4bce1de17c0adb134d6becb09836e37d8654cd3"},
]
[package.extras]
@ -346,13 +346,13 @@ type = ["mypy (>=1.8)"]
[[package]]
name = "requests"
version = "2.31.0"
version = "2.32.3"
description = "Python HTTP for Humans."
optional = false
python-versions = ">=3.7"
python-versions = ">=3.8"
files = [
{file = "requests-2.31.0-py3-none-any.whl", hash = "sha256:58cd2187c01e70e6e26505bca751777aa9f2ee0b7f4300988b709f44e013003f"},
{file = "requests-2.31.0.tar.gz", hash = "sha256:942c5a758f98d790eaed1a29cb6eefc7ffb0d1cf7af05c3d2791656dbd6ad1e1"},
{file = "requests-2.32.3-py3-none-any.whl", hash = "sha256:70761cfe03c773ceb22aa2f671b4757976145175cdfca038c02654d061d6dcc6"},
{file = "requests-2.32.3.tar.gz", hash = "sha256:55365417734eb18255590a9ff9eb97e9e1da868d4ccd6402399eaf68af20a760"},
]
[package.dependencies]
@ -400,13 +400,13 @@ files = [
[[package]]
name = "types-beautifulsoup4"
version = "4.12.0.20240504"
version = "4.12.0.20240511"
description = "Typing stubs for beautifulsoup4"
optional = false
python-versions = ">=3.8"
files = [
{file = "types-beautifulsoup4-4.12.0.20240504.tar.gz", hash = "sha256:d7b7af4ccc52fc22784d33a529695e34329a9bdd5db6a649c9b25cb2c3a148d5"},
{file = "types_beautifulsoup4-4.12.0.20240504-py3-none-any.whl", hash = "sha256:84e04e4473b3c79da04d9d1f89d20a38e5cc92f9c080a8e1f9d36a287b350465"},
{file = "types-beautifulsoup4-4.12.0.20240511.tar.gz", hash = "sha256:004f6096fdd83b19cdbf6cb10e4eae57b10205eccc365d0a69d77da836012e28"},
{file = "types_beautifulsoup4-4.12.0.20240511-py3-none-any.whl", hash = "sha256:7ceda66a93ba28d759d5046d7fec9f4cad2f563a77b3a789efc90bcadafeefd1"},
]
[package.dependencies]
@ -425,13 +425,13 @@ files = [
[[package]]
name = "types-requests"
version = "2.31.0.20240406"
version = "2.32.0.20240523"
description = "Typing stubs for requests"
optional = false
python-versions = ">=3.8"
files = [
{file = "types-requests-2.31.0.20240406.tar.gz", hash = "sha256:4428df33c5503945c74b3f42e82b181e86ec7b724620419a2966e2de604ce1a1"},
{file = "types_requests-2.31.0.20240406-py3-none-any.whl", hash = "sha256:6216cdac377c6b9a040ac1c0404f7284bd13199c0e1bb235f4324627e8898cf5"},
{file = "types-requests-2.32.0.20240523.tar.gz", hash = "sha256:26b8a6de32d9f561192b9942b41c0ab2d8010df5677ca8aa146289d11d505f57"},
{file = "types_requests-2.32.0.20240523-py3-none-any.whl", hash = "sha256:f19ed0e2daa74302069bbbbf9e82902854ffa780bc790742a810a9aaa52f65ec"},
]
[package.dependencies]
@ -439,13 +439,13 @@ urllib3 = ">=2"
[[package]]
name = "typing-extensions"
version = "4.11.0"
version = "4.12.0"
description = "Backported and Experimental Type Hints for Python 3.8+"
optional = false
python-versions = ">=3.8"
files = [
{file = "typing_extensions-4.11.0-py3-none-any.whl", hash = "sha256:c1f94d72897edaf4ce775bb7558d5b79d8126906a14ea5ed1635921406c0387a"},
{file = "typing_extensions-4.11.0.tar.gz", hash = "sha256:83f085bd5ca59c80295fc2a82ab5dac679cbe02b9f33f7d83af68e241bea51b0"},
{file = "typing_extensions-4.12.0-py3-none-any.whl", hash = "sha256:b349c66bea9016ac22978d800cfff206d5f9816951f12a7d0ec5578b0a819594"},
{file = "typing_extensions-4.12.0.tar.gz", hash = "sha256:8cbcdc8606ebcb0d95453ad7dc5065e6237b6aa230a31e81d0f440c30fed5fd8"},
]
[[package]]

View file

@ -1,6 +1,6 @@
[tool.poetry]
name = "lfcircle"
version = "0.1.0"
version = "0.2.1"
description = "last.fm statistics generator for your friend circle!"
authors = ["Mark Joshwel <mark@joshwel.co>"]
license = "Unlicense"
@ -22,6 +22,13 @@ isort = "^5.13.2"
black = "^24.4.2"
types-requests = "^2.31.0.20240406"
[tool.black]
line-length = 90
[tool.isort]
line_length = 90
profile = "black"
[build-system]
requires = ["poetry-core"]
build-backend = "poetry.core.masonry.api"

7
qc.sh Executable file
View file

@ -0,0 +1,7 @@
#!/bin/sh
set -e
black lfcircle.py --check
isort lfcircle.py --check
mypy lfcircle.py
ruff check lfcircle.py