Skip to content
Merged

Test #65

Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions .github/workflows/format_and_test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ jobs:
uv run -m ensurepip --upgrade
uv run -m pip install --upgrade pip
uv tool install pytest
uv run -m pip install pytest
uv run -m pip install pytest pytest-asyncio
uv lock --upgrade

- name: Run ruff to lint and format code
Expand All @@ -55,4 +55,4 @@ jobs:

- name: Run tests
run: uv run -m pytest
continue-on-error: true
continue-on-error: true
5,316 changes: 4,150 additions & 1,166 deletions compiler/api/docs.json

Large diffs are not rendered by default.

180 changes: 180 additions & 0 deletions compiler/api/scrape_docs.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,180 @@
from __future__ import annotations

import asyncio
import json
import re
from pathlib import Path

import httpx
from lxml import html

ROOT_DIR = Path.cwd().absolute()
if ROOT_DIR.name == "dev_tools":
ROOT_DIR = ROOT_DIR.parent


SECTION_RE = re.compile(r"---(\w+)---")
COMBINATOR_RE = re.compile(
r"^([\w.]+)#([0-9a-f]+)\s(?:.*)=\s([\w<>.]+);$",
re.MULTILINE,
)


BASE_URL = "https://corefork.telegram.org/"


MAX_TASKS = 10


sem = asyncio.Semaphore(MAX_TASKS)


client = httpx.AsyncClient()

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

suggestion (bug_risk): Creating the async HTTP client at import time can lead to lifecycle issues.

Creating httpx.AsyncClient() at import time means any import of this module allocates a client and connection pool, even if main() is never called, and makes cleanup harder to guarantee if errors occur before client.aclose().

Prefer constructing and closing the client within main() (or an async context manager), for example:

async def main():
    async with httpx.AsyncClient() as client:
        ...  # pass client down or accept it as a parameter

This keeps side effects out of imports and ties the client lifetime to main() execution.

Suggested implementation:

import asyncio
import json
import re
from pathlib import Path

import httpx
from lxml import html

BASE_URL = "https://corefork.telegram.org/"

MAX_TASKS = 10

sem = asyncio.Semaphore(MAX_TASKS)

ROOT_DIR = Path.cwd().absolute()

To fully implement the lifecycle-safe client handling you described, you will also need to:

  1. Locate the async def main(...): (or equivalent entrypoint) in compiler/api/scrape_docs.py and change it to create the client in a context manager, e.g.:
async def main(...):
    async with httpx.AsyncClient() as client:
        await run_scrape(client, ...)
  1. Update functions that currently rely on a global client (e.g. ones doing await client.get(...)) to instead accept client: httpx.AsyncClient as a parameter, and pass the client object through from main().

  2. Remove any remaining references to a global client variable in this module to ensure there is no accidental use of a stale or unclosed client.



async def main():
tl_data = parse_tl_file(ROOT_DIR / "compiler" / "api" / "source" / "main_api.tl")

it_count = (
len(tl_data["constructor"]) + len(tl_data["method"]) + len(tl_data["type"])
)

print(f"Getting {it_count} objects from {BASE_URL}…")

doc_dict = {"type": {}, "constructor": {}, "method": {}}

tasks = []

it_count_done = 0

for it_type, items in tl_data.items():
for it_name in items:
it_count_done += 1
print(f"Parsing items {it_count_done}/{it_count}", end="\r", flush=True)
await sem.acquire()
tasks.append(
asyncio.create_task(get_object_data(it_type, it_name, doc_dict)),
)

# Be sure that all tasks are done before continuing
await asyncio.gather(*tasks)

await client.aclose()

with (ROOT_DIR / "compiler" / "api" / "docs.json").open(
"w",
encoding="utf-8",
) as f:
json.dump(doc_dict, f, indent=2, sort_keys=True)

print("\nDone!")


async def get_object_data(it_type: str, it_name: str, doc_dict: dict[str, dict]):
try:
request = await client.get(f"{BASE_URL}{it_type}/{it_name}")
if request.status_code != 200:
print(f"Error {request.status_code} for {it_type}/{it_name}\n")
return

tree = html.fromstring(request.text)

page_content_xp = tree.xpath("//div[@id='dev_page_content'][1]")
if not page_content_xp:
print(f"No page content for {it_type}/{it_name}")
return

page_content = page_content_xp[0]

# Get the description of the object - always used
desc_xp = page_content.xpath("./p[1]")

if desc_xp:
desc = desc_xp[0].text_content().strip()
else:
print(f"No description for {it_type}/{it_name}")
desc = ""

if it_type == "type":
doc_dict["type"][it_name] = {"desc": desc}
elif it_type in {"constructor", "method"}:
params_link_xp = page_content.xpath("./h3/a[@id='parameters'][1]")
if params_link_xp:
params_xp = (
params_link_xp[0].getparent().getnext().xpath("./tbody[1]")
)
if params_xp:
params = {}
for row in params_xp[0].xpath("./tr"):
cells = row.getchildren()
if len(cells) >= 3:
params[cells[0].text_content().strip()] = (
cells[2].text_content().strip()
)
else:
print(f"No parameters for {it_type}/{it_name}")
params = {}
else:
print(f"No parameters section for {it_type}/{it_name}")
params = {}

doc_dict[it_type][it_name] = {"desc": desc, "params": params}
else:
raise ValueError(f"Unknown type {it_type}")
finally:
sem.release()


def devectorize(ttype: str) -> str:
ivec = ttype.find("Vector<")
if ivec != -1:
return ttype[7:-1]

return ttype


def adjust_name(qualname: str) -> str:
names = qualname.split(".")

if len(names) == 2:
name = names[1][:1].upper() + names[1][1:]
return ".".join([names[0], name])

return qualname[:1].upper() + qualname[1:]


def parse_tl_file(file_path: Path) -> dict[str, list[str]]:
lines = file_path.read_text().splitlines()

section = None

types = set()
constructors = set()
methods = set()

for line in lines:
if section_match := SECTION_RE.match(line):
section = section_match.group(1)
continue

if combinator_match := COMBINATOR_RE.match(line):
qualname, _id, qualtype = combinator_match.groups()

qualtype = devectorize(qualtype)

if section == "types":
types.add(qualtype)
constructors.add(adjust_name(qualname))
elif section == "functions":
types.add(qualtype)
methods.add(adjust_name(qualname))

return {
"type": sorted(types),
"constructor": sorted(constructors),
"method": sorted(methods),
}


if __name__ == "__main__":
asyncio.run(main())
41 changes: 22 additions & 19 deletions compiler/docs/compiler.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
from __future__ import annotations

import ast
import os
import re
import shutil
from pathlib import Path
Expand Down Expand Up @@ -45,93 +44,95 @@
return re.sub(r"([a-z0-9])([A-Z])", r"\1_\2", s).lower()


def generate_raw(source_path, base) -> None:
all_entities = {}

def build(path, level=0) -> None:
last = path.split("/")[-1]
path_obj = Path(path)

for i in os.listdir(path):
try:
if not i.startswith("__"):
build("/".join([path, i]), level=level + 1)
except NotADirectoryError:
with Path(path, i).open(encoding="utf-8") as f:
for i in path_obj.iterdir():
if i.is_dir():
if not i.name.startswith("__"):
build(str(i), level=level + 1)
else:
if i.name.startswith("__"):
continue
with i.open(encoding="utf-8") as f:
p = ast.parse(f.read())

for node in ast.walk(p):
if isinstance(node, ast.ClassDef):
name = node.name
break
else:
continue

full_path = (
Path(path).name + "/" + snake(name).replace("_", "-") + ".rst"
)

if level:
full_path = base + "/" + full_path

namespace = path.split("/")[-1]
if namespace in ["base", "types", "functions"]:
namespace = ""

full_name = f"{(namespace + '.') if namespace else ''}{name}"

Path(DESTINATION, full_path).parent.mkdir(
parents=True,
exist_ok=True,
)

with Path(DESTINATION, full_path).open("w", encoding="utf-8") as f:
f.write(
page_template.format(
title=full_name,
title_markup="=" * len(full_name),
full_class_path="pyrogram.raw.{}".format(
".".join(full_path.split("/")[:-1]) + "." + name,
),
),
)

if last not in all_entities:
all_entities[last] = []
if path_obj.name not in all_entities:
all_entities[path_obj.name] = []

all_entities[last].append(name)
all_entities[path_obj.name].append(name)

build(source_path)

for k, v in sorted(all_entities.items()):
v = sorted(v)
entities = [f"{i} <{snake(i).replace('_', '-')}>" for i in v]

if k != base:
inner_path = base + "/" + k + "/index" + ".rst"
module = f"pyrogram.raw.{base}.{k}"
else:
for i in sorted(all_entities, reverse=True):
if i != base:
entities.insert(0, f"{i}/index")

inner_path = base + "/index" + ".rst"
module = f"pyrogram.raw.{base}"

with Path(DESTINATION, inner_path).open("w", encoding="utf-8") as f:
if k == base:
f.write(":tocdepth: 1\n\n")
k = "Raw " + k

f.write(
toctree.format(
title=k.title(),
title_markup="=" * len(k),
module=module,
entities="\n ".join(entities),
),
)

f.write("\n")

Check notice on line 135 in compiler/docs/compiler.py

View check run for this annotation

codefactor.io / CodeFactor

compiler/docs/compiler.py#L47-L135

Complex Method


def pyrogram_api() -> None:
Expand All @@ -151,7 +152,7 @@
continue

# Check if it defines a class or is special function (idle, compose)
with open(file, encoding="utf-8") as f:
with Path(file).open(encoding="utf-8") as f:
tree = ast.parse(f.read())

has_class = any(
Expand Down Expand Up @@ -186,9 +187,11 @@
tree = ast.parse(f.read())
except Exception:
continue
for node in ast.walk(tree):
if isinstance(node, ast.ClassDef):
types.append(node.name)
types.extend(
node.name
for node in ast.walk(tree)
if isinstance(node, ast.ClassDef)
)
if types:
title = TYPES_TITLES.get(
category_name,
Expand Down Expand Up @@ -234,9 +237,9 @@
tree = ast.parse(f.read())
except Exception:
continue
for node in ast.walk(tree):
if isinstance(node, ast.ClassDef):
enums_list.append(node.name)
enums_list.extend(
node.name for node in ast.walk(tree) if isinstance(node, ast.ClassDef)
)

# Methods Generation

Expand Down Expand Up @@ -346,7 +349,7 @@
f.write("-----\n\n")
f.write(".. currentmodule:: pyrogram.types\n\n")

for cat_name, (title, t_list) in sorted(types_categories.items()):
for _, (title, t_list) in sorted(types_categories.items()):
f.write(f"{title}\n" + "-" * len(title) + "\n\n")
f.write(".. autosummary::\n :nosignatures:\n\n")
f.writelines(f" {t}\n" for t in sorted(t_list))
Expand Down
3 changes: 1 addition & 2 deletions compiler/errors/compiler.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
from __future__ import annotations

import csv
import os
import re
import shutil
from pathlib import Path
Expand All @@ -23,7 +22,7 @@ def camel(s):
def start() -> None:
shutil.rmtree(DEST, ignore_errors=True)
Path(DEST).mkdir(parents=True, exist_ok=True)
files = list(os.listdir(f"{HOME}/source"))
files = [i.name for i in Path(HOME, "source").iterdir() if i.is_file()]

with Path(DEST, "all.py").open("w", encoding="utf-8") as f_all:
f_all.write("count = {count}\n\n")
Expand Down
4 changes: 2 additions & 2 deletions hatch_build.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,8 @@ def initialize(self, version, build_data) -> None:
if self.target_name not in {"wheel", "install"}:
return

from compiler.api.compiler import start as compile_api
from compiler.errors.compiler import start as compile_errors
from compiler.api.compiler import start as compile_api # noqa: PLC0415
from compiler.errors.compiler import start as compile_errors # noqa: PLC0415

compile_api()
compile_errors()
6 changes: 6 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ dependencies = [
"pymediainfo",
"pymongo",
"ElectroCrypto",
"anyio>=4.12.1",
]
requires-python = ">=3.10"

Expand Down Expand Up @@ -56,6 +57,10 @@ docs = [
[tool.hatch.metadata]
allow-direct-references = true

[tool.pytest.ini_options]
asyncio_mode = "auto"
asyncio_default_test_loop_scope = "function"

[tool.hatch.build.targets.sdist]
exclude = [
".github/",
Expand Down Expand Up @@ -116,6 +121,7 @@ ignore = [
"N806",
"N818",
"PLR09",
"COM812", # Don't remove
"RUF002",
"SIM115",
"PERF203",
Expand Down
2 changes: 1 addition & 1 deletion pyrogram/__init__.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
from __future__ import annotations

__version__ = "v0.2.224.3"
__version__ = "v0.2.224.4"
__license__ = "MIT License"

from concurrent.futures.thread import ThreadPoolExecutor
Expand Down
7 changes: 5 additions & 2 deletions pyrogram/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,8 @@
from pathlib import Path
from typing import TYPE_CHECKING

from anyio import Path as AsyncPath

import pyrogram
from pyrogram import __license__, __version__, enums, raw, utils
from pyrogram.crypto import aes
Expand Down Expand Up @@ -962,7 +964,8 @@ async def handle_download(self, packet) -> str:
progress_args,
) = packet

Path(directory).mkdir(parents=True, exist_ok=True) if not in_memory else None
if not in_memory:
await AsyncPath(directory).mkdir(parents=True, exist_ok=True)
temp_file_path = (
Path(directory)
.joinpath(re.sub(r"\\", "/", file_name))
Expand All @@ -985,7 +988,7 @@ async def handle_download(self, packet) -> str:
except BaseException as e:
if not in_memory:
file.close()
Path(temp_file_path).unlink()
await AsyncPath(temp_file_path).unlink()

if isinstance(e, asyncio.CancelledError):
raise e
Expand Down
2 changes: 2 additions & 0 deletions pyrogram/enums/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

from .accent_color import AccentColor
from .business_schedule import BusinessSchedule
from .button_style import ButtonStyle
from .chat_action import ChatAction
from .chat_event_action import ChatEventAction
from .chat_join_type import ChatJoinType
Expand Down Expand Up @@ -30,6 +31,7 @@
__all__ = [
"AccentColor",
"BusinessSchedule",
"ButtonStyle",
"ChatAction",
"ChatEventAction",
"ChatJoinType",
Expand Down
Loading