diff --git a/.github/actions/deploy/action.yaml b/.github/actions/deploy/action.yaml new file mode 100644 index 0000000..8877e93 --- /dev/null +++ b/.github/actions/deploy/action.yaml @@ -0,0 +1,50 @@ +name: Build Artifacts +description: Build Artifacts + +inputs: + python-version: + description: "Python Version" + required: false + default: "3.10" + +runs: + using: "composite" + steps: + - name: Setup Python + id: setup-python + uses: actions/setup-python@v4 + with: + python-version: ${{ inputs.python-version }} + - name: Install Poetry + uses: snok/install-poetry@v1 + with: + virtualenvs-create: true + virtualenvs-in-project: true + installer-parallel: true + - name: Load Cached venv + id: cached-poetry-dependencies + uses: actions/cache@v3 + with: + path: .venv + key: venv-deploy-${{ runner.os }}-${{ runner.arch }}-${{ steps.setup-python.outputs.python-version }}-${{ hashFiles('**/poetry.lock') }} + - name: Install Dependencies + if: steps.cached-poetry-dependencies.outputs.cache-hit != 'true' + shell: bash + run: poetry install --no-interaction --no-root + - name: Install Project + shell: bash + run: poetry install --no-interaction + - if: ${{ runner.os == 'Linux' || runner.os == 'macOS' }} + name: Build Artifacts + shell: bash + run: | + source .venv/bin/activate + poetry run build + mv dist/* dist/$(poetry version | tr ' ' '-')-${{ runner.os }}-${{ runner.arch }}-py${{ steps.setup-python.outputs.python-version }} + - if: ${{ runner.os == 'Windows' }} + name: Build Artifacts + shell: bash + run: | + source .venv/Scripts/activate + poetry run build + mv dist/* dist/$(poetry version | tr ' ' '-')-${{ runner.os }}-${{ runner.arch }}-py${{ steps.setup-python.outputs.python-version }}.exe diff --git a/.github/actions/docs/action.yaml b/.github/actions/docs/action.yaml new file mode 100644 index 0000000..a896273 --- /dev/null +++ b/.github/actions/docs/action.yaml @@ -0,0 +1,41 @@ +name: Deploy MkDocs +description: Build and Deploy MkDocs to GitHub Pages + +inputs: + python-version: + description: "Python Version" + required: false + default: "3.10" + +runs: + using: "composite" + steps: + - name: Setup Python + id: setup-python + uses: actions/setup-python@v4 + with: + python-version: ${{ inputs.python-version }} + - name: Install Poetry + uses: snok/install-poetry@v1 + with: + virtualenvs-create: true + virtualenvs-in-project: true + installer-parallel: true + - name: Load Cached venv + id: cached-poetry-dependencies + uses: actions/cache@v3 + with: + path: .venv + key: venv-docs-${{ runner.os }}-${{ runner.arch }}-${{ steps.setup-python.outputs.python-version }}-${{ hashFiles('**/poetry.lock') }} + - if: steps.cached-poetry-dependencies.outputs.cache-hit != 'true' + name: Install dependencies + shell: bash + run: poetry install --with docs --no-root --no-interaction + - name: Deploy GitHub Pages + shell: bash + run: | + source .venv/bin/activate + bash scripts/docs.sh prepare + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + mkdocs gh-deploy --force diff --git a/.github/actions/publish/action.yaml b/.github/actions/publish/action.yaml new file mode 100644 index 0000000..de0e0f1 --- /dev/null +++ b/.github/actions/publish/action.yaml @@ -0,0 +1,17 @@ +name: Publish to PyPI +description: Publish Python Package to PyPI + +inputs: + pypi_token: + description: "PyPI Token" + required: true + +runs: + using: "composite" + steps: + - if: ${{ inputs.pypi_token != '' }} + name: Build and Publish to PyPI + uses: JRubics/poetry-publish@v1.13 + with: + pypi_token: ${{ inputs.pypi_token }} + continue-on-error: true diff --git a/.github/dependabot.yaml b/.github/dependabot.yaml new file mode 100644 index 0000000..4ab058f --- /dev/null +++ b/.github/dependabot.yaml @@ -0,0 +1,18 @@ +version: 2 +updates: + - package-ecosystem: "gitsubmodule" + directory: "/" + schedule: + interval: "weekly" + - package-ecosystem: "github-actions" + directory: "/" + schedule: + interval: "weekly" + - package-ecosystem: "npm" + directory: "/" + schedule: + interval: "weekly" + - package-ecosystem: "pip" + directory: "/" + schedule: + interval: "weekly" diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml new file mode 100644 index 0000000..6ab48fa --- /dev/null +++ b/.github/workflows/ci.yaml @@ -0,0 +1,119 @@ +name: CI/CD + +on: + push: + branches: + - "main" + +jobs: + cz: + if: ${{ github.repository != 'liblaf/template' }} + outputs: + bumped: ${{ steps.bumped.outputs.bumped }} + version: ${{ steps.cz.outputs.version }} + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v3.1.0 + with: + fetch-depth: 0 + - id: cz + name: Create Bump and Changelog + uses: commitizen-tools/commitizen-action@0.15.1 + with: + github_token: ${{ github.token }} + changelog_increment_filename: body.md + - id: bumped + run: | + if [[ -n "$(cat body.md)" ]]; then + echo "bumped = true" + echo "bumped=true" >> $GITHUB_OUTPUT + else + echo "bumped = false" + echo "bumped=false" >> $GITHUB_OUTPUT + fi + - if: ${{ steps.bumped.outputs.bumped == 'true' }} + name: Upload Changelog + uses: actions/upload-artifact@v3.1.1 + with: + name: changelog + path: body.md + + publish: + needs: cz + if: ${{ needs.cz.outputs.bumped == 'true' }} + continue-on-error: true + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v3.1.0 + - run: git pull + - name: Publish Package + uses: ./.github/actions/publish + with: + pypi_token: ${{ secrets.PYPI_TOKEN }} + + deploy: + needs: cz + if: ${{ needs.cz.outputs.bumped == 'true' }} + continue-on-error: true + strategy: + matrix: + os: + - macos-latest + - ubuntu-latest + - windows-latest + runs-on: ${{ matrix.os }} + steps: + - name: Checkout + uses: actions/checkout@v3.1.0 + - run: git pull + - id: detect + name: Detect Build Script + shell: bash + run: | + set +o errexit + set +o pipefail + cat pyproject.toml | grep "build = \".*.build:run\"" + if (( $? == 0 )); then + echo "build=true" >> $GITHUB_OUTPUT + else + echo "build=false" >> $GITHUB_OUTPUT + fi + - if: ${{ steps.detect.outputs.build == 'true' }} + name: Build Artifacts + uses: ./.github/actions/deploy + - if: ${{ steps.detect.outputs.build == 'true' }} + name: Upload Artifacts + uses: actions/upload-artifact@v3.1.1 + with: + name: ${{ matrix.os }} + path: dist/**/* + + release: + needs: + - cz + - deploy + if: ${{ needs.cz.outputs.bumped == 'true' }} + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v3.1.0 + - run: git pull + - name: Download Changelog + uses: actions/download-artifact@v3.0.1 + with: + name: changelog + - name: Download Artifacts + uses: actions/download-artifact@v3.0.1 + with: + path: artifacts + - name: GitHub Release + uses: softprops/action-gh-release@v1 + with: + tag_name: ${{ needs.cz.outputs.version }} + body_path: body.md + files: | + artifacts/macos-latest/**/* + artifacts/ubuntu-latest/**/* + artifacts/windows-latest/**/* diff --git a/.github/workflows/gh-pages.yaml b/.github/workflows/gh-pages.yaml new file mode 100644 index 0000000..36c4ba4 --- /dev/null +++ b/.github/workflows/gh-pages.yaml @@ -0,0 +1,16 @@ +name: Deploy GitHub Pages + +on: + push: + branches: + - main + workflow_dispatch: + +jobs: + docs: + runs-on: ubuntu-latest + steps: + - name: Check out repository + uses: actions/checkout@v3 + - name: Deploy MkDocs + uses: ./.github/actions/docs diff --git a/.github/workflows/update-license-year.yaml b/.github/workflows/update-license-year.yaml new file mode 100644 index 0000000..3a472d6 --- /dev/null +++ b/.github/workflows/update-license-year.yaml @@ -0,0 +1,16 @@ +name: Update copyright year(s) in license file + +on: + schedule: + - cron: "0 3 1 1 *" # 03:00 AM on January 1 + +jobs: + update-license-year: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + with: + fetch-depth: 0 + - uses: FantasticFiasco/action-update-license-year@v2 + with: + token: ${{ github.token }} diff --git a/.gitignore b/.gitignore index e407f6c..20ccb39 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,3 @@ -# hydra outputs -outputs - # Created by https://www.toptal.com/developers/gitignore/api/python # Edit at https://www.toptal.com/developers/gitignore?templates=python @@ -167,3 +164,4 @@ #.idea/ # End of https://www.toptal.com/developers/gitignore/api/python +outputs diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..0e06d5c --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,72 @@ +ci: + skip: + - latexindent-system + - poetry-lock + - shfmt-system +exclude: "^(.cz.yaml)|(poetry.lock)|(requirements.txt)$" +repos: + - repo: https://github.com/commitizen-tools/commitizen + rev: "v2.37.0" + hooks: + - id: commitizen + - repo: https://github.com/liblaf/pre-commit-hooks + rev: "0.2.0" + hooks: + - id: latexindent-system + - id: shfmt-system + - repo: https://github.com/macisamuele/language-formatters-pre-commit-hooks + rev: "v2.4.0" + hooks: + - id: pretty-format-toml + args: + - "--autofix" + - repo: https://github.com/pre-commit/mirrors-clang-format + rev: "v14.0.6" + hooks: + - id: clang-format + args: + - "--fallback-style" + - "Google" + - repo: https://github.com/pre-commit/mirrors-prettier + rev: "v3.0.0-alpha.4" + hooks: + - id: prettier + stages: + - commit + - repo: https://github.com/pre-commit/pre-commit-hooks + rev: "v4.3.0" + hooks: + - id: check-added-large-files + - id: check-case-conflict + - id: check-json + - id: check-symlinks + - id: check-toml + - id: check-yaml + - id: destroyed-symlinks + - id: end-of-file-fixer + - id: mixed-line-ending + - id: trailing-whitespace + - repo: https://github.com/psf/black + rev: "22.10.0" + hooks: + - id: black + - id: black-jupyter + - repo: https://github.com/python-jsonschema/check-jsonschema + rev: "0.18.4" + hooks: + - id: check-dependabot + - id: check-github-actions + - id: check-github-workflows + - repo: https://github.com/python-poetry/poetry + rev: "1.2.1" + hooks: + - id: poetry-check + - id: poetry-lock + files: "^pyproject.toml$" + - id: poetry-export + files: "^pyproject.toml$" + args: + - "--output" + - "requirements.txt" + - "--without-hashes" + - "--without-urls" diff --git a/README.md b/README.md new file mode 100644 index 0000000..1a25137 --- /dev/null +++ b/README.md @@ -0,0 +1,43 @@ +# thu-learn-downloader + +Auto download files from thu-learn + +## Demo + +See Screen Recording at [demo.webm](https://drive.liblaf.top/github/thu-learn-downloader/demo.webm). + +The resulting file structure looks like: + +``` +thu-learn +└── engineering-mechanics-for-civil-engineering + ├── docs + │ ├── 作业与思考题 + │ │ └── 第三周部分作业及思考题.pdf + │ ├── 电子教案 + │ │ └── 第13讲-杆件拉伸和压缩.pdf + │ └── 课外阅读 + │ └── 基于月面原位资源的月球基地建造技术.pdf + └── work + ├── 期中考试 + │ └── README.md + └── 第2周作业 + ├── attach-第2周作业.docx + ├── comment-2020012872-李钦-6544.pdf + ├── README.md + └── submit-第2周作业.pdf +``` + +## Features + +- pretty TUI powered by [rich](https://github.com/Textualize/rich) +- auto set `mtime` of downloaded files according to timestamp of remote file +- auto skip download when local file is newer +- dump homework details into `README.md` in each homework folder +- pretty markdown files powered by [prettier](https://prettier.io) (require `prettier` installed) + +## Usage + +1. Download pre-built binary from [releases](https://github.com/liblaf/thu-learn-downloader/releases). +2. Prepare a `config.yaml` like [config.yaml](https://github.com/liblaf/thu-learn-downloader/blob/main/config.yaml). +3. Run `thu-learn-downloader password="***"` and wait for the sync to finish. diff --git a/config.yaml b/config.yaml new file mode 100644 index 0000000..e1b2bf6 --- /dev/null +++ b/config.yaml @@ -0,0 +1,6 @@ +username: "liqin20" +password: "**************" +prefix: /home/liblaf/Desktop/thu-learn +semesters: + - "2022-2023-1" +courses: ~ diff --git a/downloader.py b/downloader.py deleted file mode 100644 index 3c39557..0000000 --- a/downloader.py +++ /dev/null @@ -1,337 +0,0 @@ -import dataclasses -import os - -import tqdm -import tqdm.contrib -import tqdm.contrib.concurrent - -import thu_learn_lib -import thu_learn_lib.ty -import thu_learn_lib.utils - - -@dataclasses.dataclass -class DownloadTask: - session: "Downloader" - url: str - filename: str - prefix: str = "." - - -class Downloader: - helper: thu_learn_lib.LearnHelper - prefix: str - file_size_limit: float = None # MB - sync_docs: bool = True - sync_work: bool = True - sync_submit: bool = True - download_tasks: list[DownloadTask] = None - - def __init__( - self, - username: str, - password: str, - prefix: str = "thu-learn", - file_size_limit: float = None, - sync_docs: bool = True, - sync_work: bool = True, - sync_submit: bool = True, - ) -> None: - self.helper = thu_learn_lib.LearnHelper( - username=username, - password=password, - ) - self.prefix = prefix - self.file_size_limit = file_size_limit - self.sync_docs = sync_docs - self.sync_work = sync_work - self.sync_submit = sync_submit - - assert self.helper.login() - - @staticmethod - def download( - self: "Downloader", - url: str, - filename: str, - prefix: str = ".", - position: int = 0, - ) -> bool: - response = self.helper.get(url=url, stream=True) - file_size = int(response.headers.get("content-length", 0)) - if self.file_size_limit: - if file_size > self.file_size_limit * 1024 * 1024: - print(f"Skip file {filename}") - return False - filename = thu_learn_lib.utils.slugify(filename) - path = os.path.join(prefix, filename) - if os.path.exists(path): - if os.path.getsize(path) == file_size: - # print(f"file {filename} is already synced") - return True - os.makedirs(prefix, exist_ok=True) - chunk_size = 8192 # 8KB - try: - with open( - file=path, - mode="wb", - ) as file: - with tqdm.tqdm( - desc=f"{position - 6} {filename}", - total=file_size, - leave=False, - unit="B", - unit_scale=True, - dynamic_ncols=True, - position=position, - ) as progress_bar: - for content in response.iter_content(chunk_size): - file.write(content) - progress_bar.update(len(content)) - except KeyboardInterrupt: - raise KeyboardInterrupt() - except: - return False - return True - - @staticmethod - def retry_download( - task: DownloadTask, - position: int = 0, - max_retries: int = 5, - ) -> bool: - for i in range(max_retries): - if Downloader.download( - self=task.session, - url=task.url, - filename=task.filename, - prefix=task.prefix, - position=position, - ): - return True - print(f"Failed to download file {task.filename}") - return False - - def schedule_download( - self, - url: str, - filename: str, - prefix: str = ".", - ) -> None: - if not self.download_tasks: - self.download_tasks = [] - self.download_tasks.append( - DownloadTask( - session=self, - url=url, - filename=filename, - prefix=prefix, - ) - ) - - def finish_download(self, desc: str = "download") -> bool: - if self.download_tasks: - success = all( - tqdm.contrib.concurrent.process_map( - Downloader.retry_download, - self.download_tasks, - range(6, 6 + len(self.download_tasks)), - desc=desc, - leave=False, - dynamic_ncols=True, - position=4, - ) - ) - self.download_tasks.clear() - return success - else: - return True - - def sync_semester( - self, - semester_id: str, - course_type: thu_learn_lib.ty.CourseType = thu_learn_lib.ty.CourseType.STUDENT, - ) -> bool: - course_list = self.helper.get_course_list( - semester_id=semester_id, - course_type=course_type, - ) - for course in tqdm.tqdm( - iterable=course_list, - desc=semester_id, - leave=False, - dynamic_ncols=True, - position=2, - ): - self.sync_course(course=course, semester_id=semester_id) - - def sync_course( - self, - course: thu_learn_lib.ty.CourseInfo, - semester_id: str, - ) -> bool: - file_list = self.helper.get_file_list( - course_id=course.id, - course_type=course.course_type, - ) - # print( - # f"Syncing Course {course.course_number} {course.name} {course.english_name} ......" - # ) - if self.sync_docs: - pass - if self.sync_docs: - for file in file_list: - self.sync_file(file, semester_id=semester_id, course=course) - self.finish_download(desc=course.english_name) - if self.sync_work: - homework_list = self.helper.get_homework_list(course_id=course.id) - for homework in homework_list: - self.sync_homework( - homework=homework, semester_id=semester_id, course=course - ) - self.finish_download(desc=course.english_name) - - def sync_file( - self, - file: thu_learn_lib.ty.File, - semester_id: str, - course: thu_learn_lib.ty.CourseInfo, - ) -> bool: - prefix = os.path.join( - self.prefix, - thu_learn_lib.utils.slugify( - f"{course.course_number}-{course.english_name}" - ), - thu_learn_lib.utils.slugify("documents"), - thu_learn_lib.utils.slugify(file.clazz), - ) - filename = ( - thu_learn_lib.utils.slugify(file.title) - + f".{thu_learn_lib.utils.slugify(file.file_type)}" - if file.file_type - else "" - ) - self.schedule_download( - url=file.download_url, - filename=filename, - prefix=prefix, - ) - return True - - def sync_homework( - self, - homework: thu_learn_lib.ty.Homework, - semester_id: str, - course: thu_learn_lib.ty.CourseInfo, - ) -> bool: - prefix = os.path.join( - self.prefix, - thu_learn_lib.utils.slugify( - f"{course.course_number}-{course.english_name}" - ), - thu_learn_lib.utils.slugify("work"), - thu_learn_lib.utils.slugify(homework.title), - ) - os.makedirs(prefix, exist_ok=True) - lines = [] - lines.append(f"## Contents and Requirements") - lines.append(f"") - lines.append(f"### Title") - lines.append(f"") - lines.append(f"{homework.title}") - lines.append(f"") - lines.append(f"### Description") - lines.append(f"") - lines.append(f"{homework.description}") - lines.append(f"") - if homework.attachment: - filename = thu_learn_lib.utils.slugify( - f"attach-{homework.title}{os.path.splitext(homework.attachment.name)[-1]}" - ) - self.schedule_download( - url=homework.attachment.download_url, - prefix=prefix, - filename=filename, - ) - lines.append(f"### Attach.") - lines.append(f"") - lines.append(f"[{homework.attachment.name}]({filename})") - lines.append(f"") - lines.append(f"### ANS") - lines.append(f"") - lines.append(f"{homework.answer_content}") - lines.append(f"") - if homework.answer_attachment: - filename = thu_learn_lib.utils.slugify( - f"ans-{homework.title}{os.path.splitext(homework.answer_attachment.name)[-1]}" - ) - self.schedule_download( - url=homework.answer_attachment.download_url, - prefix=prefix, - filename=filename, - ) - lines.append(f"### Attach.") - lines.append(f"") - lines.append(f"[{homework.answer_attachment.name}]({filename})") - lines.append(f"") - lines.append(f"### Deadline (GMT+8)") - lines.append(f"") - lines.append(f"{homework.deadline.isoformat() if homework.deadline else None}") - lines.append(f"") - if self.sync_submit: - lines.append(f"## My coursework submitted") - lines.append(f"") - lines.append(f"### Content") - lines.append(f"") - lines.append(f"{homework.submitted_content}") - lines.append(f"") - if homework.submitted_attachment: - filename = thu_learn_lib.utils.slugify( - f"submit-{homework.title}{os.path.splitext(homework.submitted_attachment.name)[-1]}" - ) - self.schedule_download( - url=homework.submitted_attachment.download_url, - prefix=prefix, - filename=filename, - ) - lines.append(f"### Attach.") - lines.append(f"") - lines.append(f"[{homework.submitted_attachment.name}]({filename})") - lines.append(f"") - lines.append(f"## Instructors' comments") - lines.append(f"") - lines.append(f"### By") - lines.append(f"") - lines.append(f"{homework.grader_name}") - lines.append(f"") - lines.append(f"### Date") - lines.append(f"") - lines.append( - f"{homework.grade_time.isoformat() if homework.grade_time else None}" - ) - lines.append(f"") - lines.append(f"### Grade") - lines.append(f"") - lines.append(f"{homework.grade}") - lines.append(f"") - lines.append(f"### Comment") - lines.append(f"") - lines.append(f"{homework.grade_content}") - lines.append(f"") - if homework.grade_attachment: - filename = thu_learn_lib.utils.slugify( - f"comment-{homework.title}{os.path.splitext(homework.grade_attachment.name)[-1]}" - ) - self.schedule_download( - url=homework.grade_attachment.download_url, - prefix=prefix, - filename=filename, - ) - lines.append(f"### Attach.") - lines.append(f"") - lines.append(f"[{homework.grade_attachment.name}]({filename})") - lines.append(f"") - lines = [line + "\n" for line in lines] - filename = "README.md" - with open(os.path.join(prefix, filename), "w") as file: - file.writelines(lines) diff --git a/identity.yaml b/identity.yaml deleted file mode 100644 index de48fa7..0000000 --- a/identity.yaml +++ /dev/null @@ -1,9 +0,0 @@ -username: "*******" -password: "**************" -prefix: "/home/liblaf/Desktop/thu-course/" -file_size_limit: null -sync_docs: true -sync_work: true -sync_submit: true -semesters: - - "2022-2023-1" diff --git a/main.py b/main.py deleted file mode 100644 index 2c97002..0000000 --- a/main.py +++ /dev/null @@ -1,35 +0,0 @@ -import hydra -import omegaconf -import tqdm - -from downloader import Downloader - - -@hydra.main(config_path=".", config_name="identity.yaml", version_base="1.2") -def main(cfg: omegaconf.DictConfig): - downloader = Downloader( - username=cfg["username"], - password=cfg["password"], - prefix=cfg["prefix"], - file_size_limit=cfg["file_size_limit"], - sync_docs=cfg["sync_docs"], - sync_work=cfg["sync_work"], - sync_submit=cfg["sync_submit"], - ) - semester_id_list: list[str] = downloader.helper.get_semester_id_list() - semesters: list[str] = cfg["semesters"] if cfg["semesters"] else semester_id_list - for semester in tqdm.tqdm( - iterable=semesters, - desc="semesters", - leave=False, - dynamic_ncols=True, - position=0, - ): - if semester in semester_id_list: - downloader.sync_semester(semester_id=semester) - else: - print(f"{semester} not found") - - -if __name__ == "__main__": - main() diff --git a/poetry.lock b/poetry.lock new file mode 100644 index 0000000..e6046d9 --- /dev/null +++ b/poetry.lock @@ -0,0 +1,945 @@ +[[package]] +name = "altgraph" +version = "0.17.3" +description = "Python graph (network) package" +category = "dev" +optional = false +python-versions = "*" + +[[package]] +name = "antlr4-python3-runtime" +version = "4.9.3" +description = "ANTLR 4.9.3 runtime for Python 3.7" +category = "main" +optional = false +python-versions = "*" + +[[package]] +name = "Babel" +version = "2.11.0" +description = "Internationalization utilities" +category = "dev" +optional = false +python-versions = ">=3.6" + +[package.dependencies] +pytz = ">=2015.7" + +[[package]] +name = "beautifulsoup4" +version = "4.11.1" +description = "Screen-scraping library" +category = "main" +optional = false +python-versions = ">=3.6.0" + +[package.dependencies] +soupsieve = ">1.2" + +[package.extras] +html5lib = ["html5lib"] +lxml = ["lxml"] + +[[package]] +name = "certifi" +version = "2022.9.24" +description = "Python package for providing Mozilla's CA Bundle." +category = "main" +optional = false +python-versions = ">=3.6" + +[[package]] +name = "charset-normalizer" +version = "2.1.1" +description = "The Real First Universal Charset Detector. Open, modern and actively maintained alternative to Chardet." +category = "main" +optional = false +python-versions = ">=3.6.0" + +[package.extras] +unicode_backport = ["unicodedata2"] + +[[package]] +name = "click" +version = "8.1.3" +description = "Composable command line interface toolkit" +category = "dev" +optional = false +python-versions = ">=3.7" + +[package.dependencies] +colorama = {version = "*", markers = "platform_system == \"Windows\""} + +[[package]] +name = "colorama" +version = "0.4.6" +description = "Cross-platform colored terminal text." +category = "dev" +optional = false +python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7" + +[[package]] +name = "commonmark" +version = "0.9.1" +description = "Python parser for the CommonMark Markdown spec" +category = "main" +optional = false +python-versions = "*" + +[package.extras] +test = ["flake8 (==3.7.8)", "hypothesis (==3.55.3)"] + +[[package]] +name = "future" +version = "0.18.2" +description = "Clean single-source support for Python 3 and 2" +category = "dev" +optional = false +python-versions = ">=2.6, !=3.0.*, !=3.1.*, !=3.2.*" + +[[package]] +name = "ghp-import" +version = "2.1.0" +description = "Copy your docs directly to the gh-pages branch." +category = "dev" +optional = false +python-versions = "*" + +[package.dependencies] +python-dateutil = ">=2.8.1" + +[package.extras] +dev = ["flake8", "markdown", "twine", "wheel"] + +[[package]] +name = "gitdb" +version = "4.0.9" +description = "Git Object Database" +category = "dev" +optional = false +python-versions = ">=3.6" + +[package.dependencies] +smmap = ">=3.0.1,<6" + +[[package]] +name = "GitPython" +version = "3.1.29" +description = "GitPython is a python library used to interact with Git repositories" +category = "dev" +optional = false +python-versions = ">=3.7" + +[package.dependencies] +gitdb = ">=4.0.1,<5" + +[[package]] +name = "hydra-core" +version = "1.2.0" +description = "A framework for elegantly configuring complex applications" +category = "main" +optional = false +python-versions = "*" + +[package.dependencies] +antlr4-python3-runtime = ">=4.9.0,<4.10.0" +omegaconf = ">=2.2,<3.0" +packaging = "*" + +[[package]] +name = "idna" +version = "3.4" +description = "Internationalized Domain Names in Applications (IDNA)" +category = "main" +optional = false +python-versions = ">=3.5" + +[[package]] +name = "Jinja2" +version = "3.1.2" +description = "A very fast and expressive template engine." +category = "dev" +optional = false +python-versions = ">=3.7" + +[package.dependencies] +MarkupSafe = ">=2.0" + +[package.extras] +i18n = ["Babel (>=2.7)"] + +[[package]] +name = "lxml" +version = "4.9.1" +description = "Powerful and Pythonic XML processing library combining libxml2/libxslt with the ElementTree API." +category = "dev" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, != 3.4.*" + +[package.extras] +cssselect = ["cssselect (>=0.7)"] +html5 = ["html5lib"] +htmlsoup = ["BeautifulSoup4"] +source = ["Cython (>=0.29.7)"] + +[[package]] +name = "macholib" +version = "1.16.2" +description = "Mach-O header analysis and editing" +category = "dev" +optional = false +python-versions = "*" + +[package.dependencies] +altgraph = ">=0.17" + +[[package]] +name = "Markdown" +version = "3.3.7" +description = "Python implementation of Markdown." +category = "dev" +optional = false +python-versions = ">=3.6" + +[package.extras] +testing = ["coverage", "pyyaml"] + +[[package]] +name = "MarkupSafe" +version = "2.1.1" +description = "Safely add untrusted strings to HTML/XML markup." +category = "dev" +optional = false +python-versions = ">=3.7" + +[[package]] +name = "mergedeep" +version = "1.3.4" +description = "A deep merge function for 🐍." +category = "dev" +optional = false +python-versions = ">=3.6" + +[[package]] +name = "mkdocs" +version = "1.4.2" +description = "Project documentation with Markdown." +category = "dev" +optional = false +python-versions = ">=3.7" + +[package.dependencies] +click = ">=7.0" +colorama = {version = ">=0.4", markers = "platform_system == \"Windows\""} +ghp-import = ">=1.0" +jinja2 = ">=2.11.1" +markdown = ">=3.2.1,<3.4" +mergedeep = ">=1.3.4" +packaging = ">=20.5" +pyyaml = ">=5.1" +pyyaml-env-tag = ">=0.1" +watchdog = ">=2.0" + +[package.extras] +i18n = ["babel (>=2.9.0)"] +min-versions = ["babel (==2.9.0)", "click (==7.0)", "colorama (==0.4)", "ghp-import (==1.0)", "importlib-metadata (==4.3)", "jinja2 (==2.11.1)", "markdown (==3.2.1)", "markupsafe (==2.0.1)", "mergedeep (==1.3.4)", "packaging (==20.5)", "pyyaml (==5.1)", "pyyaml-env-tag (==0.1)", "typing-extensions (==3.10)", "watchdog (==2.0)"] + +[[package]] +name = "mkdocs-git-authors-plugin" +version = "0.6.5" +description = "Mkdocs plugin to display git authors of a page" +category = "dev" +optional = false +python-versions = ">=3.6" + +[package.dependencies] +mkdocs = ">=1.0" + +[[package]] +name = "mkdocs-git-committers-plugin-2" +version = "1.1.1" +description = "An MkDocs plugin to create a list of contributors on the page" +category = "dev" +optional = false +python-versions = ">=2.7" + +[package.dependencies] +beautifulsoup4 = "*" +gitpython = "*" +mkdocs = ">=1.0.3" +requests = "*" + +[[package]] +name = "mkdocs-git-revision-date-localized-plugin" +version = "1.1.0" +description = "Mkdocs plugin that enables displaying the localized date of the last git modification of a markdown file." +category = "dev" +optional = false +python-versions = ">=3.6" + +[package.dependencies] +babel = ">=2.7.0" +GitPython = "*" +mkdocs = ">=1.0" + +[[package]] +name = "mkdocs-material" +version = "8.5.10" +description = "Documentation that simply works" +category = "dev" +optional = false +python-versions = ">=3.7" + +[package.dependencies] +jinja2 = ">=3.0.2" +markdown = ">=3.2" +mkdocs = ">=1.4.0" +mkdocs-material-extensions = ">=1.1" +pygments = ">=2.12" +pymdown-extensions = ">=9.4" +requests = ">=2.26" + +[[package]] +name = "mkdocs-material-extensions" +version = "1.1" +description = "Extension pack for Python Markdown and MkDocs Material." +category = "dev" +optional = false +python-versions = ">=3.7" + +[[package]] +name = "omegaconf" +version = "2.2.3" +description = "A flexible configuration library" +category = "main" +optional = false +python-versions = ">=3.6" + +[package.dependencies] +antlr4-python3-runtime = ">=4.9.0,<4.10.0" +PyYAML = ">=5.1.0" + +[[package]] +name = "packaging" +version = "21.3" +description = "Core utilities for Python packages" +category = "main" +optional = false +python-versions = ">=3.6" + +[package.dependencies] +pyparsing = ">=2.0.2,<3.0.5 || >3.0.5" + +[[package]] +name = "pefile" +version = "2022.5.30" +description = "Python PE parsing module" +category = "dev" +optional = false +python-versions = ">=3.6.0" + +[package.dependencies] +future = "*" + +[[package]] +name = "Pygments" +version = "2.13.0" +description = "Pygments is a syntax highlighting package written in Python." +category = "main" +optional = false +python-versions = ">=3.6" + +[package.extras] +plugins = ["importlib-metadata"] + +[[package]] +name = "pyinstaller" +version = "5.6.2" +description = "PyInstaller bundles a Python application and all its dependencies into a single package." +category = "dev" +optional = false +python-versions = "<3.12,>=3.7" + +[package.dependencies] +altgraph = "*" +macholib = {version = ">=1.8", markers = "sys_platform == \"darwin\""} +pefile = {version = ">=2022.5.30", markers = "sys_platform == \"win32\""} +pyinstaller-hooks-contrib = ">=2021.4" +pywin32-ctypes = {version = ">=0.2.0", markers = "sys_platform == \"win32\""} +setuptools = "*" + +[package.extras] +encryption = ["tinyaes (>=1.0.0)"] +hook_testing = ["execnet (>=1.5.0)", "psutil", "pytest (>=2.7.3)"] + +[[package]] +name = "pyinstaller-hooks-contrib" +version = "2022.13" +description = "Community maintained hooks for PyInstaller" +category = "dev" +optional = false +python-versions = ">=3.7" + +[[package]] +name = "pymdown-extensions" +version = "9.8" +description = "Extension pack for Python Markdown." +category = "dev" +optional = false +python-versions = ">=3.7" + +[package.dependencies] +markdown = ">=3.2" + +[[package]] +name = "pyparsing" +version = "3.0.9" +description = "pyparsing module - Classes and methods to define and execute parsing grammars" +category = "main" +optional = false +python-versions = ">=3.6.8" + +[package.extras] +diagrams = ["jinja2", "railroad-diagrams"] + +[[package]] +name = "python-dateutil" +version = "2.8.2" +description = "Extensions to the standard Python datetime module" +category = "dev" +optional = false +python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,>=2.7" + +[package.dependencies] +six = ">=1.5" + +[[package]] +name = "python-slugify" +version = "6.1.2" +description = "A Python slugify application that also handles Unicode" +category = "main" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*, !=3.5.*" + +[package.dependencies] +text-unidecode = ">=1.3" + +[package.extras] +unidecode = ["Unidecode (>=1.1.1)"] + +[[package]] +name = "pytz" +version = "2022.6" +description = "World timezone definitions, modern and historical" +category = "dev" +optional = false +python-versions = "*" + +[[package]] +name = "pywin32-ctypes" +version = "0.2.0" +description = "" +category = "dev" +optional = false +python-versions = "*" + +[[package]] +name = "PyYAML" +version = "6.0" +description = "YAML parser and emitter for Python" +category = "main" +optional = false +python-versions = ">=3.6" + +[[package]] +name = "pyyaml_env_tag" +version = "0.1" +description = "A custom YAML tag for referencing environment variables in YAML files. " +category = "dev" +optional = false +python-versions = ">=3.6" + +[package.dependencies] +pyyaml = "*" + +[[package]] +name = "requests" +version = "2.28.1" +description = "Python HTTP for Humans." +category = "main" +optional = false +python-versions = ">=3.7, <4" + +[package.dependencies] +certifi = ">=2017.4.17" +charset-normalizer = ">=2,<3" +idna = ">=2.5,<4" +urllib3 = ">=1.21.1,<1.27" + +[package.extras] +socks = ["PySocks (>=1.5.6,!=1.5.7)"] +use_chardet_on_py3 = ["chardet (>=3.0.2,<6)"] + +[[package]] +name = "rich" +version = "12.6.0" +description = "Render rich text, tables, progress bars, syntax highlighting, markdown and more to the terminal" +category = "main" +optional = false +python-versions = ">=3.6.3,<4.0.0" + +[package.dependencies] +commonmark = ">=0.9.0,<0.10.0" +pygments = ">=2.6.0,<3.0.0" + +[package.extras] +jupyter = ["ipywidgets (>=7.5.1,<8.0.0)"] + +[[package]] +name = "setuptools" +version = "65.5.1" +description = "Easily download, build, install, upgrade, and uninstall Python packages" +category = "dev" +optional = false +python-versions = ">=3.7" + +[package.extras] +docs = ["furo", "jaraco.packaging (>=9)", "jaraco.tidelift (>=1.4)", "pygments-github-lexers (==0.0.5)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-favicon", "sphinx-hoverxref (<2)", "sphinx-inline-tabs", "sphinx-notfound-page (==0.8.3)", "sphinx-reredirects", "sphinxcontrib-towncrier"] +testing = ["build[virtualenv]", "filelock (>=3.4.0)", "flake8 (<5)", "flake8-2020", "ini2toml[lite] (>=0.9)", "jaraco.envs (>=2.2)", "jaraco.path (>=3.2.0)", "pip (>=19.1)", "pip-run (>=8.8)", "pytest (>=6)", "pytest-black (>=0.3.7)", "pytest-checkdocs (>=2.4)", "pytest-cov", "pytest-enabler (>=1.3)", "pytest-flake8", "pytest-mypy (>=0.9.1)", "pytest-perf", "pytest-timeout", "pytest-xdist", "tomli-w (>=1.0.0)", "virtualenv (>=13.0.0)", "wheel"] +testing-integration = ["build[virtualenv]", "filelock (>=3.4.0)", "jaraco.envs (>=2.2)", "jaraco.path (>=3.2.0)", "pytest", "pytest-enabler", "pytest-xdist", "tomli", "virtualenv (>=13.0.0)", "wheel"] + +[[package]] +name = "six" +version = "1.16.0" +description = "Python 2 and 3 compatibility utilities" +category = "dev" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*" + +[[package]] +name = "smmap" +version = "5.0.0" +description = "A pure Python implementation of a sliding window memory map manager" +category = "dev" +optional = false +python-versions = ">=3.6" + +[[package]] +name = "soupsieve" +version = "2.3.2.post1" +description = "A modern CSS selector implementation for Beautiful Soup." +category = "main" +optional = false +python-versions = ">=3.6" + +[[package]] +name = "text-unidecode" +version = "1.3" +description = "The most basic Text::Unidecode port" +category = "main" +optional = false +python-versions = "*" + +[[package]] +name = "urllib3" +version = "1.26.12" +description = "HTTP library with thread-safe connection pooling, file post, and more." +category = "main" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*, !=3.5.*, <4" + +[package.extras] +brotli = ["brotli (>=1.0.9)", "brotlicffi (>=0.8.0)", "brotlipy (>=0.6.0)"] +secure = ["certifi", "cryptography (>=1.3.4)", "idna (>=2.0.0)", "ipaddress", "pyOpenSSL (>=0.14)", "urllib3-secure-extra"] +socks = ["PySocks (>=1.5.6,!=1.5.7,<2.0)"] + +[[package]] +name = "watchdog" +version = "2.1.9" +description = "Filesystem events monitoring" +category = "dev" +optional = false +python-versions = ">=3.6" + +[package.extras] +watchmedo = ["PyYAML (>=3.10)"] + +[metadata] +lock-version = "1.1" +python-versions = ">=3.10,<3.12" +content-hash = "28cad533c85919d3a1ffeb3f51f3a2adcc15e6d8fb1d883a6f4f651fca329663" + +[metadata.files] +altgraph = [ + {file = "altgraph-0.17.3-py2.py3-none-any.whl", hash = "sha256:c8ac1ca6772207179ed8003ce7687757c04b0b71536f81e2ac5755c6226458fe"}, + {file = "altgraph-0.17.3.tar.gz", hash = "sha256:ad33358114df7c9416cdb8fa1eaa5852166c505118717021c6a8c7c7abbd03dd"}, +] +antlr4-python3-runtime = [ + {file = "antlr4-python3-runtime-4.9.3.tar.gz", hash = "sha256:f224469b4168294902bb1efa80a8bf7855f24c99aef99cbefc1bcd3cce77881b"}, +] +Babel = [ + {file = "Babel-2.11.0-py3-none-any.whl", hash = "sha256:1ad3eca1c885218f6dce2ab67291178944f810a10a9b5f3cb8382a5a232b64fe"}, + {file = "Babel-2.11.0.tar.gz", hash = "sha256:5ef4b3226b0180dedded4229651c8b0e1a3a6a2837d45a073272f313e4cf97f6"}, +] +beautifulsoup4 = [ + {file = "beautifulsoup4-4.11.1-py3-none-any.whl", hash = "sha256:58d5c3d29f5a36ffeb94f02f0d786cd53014cf9b3b3951d42e0080d8a9498d30"}, + {file = "beautifulsoup4-4.11.1.tar.gz", hash = "sha256:ad9aa55b65ef2808eb405f46cf74df7fcb7044d5cbc26487f96eb2ef2e436693"}, +] +certifi = [ + {file = "certifi-2022.9.24-py3-none-any.whl", hash = "sha256:90c1a32f1d68f940488354e36370f6cca89f0f106db09518524c88d6ed83f382"}, + {file = "certifi-2022.9.24.tar.gz", hash = "sha256:0d9c601124e5a6ba9712dbc60d9c53c21e34f5f641fe83002317394311bdce14"}, +] +charset-normalizer = [ + {file = "charset-normalizer-2.1.1.tar.gz", hash = "sha256:5a3d016c7c547f69d6f81fb0db9449ce888b418b5b9952cc5e6e66843e9dd845"}, + {file = "charset_normalizer-2.1.1-py3-none-any.whl", hash = "sha256:83e9a75d1911279afd89352c68b45348559d1fc0506b054b346651b5e7fee29f"}, +] +click = [ + {file = "click-8.1.3-py3-none-any.whl", hash = "sha256:bb4d8133cb15a609f44e8213d9b391b0809795062913b383c62be0ee95b1db48"}, + {file = "click-8.1.3.tar.gz", hash = "sha256:7682dc8afb30297001674575ea00d1814d808d6a36af415a82bd481d37ba7b8e"}, +] +colorama = [ + {file = "colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6"}, + {file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"}, +] +commonmark = [ + {file = "commonmark-0.9.1-py2.py3-none-any.whl", hash = "sha256:da2f38c92590f83de410ba1a3cbceafbc74fee9def35f9251ba9a971d6d66fd9"}, + {file = "commonmark-0.9.1.tar.gz", hash = "sha256:452f9dc859be7f06631ddcb328b6919c67984aca654e5fefb3914d54691aed60"}, +] +future = [ + {file = "future-0.18.2.tar.gz", hash = "sha256:b1bead90b70cf6ec3f0710ae53a525360fa360d306a86583adc6bf83a4db537d"}, +] +ghp-import = [ + {file = "ghp-import-2.1.0.tar.gz", hash = "sha256:9c535c4c61193c2df8871222567d7fd7e5014d835f97dc7b7439069e2413d343"}, + {file = "ghp_import-2.1.0-py3-none-any.whl", hash = "sha256:8337dd7b50877f163d4c0289bc1f1c7f127550241988d568c1db512c4324a619"}, +] +gitdb = [ + {file = "gitdb-4.0.9-py3-none-any.whl", hash = "sha256:8033ad4e853066ba6ca92050b9df2f89301b8fc8bf7e9324d412a63f8bf1a8fd"}, + {file = "gitdb-4.0.9.tar.gz", hash = "sha256:bac2fd45c0a1c9cf619e63a90d62bdc63892ef92387424b855792a6cabe789aa"}, +] +GitPython = [ + {file = "GitPython-3.1.29-py3-none-any.whl", hash = "sha256:41eea0deec2deea139b459ac03656f0dd28fc4a3387240ec1d3c259a2c47850f"}, + {file = "GitPython-3.1.29.tar.gz", hash = "sha256:cc36bfc4a3f913e66805a28e84703e419d9c264c1077e537b54f0e1af85dbefd"}, +] +hydra-core = [ + {file = "hydra-core-1.2.0.tar.gz", hash = "sha256:4990721ce4ac69abafaffee566d6b63a54faa6501ecce65b338d3251446ff634"}, + {file = "hydra_core-1.2.0-py3-none-any.whl", hash = "sha256:b6614fd6d6a97a9499f7ddbef02c9dd38f2fec6a9bc83c10e248db1dae50a528"}, +] +idna = [ + {file = "idna-3.4-py3-none-any.whl", hash = "sha256:90b77e79eaa3eba6de819a0c442c0b4ceefc341a7a2ab77d7562bf49f425c5c2"}, + {file = "idna-3.4.tar.gz", hash = "sha256:814f528e8dead7d329833b91c5faa87d60bf71824cd12a7530b5526063d02cb4"}, +] +Jinja2 = [ + {file = "Jinja2-3.1.2-py3-none-any.whl", hash = "sha256:6088930bfe239f0e6710546ab9c19c9ef35e29792895fed6e6e31a023a182a61"}, + {file = "Jinja2-3.1.2.tar.gz", hash = "sha256:31351a702a408a9e7595a8fc6150fc3f43bb6bf7e319770cbc0db9df9437e852"}, +] +lxml = [ + {file = "lxml-4.9.1-cp27-cp27m-macosx_10_15_x86_64.whl", hash = "sha256:98cafc618614d72b02185ac583c6f7796202062c41d2eeecdf07820bad3295ed"}, + {file = "lxml-4.9.1-cp27-cp27m-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c62e8dd9754b7debda0c5ba59d34509c4688f853588d75b53c3791983faa96fc"}, + {file = "lxml-4.9.1-cp27-cp27m-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:21fb3d24ab430fc538a96e9fbb9b150029914805d551deeac7d7822f64631dfc"}, + {file = "lxml-4.9.1-cp27-cp27m-win32.whl", hash = "sha256:86e92728ef3fc842c50a5cb1d5ba2bc66db7da08a7af53fb3da79e202d1b2cd3"}, + {file = "lxml-4.9.1-cp27-cp27m-win_amd64.whl", hash = "sha256:4cfbe42c686f33944e12f45a27d25a492cc0e43e1dc1da5d6a87cbcaf2e95627"}, + {file = "lxml-4.9.1-cp27-cp27mu-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:dad7b164905d3e534883281c050180afcf1e230c3d4a54e8038aa5cfcf312b84"}, + {file = "lxml-4.9.1-cp27-cp27mu-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:a614e4afed58c14254e67862456d212c4dcceebab2eaa44d627c2ca04bf86837"}, + {file = "lxml-4.9.1-cp310-cp310-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_24_i686.whl", hash = "sha256:f9ced82717c7ec65a67667bb05865ffe38af0e835cdd78728f1209c8fffe0cad"}, + {file = "lxml-4.9.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_24_aarch64.whl", hash = "sha256:d9fc0bf3ff86c17348dfc5d322f627d78273eba545db865c3cd14b3f19e57fa5"}, + {file = "lxml-4.9.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_24_x86_64.whl", hash = "sha256:e5f66bdf0976ec667fc4594d2812a00b07ed14d1b44259d19a41ae3fff99f2b8"}, + {file = "lxml-4.9.1-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:fe17d10b97fdf58155f858606bddb4e037b805a60ae023c009f760d8361a4eb8"}, + {file = "lxml-4.9.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:8caf4d16b31961e964c62194ea3e26a0e9561cdf72eecb1781458b67ec83423d"}, + {file = "lxml-4.9.1-cp310-cp310-win32.whl", hash = "sha256:4780677767dd52b99f0af1f123bc2c22873d30b474aa0e2fc3fe5e02217687c7"}, + {file = "lxml-4.9.1-cp310-cp310-win_amd64.whl", hash = "sha256:b122a188cd292c4d2fcd78d04f863b789ef43aa129b233d7c9004de08693728b"}, + {file = "lxml-4.9.1-cp311-cp311-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_24_i686.whl", hash = "sha256:be9eb06489bc975c38706902cbc6888f39e946b81383abc2838d186f0e8b6a9d"}, + {file = "lxml-4.9.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_24_x86_64.whl", hash = "sha256:f1be258c4d3dc609e654a1dc59d37b17d7fef05df912c01fc2e15eb43a9735f3"}, + {file = "lxml-4.9.1-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:927a9dd016d6033bc12e0bf5dee1dde140235fc8d0d51099353c76081c03dc29"}, + {file = "lxml-4.9.1-cp35-cp35m-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:9232b09f5efee6a495a99ae6824881940d6447debe272ea400c02e3b68aad85d"}, + {file = "lxml-4.9.1-cp35-cp35m-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:04da965dfebb5dac2619cb90fcf93efdb35b3c6994fea58a157a834f2f94b318"}, + {file = "lxml-4.9.1-cp35-cp35m-win32.whl", hash = "sha256:4d5bae0a37af799207140652a700f21a85946f107a199bcb06720b13a4f1f0b7"}, + {file = "lxml-4.9.1-cp35-cp35m-win_amd64.whl", hash = "sha256:4878e667ebabe9b65e785ac8da4d48886fe81193a84bbe49f12acff8f7a383a4"}, + {file = "lxml-4.9.1-cp36-cp36m-macosx_10_15_x86_64.whl", hash = "sha256:1355755b62c28950f9ce123c7a41460ed9743c699905cbe664a5bcc5c9c7c7fb"}, + {file = "lxml-4.9.1-cp36-cp36m-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_24_i686.whl", hash = "sha256:bcaa1c495ce623966d9fc8a187da80082334236a2a1c7e141763ffaf7a405067"}, + {file = "lxml-4.9.1-cp36-cp36m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6eafc048ea3f1b3c136c71a86db393be36b5b3d9c87b1c25204e7d397cee9536"}, + {file = "lxml-4.9.1-cp36-cp36m-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_24_x86_64.whl", hash = "sha256:13c90064b224e10c14dcdf8086688d3f0e612db53766e7478d7754703295c7c8"}, + {file = "lxml-4.9.1-cp36-cp36m-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:206a51077773c6c5d2ce1991327cda719063a47adc02bd703c56a662cdb6c58b"}, + {file = "lxml-4.9.1-cp36-cp36m-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:e8f0c9d65da595cfe91713bc1222af9ecabd37971762cb830dea2fc3b3bb2acf"}, + {file = "lxml-4.9.1-cp36-cp36m-musllinux_1_1_aarch64.whl", hash = "sha256:8f0a4d179c9a941eb80c3a63cdb495e539e064f8054230844dcf2fcb812b71d3"}, + {file = "lxml-4.9.1-cp36-cp36m-musllinux_1_1_x86_64.whl", hash = "sha256:830c88747dce8a3e7525defa68afd742b4580df6aa2fdd6f0855481e3994d391"}, + {file = "lxml-4.9.1-cp36-cp36m-win32.whl", hash = "sha256:1e1cf47774373777936c5aabad489fef7b1c087dcd1f426b621fda9dcc12994e"}, + {file = "lxml-4.9.1-cp36-cp36m-win_amd64.whl", hash = "sha256:5974895115737a74a00b321e339b9c3f45c20275d226398ae79ac008d908bff7"}, + {file = "lxml-4.9.1-cp37-cp37m-macosx_10_15_x86_64.whl", hash = "sha256:1423631e3d51008871299525b541413c9b6c6423593e89f9c4cfbe8460afc0a2"}, + {file = "lxml-4.9.1-cp37-cp37m-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_24_i686.whl", hash = "sha256:2aaf6a0a6465d39b5ca69688fce82d20088c1838534982996ec46633dc7ad6cc"}, + {file = "lxml-4.9.1-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_24_aarch64.whl", hash = "sha256:9f36de4cd0c262dd9927886cc2305aa3f2210db437aa4fed3fb4940b8bf4592c"}, + {file = "lxml-4.9.1-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_24_x86_64.whl", hash = "sha256:ae06c1e4bc60ee076292e582a7512f304abdf6c70db59b56745cca1684f875a4"}, + {file = "lxml-4.9.1-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:57e4d637258703d14171b54203fd6822fda218c6c2658a7d30816b10995f29f3"}, + {file = "lxml-4.9.1-cp37-cp37m-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:6d279033bf614953c3fc4a0aa9ac33a21e8044ca72d4fa8b9273fe75359d5cca"}, + {file = "lxml-4.9.1-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:a60f90bba4c37962cbf210f0188ecca87daafdf60271f4c6948606e4dabf8785"}, + {file = "lxml-4.9.1-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:6ca2264f341dd81e41f3fffecec6e446aa2121e0b8d026fb5130e02de1402785"}, + {file = "lxml-4.9.1-cp37-cp37m-win32.whl", hash = "sha256:27e590352c76156f50f538dbcebd1925317a0f70540f7dc8c97d2931c595783a"}, + {file = "lxml-4.9.1-cp37-cp37m-win_amd64.whl", hash = "sha256:eea5d6443b093e1545ad0210e6cf27f920482bfcf5c77cdc8596aec73523bb7e"}, + {file = "lxml-4.9.1-cp38-cp38-macosx_10_15_x86_64.whl", hash = "sha256:f05251bbc2145349b8d0b77c0d4e5f3b228418807b1ee27cefb11f69ed3d233b"}, + {file = "lxml-4.9.1-cp38-cp38-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_24_i686.whl", hash = "sha256:487c8e61d7acc50b8be82bda8c8d21d20e133c3cbf41bd8ad7eb1aaeb3f07c97"}, + {file = "lxml-4.9.1-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_24_aarch64.whl", hash = "sha256:8d1a92d8e90b286d491e5626af53afef2ba04da33e82e30744795c71880eaa21"}, + {file = "lxml-4.9.1-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_24_x86_64.whl", hash = "sha256:b570da8cd0012f4af9fa76a5635cd31f707473e65a5a335b186069d5c7121ff2"}, + {file = "lxml-4.9.1-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:5ef87fca280fb15342726bd5f980f6faf8b84a5287fcc2d4962ea8af88b35130"}, + {file = "lxml-4.9.1-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:93e414e3206779ef41e5ff2448067213febf260ba747fc65389a3ddaa3fb8715"}, + {file = "lxml-4.9.1-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:6653071f4f9bac46fbc30f3c7838b0e9063ee335908c5d61fb7a4a86c8fd2036"}, + {file = "lxml-4.9.1-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:32a73c53783becdb7eaf75a2a1525ea8e49379fb7248c3eeefb9412123536387"}, + {file = "lxml-4.9.1-cp38-cp38-win32.whl", hash = "sha256:1a7c59c6ffd6ef5db362b798f350e24ab2cfa5700d53ac6681918f314a4d3b94"}, + {file = "lxml-4.9.1-cp38-cp38-win_amd64.whl", hash = "sha256:1436cf0063bba7888e43f1ba8d58824f085410ea2025befe81150aceb123e345"}, + {file = "lxml-4.9.1-cp39-cp39-macosx_10_15_x86_64.whl", hash = "sha256:4beea0f31491bc086991b97517b9683e5cfb369205dac0148ef685ac12a20a67"}, + {file = "lxml-4.9.1-cp39-cp39-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_24_i686.whl", hash = "sha256:41fb58868b816c202e8881fd0f179a4644ce6e7cbbb248ef0283a34b73ec73bb"}, + {file = "lxml-4.9.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_24_aarch64.whl", hash = "sha256:bd34f6d1810d9354dc7e35158aa6cc33456be7706df4420819af6ed966e85448"}, + {file = "lxml-4.9.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_24_x86_64.whl", hash = "sha256:edffbe3c510d8f4bf8640e02ca019e48a9b72357318383ca60e3330c23aaffc7"}, + {file = "lxml-4.9.1-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:6d949f53ad4fc7cf02c44d6678e7ff05ec5f5552b235b9e136bd52e9bf730b91"}, + {file = "lxml-4.9.1-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:079b68f197c796e42aa80b1f739f058dcee796dc725cc9a1be0cdb08fc45b000"}, + {file = "lxml-4.9.1-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:9c3a88d20e4fe4a2a4a84bf439a5ac9c9aba400b85244c63a1ab7088f85d9d25"}, + {file = "lxml-4.9.1-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:4e285b5f2bf321fc0857b491b5028c5f276ec0c873b985d58d7748ece1d770dd"}, + {file = "lxml-4.9.1-cp39-cp39-win32.whl", hash = "sha256:ef72013e20dd5ba86a8ae1aed7f56f31d3374189aa8b433e7b12ad182c0d2dfb"}, + {file = "lxml-4.9.1-cp39-cp39-win_amd64.whl", hash = "sha256:10d2017f9150248563bb579cd0d07c61c58da85c922b780060dcc9a3aa9f432d"}, + {file = "lxml-4.9.1-pp37-pypy37_pp73-macosx_10_15_x86_64.whl", hash = "sha256:0538747a9d7827ce3e16a8fdd201a99e661c7dee3c96c885d8ecba3c35d1032c"}, + {file = "lxml-4.9.1-pp37-pypy37_pp73-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_24_i686.whl", hash = "sha256:0645e934e940107e2fdbe7c5b6fb8ec6232444260752598bc4d09511bd056c0b"}, + {file = "lxml-4.9.1-pp37-pypy37_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_24_x86_64.whl", hash = "sha256:6daa662aba22ef3258934105be2dd9afa5bb45748f4f702a3b39a5bf53a1f4dc"}, + {file = "lxml-4.9.1-pp38-pypy38_pp73-macosx_10_15_x86_64.whl", hash = "sha256:603a464c2e67d8a546ddaa206d98e3246e5db05594b97db844c2f0a1af37cf5b"}, + {file = "lxml-4.9.1-pp38-pypy38_pp73-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_24_i686.whl", hash = "sha256:c4b2e0559b68455c085fb0f6178e9752c4be3bba104d6e881eb5573b399d1eb2"}, + {file = "lxml-4.9.1-pp38-pypy38_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_24_x86_64.whl", hash = "sha256:0f3f0059891d3254c7b5fb935330d6db38d6519ecd238ca4fce93c234b4a0f73"}, + {file = "lxml-4.9.1-pp39-pypy39_pp73-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_24_i686.whl", hash = "sha256:c852b1530083a620cb0de5f3cd6826f19862bafeaf77586f1aef326e49d95f0c"}, + {file = "lxml-4.9.1-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_24_x86_64.whl", hash = "sha256:287605bede6bd36e930577c5925fcea17cb30453d96a7b4c63c14a257118dbb9"}, + {file = "lxml-4.9.1.tar.gz", hash = "sha256:fe749b052bb7233fe5d072fcb549221a8cb1a16725c47c37e42b0b9cb3ff2c3f"}, +] +macholib = [ + {file = "macholib-1.16.2-py2.py3-none-any.whl", hash = "sha256:44c40f2cd7d6726af8fa6fe22549178d3a4dfecc35a9cd15ea916d9c83a688e0"}, + {file = "macholib-1.16.2.tar.gz", hash = "sha256:557bbfa1bb255c20e9abafe7ed6cd8046b48d9525db2f9b77d3122a63a2a8bf8"}, +] +Markdown = [ + {file = "Markdown-3.3.7-py3-none-any.whl", hash = "sha256:f5da449a6e1c989a4cea2631aa8ee67caa5a2ef855d551c88f9e309f4634c621"}, + {file = "Markdown-3.3.7.tar.gz", hash = "sha256:cbb516f16218e643d8e0a95b309f77eb118cb138d39a4f27851e6a63581db874"}, +] +MarkupSafe = [ + {file = "MarkupSafe-2.1.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:86b1f75c4e7c2ac2ccdaec2b9022845dbb81880ca318bb7a0a01fbf7813e3812"}, + {file = "MarkupSafe-2.1.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:f121a1420d4e173a5d96e47e9a0c0dcff965afdf1626d28de1460815f7c4ee7a"}, + {file = "MarkupSafe-2.1.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a49907dd8420c5685cfa064a1335b6754b74541bbb3706c259c02ed65b644b3e"}, + {file = "MarkupSafe-2.1.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:10c1bfff05d95783da83491be968e8fe789263689c02724e0c691933c52994f5"}, + {file = "MarkupSafe-2.1.1-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b7bd98b796e2b6553da7225aeb61f447f80a1ca64f41d83612e6139ca5213aa4"}, + {file = "MarkupSafe-2.1.1-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:b09bf97215625a311f669476f44b8b318b075847b49316d3e28c08e41a7a573f"}, + {file = "MarkupSafe-2.1.1-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:694deca8d702d5db21ec83983ce0bb4b26a578e71fbdbd4fdcd387daa90e4d5e"}, + {file = "MarkupSafe-2.1.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:efc1913fd2ca4f334418481c7e595c00aad186563bbc1ec76067848c7ca0a933"}, + {file = "MarkupSafe-2.1.1-cp310-cp310-win32.whl", hash = "sha256:4a33dea2b688b3190ee12bd7cfa29d39c9ed176bda40bfa11099a3ce5d3a7ac6"}, + {file = "MarkupSafe-2.1.1-cp310-cp310-win_amd64.whl", hash = "sha256:dda30ba7e87fbbb7eab1ec9f58678558fd9a6b8b853530e176eabd064da81417"}, + {file = "MarkupSafe-2.1.1-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:671cd1187ed5e62818414afe79ed29da836dde67166a9fac6d435873c44fdd02"}, + {file = "MarkupSafe-2.1.1-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3799351e2336dc91ea70b034983ee71cf2f9533cdff7c14c90ea126bfd95d65a"}, + {file = "MarkupSafe-2.1.1-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e72591e9ecd94d7feb70c1cbd7be7b3ebea3f548870aa91e2732960fa4d57a37"}, + {file = "MarkupSafe-2.1.1-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:6fbf47b5d3728c6aea2abb0589b5d30459e369baa772e0f37a0320185e87c980"}, + {file = "MarkupSafe-2.1.1-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:d5ee4f386140395a2c818d149221149c54849dfcfcb9f1debfe07a8b8bd63f9a"}, + {file = "MarkupSafe-2.1.1-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:bcb3ed405ed3222f9904899563d6fc492ff75cce56cba05e32eff40e6acbeaa3"}, + {file = "MarkupSafe-2.1.1-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:e1c0b87e09fa55a220f058d1d49d3fb8df88fbfab58558f1198e08c1e1de842a"}, + {file = "MarkupSafe-2.1.1-cp37-cp37m-win32.whl", hash = "sha256:8dc1c72a69aa7e082593c4a203dcf94ddb74bb5c8a731e4e1eb68d031e8498ff"}, + {file = "MarkupSafe-2.1.1-cp37-cp37m-win_amd64.whl", hash = "sha256:97a68e6ada378df82bc9f16b800ab77cbf4b2fada0081794318520138c088e4a"}, + {file = "MarkupSafe-2.1.1-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:e8c843bbcda3a2f1e3c2ab25913c80a3c5376cd00c6e8c4a86a89a28c8dc5452"}, + {file = "MarkupSafe-2.1.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:0212a68688482dc52b2d45013df70d169f542b7394fc744c02a57374a4207003"}, + {file = "MarkupSafe-2.1.1-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8e576a51ad59e4bfaac456023a78f6b5e6e7651dcd383bcc3e18d06f9b55d6d1"}, + {file = "MarkupSafe-2.1.1-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4b9fe39a2ccc108a4accc2676e77da025ce383c108593d65cc909add5c3bd601"}, + {file = "MarkupSafe-2.1.1-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:96e37a3dc86e80bf81758c152fe66dbf60ed5eca3d26305edf01892257049925"}, + {file = "MarkupSafe-2.1.1-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:6d0072fea50feec76a4c418096652f2c3238eaa014b2f94aeb1d56a66b41403f"}, + {file = "MarkupSafe-2.1.1-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:089cf3dbf0cd6c100f02945abeb18484bd1ee57a079aefd52cffd17fba910b88"}, + {file = "MarkupSafe-2.1.1-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:6a074d34ee7a5ce3effbc526b7083ec9731bb3cbf921bbe1d3005d4d2bdb3a63"}, + {file = "MarkupSafe-2.1.1-cp38-cp38-win32.whl", hash = "sha256:421be9fbf0ffe9ffd7a378aafebbf6f4602d564d34be190fc19a193232fd12b1"}, + {file = "MarkupSafe-2.1.1-cp38-cp38-win_amd64.whl", hash = "sha256:fc7b548b17d238737688817ab67deebb30e8073c95749d55538ed473130ec0c7"}, + {file = "MarkupSafe-2.1.1-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:e04e26803c9c3851c931eac40c695602c6295b8d432cbe78609649ad9bd2da8a"}, + {file = "MarkupSafe-2.1.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:b87db4360013327109564f0e591bd2a3b318547bcef31b468a92ee504d07ae4f"}, + {file = "MarkupSafe-2.1.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:99a2a507ed3ac881b975a2976d59f38c19386d128e7a9a18b7df6fff1fd4c1d6"}, + {file = "MarkupSafe-2.1.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:56442863ed2b06d19c37f94d999035e15ee982988920e12a5b4ba29b62ad1f77"}, + {file = "MarkupSafe-2.1.1-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3ce11ee3f23f79dbd06fb3d63e2f6af7b12db1d46932fe7bd8afa259a5996603"}, + {file = "MarkupSafe-2.1.1-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:33b74d289bd2f5e527beadcaa3f401e0df0a89927c1559c8566c066fa4248ab7"}, + {file = "MarkupSafe-2.1.1-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:43093fb83d8343aac0b1baa75516da6092f58f41200907ef92448ecab8825135"}, + {file = "MarkupSafe-2.1.1-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:8e3dcf21f367459434c18e71b2a9532d96547aef8a871872a5bd69a715c15f96"}, + {file = "MarkupSafe-2.1.1-cp39-cp39-win32.whl", hash = "sha256:d4306c36ca495956b6d568d276ac11fdd9c30a36f1b6eb928070dc5360b22e1c"}, + {file = "MarkupSafe-2.1.1-cp39-cp39-win_amd64.whl", hash = "sha256:46d00d6cfecdde84d40e572d63735ef81423ad31184100411e6e3388d405e247"}, + {file = "MarkupSafe-2.1.1.tar.gz", hash = "sha256:7f91197cc9e48f989d12e4e6fbc46495c446636dfc81b9ccf50bb0ec74b91d4b"}, +] +mergedeep = [ + {file = "mergedeep-1.3.4-py3-none-any.whl", hash = "sha256:70775750742b25c0d8f36c55aed03d24c3384d17c951b3175d898bd778ef0307"}, + {file = "mergedeep-1.3.4.tar.gz", hash = "sha256:0096d52e9dad9939c3d975a774666af186eda617e6ca84df4c94dec30004f2a8"}, +] +mkdocs = [ + {file = "mkdocs-1.4.2-py3-none-any.whl", hash = "sha256:c8856a832c1e56702577023cd64cc5f84948280c1c0fcc6af4cd39006ea6aa8c"}, + {file = "mkdocs-1.4.2.tar.gz", hash = "sha256:8947af423a6d0facf41ea1195b8e1e8c85ad94ac95ae307fe11232e0424b11c5"}, +] +mkdocs-git-authors-plugin = [ + {file = "mkdocs-git-authors-plugin-0.6.5.tar.gz", hash = "sha256:5e10d1685745e128b0bc88e229c13cf62bab2b5a1b716b68301216b94e206bd4"}, + {file = "mkdocs_git_authors_plugin-0.6.5-py3-none-any.whl", hash = "sha256:b82aad1a476a673d0746e8ce5ccccbd39822fc730ef0a6a113ec8517d30016fc"}, +] +mkdocs-git-committers-plugin-2 = [ + {file = "mkdocs-git-committers-plugin-2-1.1.1.tar.gz", hash = "sha256:4f6eb6137f35967dfa444703b6ea293f05bf2fd183506bc51db8fb21b061d5a3"}, + {file = "mkdocs_git_committers_plugin_2-1.1.1-py3-none-any.whl", hash = "sha256:14d4a89bf8965ab62ca9b8b0cd90f6c9b421bb89bfedca0d91c5119f18791360"}, +] +mkdocs-git-revision-date-localized-plugin = [ + {file = "mkdocs-git-revision-date-localized-plugin-1.1.0.tar.gz", hash = "sha256:38517e2084229da1a1b9460e846c2748d238c2d79efd405d1b9174a87bd81d79"}, + {file = "mkdocs_git_revision_date_localized_plugin-1.1.0-py3-none-any.whl", hash = "sha256:4ba0e49abea3e9f6ee26e2623ff7283873da657471c61f1d0cfbb986f403316d"}, +] +mkdocs-material = [ + {file = "mkdocs_material-8.5.10-py3-none-any.whl", hash = "sha256:51760fa4c9ee3ca0b3a661ec9f9817ec312961bb84ff19e5b523fdc5256e1d6c"}, + {file = "mkdocs_material-8.5.10.tar.gz", hash = "sha256:7623608f746c6d9ff68a8ef01f13eddf32fa2cae5e15badb251f26d1196bc8f1"}, +] +mkdocs-material-extensions = [ + {file = "mkdocs_material_extensions-1.1-py3-none-any.whl", hash = "sha256:bcc2e5fc70c0ec50e59703ee6e639d87c7e664c0c441c014ea84461a90f1e902"}, + {file = "mkdocs_material_extensions-1.1.tar.gz", hash = "sha256:96ca979dae66d65c2099eefe189b49d5ac62f76afb59c38e069ffc7cf3c131ec"}, +] +omegaconf = [ + {file = "omegaconf-2.2.3-py3-none-any.whl", hash = "sha256:d6f2cbf79a992899eb76c6cb1aedfcf0fe7456a8654382edd5ee0c1b199c0657"}, + {file = "omegaconf-2.2.3.tar.gz", hash = "sha256:59ff9fba864ffbb5fb710b64e8a9ba37c68fa339a2e2bb4f1b648d6901552523"}, +] +packaging = [ + {file = "packaging-21.3-py3-none-any.whl", hash = "sha256:ef103e05f519cdc783ae24ea4e2e0f508a9c99b2d4969652eed6a2e1ea5bd522"}, + {file = "packaging-21.3.tar.gz", hash = "sha256:dd47c42927d89ab911e606518907cc2d3a1f38bbd026385970643f9c5b8ecfeb"}, +] +pefile = [ + {file = "pefile-2022.5.30.tar.gz", hash = "sha256:a5488a3dd1fd021ce33f969780b88fe0f7eebb76eb20996d7318f307612a045b"}, +] +Pygments = [ + {file = "Pygments-2.13.0-py3-none-any.whl", hash = "sha256:f643f331ab57ba3c9d89212ee4a2dabc6e94f117cf4eefde99a0574720d14c42"}, + {file = "Pygments-2.13.0.tar.gz", hash = "sha256:56a8508ae95f98e2b9bdf93a6be5ae3f7d8af858b43e02c5a2ff083726be40c1"}, +] +pyinstaller = [ + {file = "pyinstaller-5.6.2-py3-none-macosx_10_13_universal2.whl", hash = "sha256:1b1e3b37a22fb36555d917f0c3dfb998159ff4af6d8fa7cc0074d630c6fe81ad"}, + {file = "pyinstaller-5.6.2-py3-none-manylinux2014_aarch64.whl", hash = "sha256:05df5d2b9ca645cc6ef61d8a85451d2aabe5501997f1f50cd94306fd6bc0485d"}, + {file = "pyinstaller-5.6.2-py3-none-manylinux2014_i686.whl", hash = "sha256:eb083c25f711769af0898852ea30dcb727ba43990bbdf9ffbaa9c77a7bd0d720"}, + {file = "pyinstaller-5.6.2-py3-none-manylinux2014_ppc64le.whl", hash = "sha256:0d167d57036219914188f1400427dd297b975707e78c32a5511191e607be920a"}, + {file = "pyinstaller-5.6.2-py3-none-manylinux2014_s390x.whl", hash = "sha256:32727232f446aa96e394f01b0c35b3de0dc3513c6ba3e26d1ef64c57edb1e9e5"}, + {file = "pyinstaller-5.6.2-py3-none-manylinux2014_x86_64.whl", hash = "sha256:181856ade585b090379ae26b7017dc2c30620e36e3a804b381417a6dc3b2a82b"}, + {file = "pyinstaller-5.6.2-py3-none-musllinux_1_1_aarch64.whl", hash = "sha256:77888f52b61089caa0bee70809bbce9e9b1c613c88b6cb0742ff2a45f1511cbb"}, + {file = "pyinstaller-5.6.2-py3-none-musllinux_1_1_x86_64.whl", hash = "sha256:d888db9afedff290d362ee296d30eb339abeba707ca1565916ce1cd5947131c3"}, + {file = "pyinstaller-5.6.2-py3-none-win32.whl", hash = "sha256:e026adc92c60158741d0bfca27eefaa2414801f61328cb84d0c88241fe8c2087"}, + {file = "pyinstaller-5.6.2-py3-none-win_amd64.whl", hash = "sha256:04ecf805bde2ef25b8e3642410871e6747c22fa7254107f155b8cd179c2a13b6"}, + {file = "pyinstaller-5.6.2.tar.gz", hash = "sha256:865025b6809d777bb0f66d8f8ab50cc97dc3dbe0ff09a1ef1f2fd646432714fc"}, +] +pyinstaller-hooks-contrib = [ + {file = "pyinstaller-hooks-contrib-2022.13.tar.gz", hash = "sha256:e06d0881e599d94dc39c6ed1917f0ad9b1858a2478b9892faac18bd48bcdc2de"}, + {file = "pyinstaller_hooks_contrib-2022.13-py2.py3-none-any.whl", hash = "sha256:91ecb30db757a8db8b6661d91d5df99e0998245f05f5cfaade0550922c7030a3"}, +] +pymdown-extensions = [ + {file = "pymdown_extensions-9.8-py3-none-any.whl", hash = "sha256:8e62688a8b1128acd42fa823f3d429d22f4284b5e6dd4d3cd56721559a5a211b"}, + {file = "pymdown_extensions-9.8.tar.gz", hash = "sha256:1bd4a173095ef8c433b831af1f3cb13c10883be0c100ae613560668e594651f7"}, +] +pyparsing = [ + {file = "pyparsing-3.0.9-py3-none-any.whl", hash = "sha256:5026bae9a10eeaefb61dab2f09052b9f4307d44aee4eda64b309723d8d206bbc"}, + {file = "pyparsing-3.0.9.tar.gz", hash = "sha256:2b020ecf7d21b687f219b71ecad3631f644a47f01403fa1d1036b0c6416d70fb"}, +] +python-dateutil = [ + {file = "python-dateutil-2.8.2.tar.gz", hash = "sha256:0123cacc1627ae19ddf3c27a5de5bd67ee4586fbdd6440d9748f8abb483d3e86"}, + {file = "python_dateutil-2.8.2-py2.py3-none-any.whl", hash = "sha256:961d03dc3453ebbc59dbdea9e4e11c5651520a876d0f4db161e8674aae935da9"}, +] +python-slugify = [ + {file = "python-slugify-6.1.2.tar.gz", hash = "sha256:272d106cb31ab99b3496ba085e3fea0e9e76dcde967b5e9992500d1f785ce4e1"}, + {file = "python_slugify-6.1.2-py2.py3-none-any.whl", hash = "sha256:7b2c274c308b62f4269a9ba701aa69a797e9bca41aeee5b3a9e79e36b6656927"}, +] +pytz = [ + {file = "pytz-2022.6-py2.py3-none-any.whl", hash = "sha256:222439474e9c98fced559f1709d89e6c9cbf8d79c794ff3eb9f8800064291427"}, + {file = "pytz-2022.6.tar.gz", hash = "sha256:e89512406b793ca39f5971bc999cc538ce125c0e51c27941bef4568b460095e2"}, +] +pywin32-ctypes = [ + {file = "pywin32-ctypes-0.2.0.tar.gz", hash = "sha256:24ffc3b341d457d48e8922352130cf2644024a4ff09762a2261fd34c36ee5942"}, + {file = "pywin32_ctypes-0.2.0-py2.py3-none-any.whl", hash = "sha256:9dc2d991b3479cc2df15930958b674a48a227d5361d413827a4cfd0b5876fc98"}, +] +PyYAML = [ + {file = "PyYAML-6.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:d4db7c7aef085872ef65a8fd7d6d09a14ae91f691dec3e87ee5ee0539d516f53"}, + {file = "PyYAML-6.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:9df7ed3b3d2e0ecfe09e14741b857df43adb5a3ddadc919a2d94fbdf78fea53c"}, + {file = "PyYAML-6.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:77f396e6ef4c73fdc33a9157446466f1cff553d979bd00ecb64385760c6babdc"}, + {file = "PyYAML-6.0-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a80a78046a72361de73f8f395f1f1e49f956c6be882eed58505a15f3e430962b"}, + {file = "PyYAML-6.0-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:f84fbc98b019fef2ee9a1cb3ce93e3187a6df0b2538a651bfb890254ba9f90b5"}, + {file = "PyYAML-6.0-cp310-cp310-win32.whl", hash = "sha256:2cd5df3de48857ed0544b34e2d40e9fac445930039f3cfe4bcc592a1f836d513"}, + {file = "PyYAML-6.0-cp310-cp310-win_amd64.whl", hash = "sha256:daf496c58a8c52083df09b80c860005194014c3698698d1a57cbcfa182142a3a"}, + {file = "PyYAML-6.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:d4b0ba9512519522b118090257be113b9468d804b19d63c71dbcf4a48fa32358"}, + {file = "PyYAML-6.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:81957921f441d50af23654aa6c5e5eaf9b06aba7f0a19c18a538dc7ef291c5a1"}, + {file = "PyYAML-6.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:afa17f5bc4d1b10afd4466fd3a44dc0e245382deca5b3c353d8b757f9e3ecb8d"}, + {file = "PyYAML-6.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:dbad0e9d368bb989f4515da330b88a057617d16b6a8245084f1b05400f24609f"}, + {file = "PyYAML-6.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:432557aa2c09802be39460360ddffd48156e30721f5e8d917f01d31694216782"}, + {file = "PyYAML-6.0-cp311-cp311-win32.whl", hash = "sha256:bfaef573a63ba8923503d27530362590ff4f576c626d86a9fed95822a8255fd7"}, + {file = "PyYAML-6.0-cp311-cp311-win_amd64.whl", hash = "sha256:01b45c0191e6d66c470b6cf1b9531a771a83c1c4208272ead47a3ae4f2f603bf"}, + {file = "PyYAML-6.0-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:897b80890765f037df3403d22bab41627ca8811ae55e9a722fd0392850ec4d86"}, + {file = "PyYAML-6.0-cp36-cp36m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:50602afada6d6cbfad699b0c7bb50d5ccffa7e46a3d738092afddc1f9758427f"}, + {file = "PyYAML-6.0-cp36-cp36m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:48c346915c114f5fdb3ead70312bd042a953a8ce5c7106d5bfb1a5254e47da92"}, + {file = "PyYAML-6.0-cp36-cp36m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:98c4d36e99714e55cfbaaee6dd5badbc9a1ec339ebfc3b1f52e293aee6bb71a4"}, + {file = "PyYAML-6.0-cp36-cp36m-win32.whl", hash = "sha256:0283c35a6a9fbf047493e3a0ce8d79ef5030852c51e9d911a27badfde0605293"}, + {file = "PyYAML-6.0-cp36-cp36m-win_amd64.whl", hash = "sha256:07751360502caac1c067a8132d150cf3d61339af5691fe9e87803040dbc5db57"}, + {file = "PyYAML-6.0-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:819b3830a1543db06c4d4b865e70ded25be52a2e0631ccd2f6a47a2822f2fd7c"}, + {file = "PyYAML-6.0-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:473f9edb243cb1935ab5a084eb238d842fb8f404ed2193a915d1784b5a6b5fc0"}, + {file = "PyYAML-6.0-cp37-cp37m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:0ce82d761c532fe4ec3f87fc45688bdd3a4c1dc5e0b4a19814b9009a29baefd4"}, + {file = "PyYAML-6.0-cp37-cp37m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:231710d57adfd809ef5d34183b8ed1eeae3f76459c18fb4a0b373ad56bedcdd9"}, + {file = "PyYAML-6.0-cp37-cp37m-win32.whl", hash = "sha256:c5687b8d43cf58545ade1fe3e055f70eac7a5a1a0bf42824308d868289a95737"}, + {file = "PyYAML-6.0-cp37-cp37m-win_amd64.whl", hash = "sha256:d15a181d1ecd0d4270dc32edb46f7cb7733c7c508857278d3d378d14d606db2d"}, + {file = "PyYAML-6.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:0b4624f379dab24d3725ffde76559cff63d9ec94e1736b556dacdfebe5ab6d4b"}, + {file = "PyYAML-6.0-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:213c60cd50106436cc818accf5baa1aba61c0189ff610f64f4a3e8c6726218ba"}, + {file = "PyYAML-6.0-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9fa600030013c4de8165339db93d182b9431076eb98eb40ee068700c9c813e34"}, + {file = "PyYAML-6.0-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:277a0ef2981ca40581a47093e9e2d13b3f1fbbeffae064c1d21bfceba2030287"}, + {file = "PyYAML-6.0-cp38-cp38-win32.whl", hash = "sha256:d4eccecf9adf6fbcc6861a38015c2a64f38b9d94838ac1810a9023a0609e1b78"}, + {file = "PyYAML-6.0-cp38-cp38-win_amd64.whl", hash = "sha256:1e4747bc279b4f613a09eb64bba2ba602d8a6664c6ce6396a4d0cd413a50ce07"}, + {file = "PyYAML-6.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:055d937d65826939cb044fc8c9b08889e8c743fdc6a32b33e2390f66013e449b"}, + {file = "PyYAML-6.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:e61ceaab6f49fb8bdfaa0f92c4b57bcfbea54c09277b1b4f7ac376bfb7a7c174"}, + {file = "PyYAML-6.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d67d839ede4ed1b28a4e8909735fc992a923cdb84e618544973d7dfc71540803"}, + {file = "PyYAML-6.0-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:cba8c411ef271aa037d7357a2bc8f9ee8b58b9965831d9e51baf703280dc73d3"}, + {file = "PyYAML-6.0-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:40527857252b61eacd1d9af500c3337ba8deb8fc298940291486c465c8b46ec0"}, + {file = "PyYAML-6.0-cp39-cp39-win32.whl", hash = "sha256:b5b9eccad747aabaaffbc6064800670f0c297e52c12754eb1d976c57e4f74dcb"}, + {file = "PyYAML-6.0-cp39-cp39-win_amd64.whl", hash = "sha256:b3d267842bf12586ba6c734f89d1f5b871df0273157918b0ccefa29deb05c21c"}, + {file = "PyYAML-6.0.tar.gz", hash = "sha256:68fb519c14306fec9720a2a5b45bc9f0c8d1b9c72adf45c37baedfcd949c35a2"}, +] +pyyaml_env_tag = [ + {file = "pyyaml_env_tag-0.1-py3-none-any.whl", hash = "sha256:af31106dec8a4d68c60207c1886031cbf839b68aa7abccdb19868200532c2069"}, + {file = "pyyaml_env_tag-0.1.tar.gz", hash = "sha256:70092675bda14fdec33b31ba77e7543de9ddc88f2e5b99160396572d11525bdb"}, +] +requests = [ + {file = "requests-2.28.1-py3-none-any.whl", hash = "sha256:8fefa2a1a1365bf5520aac41836fbee479da67864514bdb821f31ce07ce65349"}, + {file = "requests-2.28.1.tar.gz", hash = "sha256:7c5599b102feddaa661c826c56ab4fee28bfd17f5abca1ebbe3e7f19d7c97983"}, +] +rich = [ + {file = "rich-12.6.0-py3-none-any.whl", hash = "sha256:a4eb26484f2c82589bd9a17c73d32a010b1e29d89f1604cd9bf3a2097b81bb5e"}, + {file = "rich-12.6.0.tar.gz", hash = "sha256:ba3a3775974105c221d31141f2c116f4fd65c5ceb0698657a11e9f295ec93fd0"}, +] +setuptools = [ + {file = "setuptools-65.5.1-py3-none-any.whl", hash = "sha256:d0b9a8433464d5800cbe05094acf5c6d52a91bfac9b52bcfc4d41382be5d5d31"}, + {file = "setuptools-65.5.1.tar.gz", hash = "sha256:e197a19aa8ec9722928f2206f8de752def0e4c9fc6953527360d1c36d94ddb2f"}, +] +six = [ + {file = "six-1.16.0-py2.py3-none-any.whl", hash = "sha256:8abb2f1d86890a2dfb989f9a77cfcfd3e47c2a354b01111771326f8aa26e0254"}, + {file = "six-1.16.0.tar.gz", hash = "sha256:1e61c37477a1626458e36f7b1d82aa5c9b094fa4802892072e49de9c60c4c926"}, +] +smmap = [ + {file = "smmap-5.0.0-py3-none-any.whl", hash = "sha256:2aba19d6a040e78d8b09de5c57e96207b09ed71d8e55ce0959eeee6c8e190d94"}, + {file = "smmap-5.0.0.tar.gz", hash = "sha256:c840e62059cd3be204b0c9c9f74be2c09d5648eddd4580d9314c3ecde0b30936"}, +] +soupsieve = [ + {file = "soupsieve-2.3.2.post1-py3-none-any.whl", hash = "sha256:3b2503d3c7084a42b1ebd08116e5f81aadfaea95863628c80a3b774a11b7c759"}, + {file = "soupsieve-2.3.2.post1.tar.gz", hash = "sha256:fc53893b3da2c33de295667a0e19f078c14bf86544af307354de5fcf12a3f30d"}, +] +text-unidecode = [ + {file = "text-unidecode-1.3.tar.gz", hash = "sha256:bad6603bb14d279193107714b288be206cac565dfa49aa5b105294dd5c4aab93"}, + {file = "text_unidecode-1.3-py2.py3-none-any.whl", hash = "sha256:1311f10e8b895935241623731c2ba64f4c455287888b18189350b67134a822e8"}, +] +urllib3 = [ + {file = "urllib3-1.26.12-py2.py3-none-any.whl", hash = "sha256:b930dd878d5a8afb066a637fbb35144fe7901e3b209d1cd4f524bd0e9deee997"}, + {file = "urllib3-1.26.12.tar.gz", hash = "sha256:3fa96cf423e6987997fc326ae8df396db2a8b7c667747d47ddd8ecba91f4a74e"}, +] +watchdog = [ + {file = "watchdog-2.1.9-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:a735a990a1095f75ca4f36ea2ef2752c99e6ee997c46b0de507ba40a09bf7330"}, + {file = "watchdog-2.1.9-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:6b17d302850c8d412784d9246cfe8d7e3af6bcd45f958abb2d08a6f8bedf695d"}, + {file = "watchdog-2.1.9-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:ee3e38a6cc050a8830089f79cbec8a3878ec2fe5160cdb2dc8ccb6def8552658"}, + {file = "watchdog-2.1.9-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:64a27aed691408a6abd83394b38503e8176f69031ca25d64131d8d640a307591"}, + {file = "watchdog-2.1.9-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:195fc70c6e41237362ba720e9aaf394f8178bfc7fa68207f112d108edef1af33"}, + {file = "watchdog-2.1.9-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:bfc4d351e6348d6ec51df007432e6fe80adb53fd41183716017026af03427846"}, + {file = "watchdog-2.1.9-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:8250546a98388cbc00c3ee3cc5cf96799b5a595270dfcfa855491a64b86ef8c3"}, + {file = "watchdog-2.1.9-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:117ffc6ec261639a0209a3252546b12800670d4bf5f84fbd355957a0595fe654"}, + {file = "watchdog-2.1.9-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:97f9752208f5154e9e7b76acc8c4f5a58801b338de2af14e7e181ee3b28a5d39"}, + {file = "watchdog-2.1.9-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:247dcf1df956daa24828bfea5a138d0e7a7c98b1a47cf1fa5b0c3c16241fcbb7"}, + {file = "watchdog-2.1.9-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:226b3c6c468ce72051a4c15a4cc2ef317c32590d82ba0b330403cafd98a62cfd"}, + {file = "watchdog-2.1.9-pp37-pypy37_pp73-macosx_10_9_x86_64.whl", hash = "sha256:d9820fe47c20c13e3c9dd544d3706a2a26c02b2b43c993b62fcd8011bcc0adb3"}, + {file = "watchdog-2.1.9-pp38-pypy38_pp73-macosx_10_9_x86_64.whl", hash = "sha256:70af927aa1613ded6a68089a9262a009fbdf819f46d09c1a908d4b36e1ba2b2d"}, + {file = "watchdog-2.1.9-pp39-pypy39_pp73-macosx_10_9_x86_64.whl", hash = "sha256:ed80a1628cee19f5cfc6bb74e173f1b4189eb532e705e2a13e3250312a62e0c9"}, + {file = "watchdog-2.1.9-py3-none-manylinux2014_aarch64.whl", hash = "sha256:9f05a5f7c12452f6a27203f76779ae3f46fa30f1dd833037ea8cbc2887c60213"}, + {file = "watchdog-2.1.9-py3-none-manylinux2014_armv7l.whl", hash = "sha256:255bb5758f7e89b1a13c05a5bceccec2219f8995a3a4c4d6968fe1de6a3b2892"}, + {file = "watchdog-2.1.9-py3-none-manylinux2014_i686.whl", hash = "sha256:d3dda00aca282b26194bdd0adec21e4c21e916956d972369359ba63ade616153"}, + {file = "watchdog-2.1.9-py3-none-manylinux2014_ppc64.whl", hash = "sha256:186f6c55abc5e03872ae14c2f294a153ec7292f807af99f57611acc8caa75306"}, + {file = "watchdog-2.1.9-py3-none-manylinux2014_ppc64le.whl", hash = "sha256:083171652584e1b8829581f965b9b7723ca5f9a2cd7e20271edf264cfd7c1412"}, + {file = "watchdog-2.1.9-py3-none-manylinux2014_s390x.whl", hash = "sha256:b530ae007a5f5d50b7fbba96634c7ee21abec70dc3e7f0233339c81943848dc1"}, + {file = "watchdog-2.1.9-py3-none-manylinux2014_x86_64.whl", hash = "sha256:4f4e1c4aa54fb86316a62a87b3378c025e228178d55481d30d857c6c438897d6"}, + {file = "watchdog-2.1.9-py3-none-win32.whl", hash = "sha256:5952135968519e2447a01875a6f5fc8c03190b24d14ee52b0f4b1682259520b1"}, + {file = "watchdog-2.1.9-py3-none-win_amd64.whl", hash = "sha256:7a833211f49143c3d336729b0020ffd1274078e94b0ae42e22f596999f50279c"}, + {file = "watchdog-2.1.9-py3-none-win_ia64.whl", hash = "sha256:ad576a565260d8f99d97f2e64b0f97a48228317095908568a9d5c786c829d428"}, + {file = "watchdog-2.1.9.tar.gz", hash = "sha256:43ce20ebb36a51f21fa376f76d1d4692452b2527ccd601950d69ed36b9e21609"}, +] diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..a212c34 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,41 @@ +[build-system] +requires = ["poetry-core"] +build-backend = "poetry.core.masonry.api" + +[tool.commitizen] +name = "cz_conventional_commits" +version = "0.0.5" +tag_format = "$version" +version_files = ["pyproject.toml:version"] + +[tool.poetry] +name = "thu-learn-downloader" +version = "0.0.5" +description = "Auto download files from thu-learn" +authors = ["Qin Li "] +license = "MIT" +readme = "README.md" +homepage = "https://liblaf.github.io/thu-learn-downloader/" +repository = "https://github.com/liblaf/thu-learn-downloader" +documentation = "https://liblaf.github.io/thu-learn-downloader/" + +[tool.poetry.dependencies] +python = ">=3.10,<3.12" +beautifulsoup4 = "^4.11.1" +hydra-core = "^1.2.0" +python-slugify = "^6.1.2" +requests = "^2.28.1" +rich = "^12.6.0" + +[tool.poetry.group.build.dependencies] +pyinstaller = "^5.6.2" + +[tool.poetry.group.docs.dependencies] +lxml = "^4.9.1" +mkdocs-git-authors-plugin = "^0.6.4" +mkdocs-git-committers-plugin-2 = "^1.1.1" +mkdocs-git-revision-date-localized-plugin = "^1.1.0" +mkdocs-material = "^8.5.7" + +[tool.poetry.scripts] +build = "thu_learn_downloader.build:run" diff --git a/requirements.txt b/requirements.txt index 5b5514f..d76d50f 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,6 +1,18 @@ -beautifulsoup4~=4.11.1 -hydra-core~=1.2.0 -omegaconf~=2.2.3 -python-slugify~=6.1.2 -requests~=2.28.1 -tqdm~=4.64.1 +antlr4-python3-runtime==4.9.3 ; python_version >= "3.10" and python_version < "3.12" +beautifulsoup4==4.11.1 ; python_version >= "3.10" and python_version < "3.12" +certifi==2022.9.24 ; python_version >= "3.10" and python_version < "3.12" +charset-normalizer==2.1.1 ; python_version >= "3.10" and python_version < "3.12" +commonmark==0.9.1 ; python_version >= "3.10" and python_version < "3.12" +hydra-core==1.2.0 ; python_version >= "3.10" and python_version < "3.12" +idna==3.4 ; python_version >= "3.10" and python_version < "3.12" +omegaconf==2.2.3 ; python_version >= "3.10" and python_version < "3.12" +packaging==21.3 ; python_version >= "3.10" and python_version < "3.12" +pygments==2.13.0 ; python_version >= "3.10" and python_version < "3.12" +pyparsing==3.0.9 ; python_version >= "3.10" and python_version < "3.12" +python-slugify==6.1.2 ; python_version >= "3.10" and python_version < "3.12" +pyyaml==6.0 ; python_version >= "3.10" and python_version < "3.12" +requests==2.28.1 ; python_version >= "3.10" and python_version < "3.12" +rich==12.6.0 ; python_version >= "3.10" and python_version < "3.12" +soupsieve==2.3.2.post1 ; python_version >= "3.10" and python_version < "3.12" +text-unidecode==1.3 ; python_version >= "3.10" and python_version < "3.12" +urllib3==1.26.12 ; python_version >= "3.10" and python_version < "3.12" diff --git a/scripts/docs.sh b/scripts/docs.sh new file mode 100644 index 0000000..541154c --- /dev/null +++ b/scripts/docs.sh @@ -0,0 +1,43 @@ +#!/usr/bin/bash +set -o errexit +set -o nounset +set -o pipefail + +if command -v rich > /dev/null 2>&1; then + function info() { + rich --print "[bold bright_blue]${*}" + } +else + function info() { + echo -e -n "\x1b[1;94m" + echo -n "${*}" + echo -e "\x1b[0m" + } +fi + +function call() { + info "+ ${*}" + "${@}" +} + +function prepare() { + if [[ ! -f "docs/index.md" ]]; then + call cp "README.md" "docs/index.md" + fi +} + +function build() { + call poetry run mkdocs build +} + +function deploy() { + call poetry run mkdocs gh-deploy +} + +cmd="${1}" +shift 1 +case "${cmd}" in + *) + "${cmd}" "${@}" + ;; +esac diff --git a/scripts/install.sh b/scripts/install.sh new file mode 100644 index 0000000..0d2a0bb --- /dev/null +++ b/scripts/install.sh @@ -0,0 +1,27 @@ +#!/usr/bin/bash +set -o errexit +set -o nounset +set -o pipefail + +if command -v rich > /dev/null 2>&1; then + function info() { + rich --print "[bold bright_blue]${*}" + } +else + function info() { + echo -e -n "\x1b[1;94m" + echo -n "${*}" + echo -e "\x1b[0m" + } +fi + +function call() { + info "+ ${*}" + "${@}" +} + +REPO_HOME="$(realpath --canonicalize-missing "${0}/../..")" +call cd "${REPO_HOME}" +call poetry run build +mkdir --parents "${HOME}/.local/bin" +call cp "${REPO_HOME}/dist/$(basename "${REPO_HOME}")" "${HOME}/.local/bin" diff --git a/scripts/template.sh b/scripts/template.sh new file mode 100644 index 0000000..45468ff --- /dev/null +++ b/scripts/template.sh @@ -0,0 +1,49 @@ +#!/usr/bin/bash +set -o errexit +set -o nounset +set -o pipefail + +if command -v rich > /dev/null 2>&1; then + function info() { + rich --print "[bold bright_blue]${*}" + } +else + function info() { + echo -e -n "\x1b[1 +94m" + echo -n "${*}" + echo -e "\x1b[0m" + } +fi + +function call() { + info "+ ${*}" + "${@}" +} + +cd "$(git rev-parse --show-toplevel || echo .)" +REPO_NAME="$(basename "$(pwd)")" + +description="${*}" +echo "# ${REPO_NAME}" > "README.md" +if [[ -n ${description} ]]; then + echo "" >> "README.md" + echo "${description}" >> "README.md" +fi + +files=( + "mkdocs.yaml" + "pyproject.toml" +) +for file in "${files[@]}"; do + call sed --in-place "s/template/${REPO_NAME}/g" "${file}" +done + +call sed --in-place "s/description = \"Repository Template\"/description = \"${description}\"/g" pyproject.toml + +call gh repo edit --description "${description}" +call gh repo edit --homepage "https://liblaf.github.io/${REPO_NAME}/" + +call git add --all +call git commit --message "build: initialize" --verify --gpg-sign +call git push diff --git a/thu_learn_downloader/__init__.py b/thu_learn_downloader/__init__.py new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/thu_learn_downloader/__init__.py diff --git a/thu_learn_downloader/__main__.py b/thu_learn_downloader/__main__.py new file mode 100644 index 0000000..42d7bd9 --- /dev/null +++ b/thu_learn_downloader/__main__.py @@ -0,0 +1,64 @@ +import sys +import os + +import hydra +import omegaconf +import rich.progress +import rich.panel +import rich.table +import rich.console +import rich.live + +from thu_learn_downloader import sync +from thu_learn_downloader.downloader import Downloader +from thu_learn_downloader.helper import LearnHelper + + +@hydra.main(config_path=os.getcwd(), config_name="config.yaml", version_base="1.2") +def main(config: omegaconf.DictConfig) -> int: + helper = LearnHelper( + username=config.get("username"), password=config.get("password") + ) + downloader = Downloader() + overall_progress = rich.progress.Progress( + rich.progress.TextColumn("{task.description}", style="bold bright_blue"), + rich.progress.BarColumn(), + rich.progress.MofNCompleteColumn(), + rich.progress.TimeElapsedColumn(), + ) + semesters_task_id = overall_progress.add_task(description="Semesters") + courses_task_id = overall_progress.add_task(description="Courses") + progress_group = rich.console.Group( + rich.panel.Panel(overall_progress), + rich.panel.Panel(downloader.progress), + ) + + with rich.live.Live(progress_group) as live: + with downloader.pool: + try: + helper.login() + except: + live.console.log( + f"Login as {helper.username} {helper.status or 'FAILED'}", + style="bold bright_red", + ) + else: + live.console.log( + f"Login as {helper.username} {helper.status}", + style="bold bright_green", + ) + sync.sync_all( + helper=helper, + downloader=downloader, + config=config, + console=live.console, + overall_progress=overall_progress, + semesters_task_id=semesters_task_id, + courses_task_id=courses_task_id, + ) + + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/thu_learn_downloader/build.py b/thu_learn_downloader/build.py new file mode 100644 index 0000000..b1cd9f9 --- /dev/null +++ b/thu_learn_downloader/build.py @@ -0,0 +1,14 @@ +import os.path + +import PyInstaller.__main__ + + +def run(): + PyInstaller.__main__.run( + [ + os.path.join(__package__, "__main__.py"), + "--onefile", + "--name", + "thu-learn-downloader", + ] + ) diff --git a/thu_learn_downloader/downloader.py b/thu_learn_downloader/downloader.py new file mode 100644 index 0000000..936612d --- /dev/null +++ b/thu_learn_downloader/downloader.py @@ -0,0 +1,139 @@ +import concurrent.futures +import datetime +import os +import time +import typing + +import requests +import rich.console +import rich.progress + + +def download_once( + url: str, + file: str, + raw_size: typing.Optional[int] = 0, + upload_time: typing.Optional[datetime.datetime] = None, + session: typing.Optional[requests.Session] = None, + console: rich.console.Console = rich.console.Console(), + progress: rich.progress.Progress = rich.progress.Progress(), + task_id: rich.progress.TaskID = rich.progress.TaskID(0), +) -> None: + res = (session or requests).get(url=url, stream=True) + raw_size = int(res.headers.get("Content-Length", 0)) or raw_size + if os.path.exists(file): + if os.path.getsize(file) == raw_size: + if upload_time: + mtime = os.path.getmtime(filename=file) + mtime = datetime.datetime.fromtimestamp(mtime) + if mtime >= upload_time: + return + os.makedirs(name=os.path.dirname(file), exist_ok=True) + progress.reset(task_id=task_id, total=raw_size) + with open(file=file, mode="wb") as fp: + progress.start_task(task_id=task_id) + for chunk in res.iter_content(chunk_size=1024 * 1024): + bytes_written = fp.write(chunk) + progress.advance(task_id=task_id, advance=bytes_written) + if not raw_size: + progress.update(task_id=task_id, total=fp.tell()) + if upload_time: + mtime = int(upload_time.timestamp()) + os.utime(path=file, times=(mtime, mtime)) + + +def download( + url: str, + file: str, + raw_size: typing.Optional[int] = None, + upload_time: typing.Optional[datetime.datetime] = None, + session: typing.Optional[requests.Session] = None, + console: rich.console.Console = rich.console.Console(), + progress: rich.progress.Progress = rich.progress.Progress(), + task_id: rich.progress.TaskID = rich.progress.TaskID(0), + max_retries: int = 4, +) -> None: + for i in range(max_retries + 1): + try: + if i: + console.log( + f"Retry {i}: {progress.tasks[task_id].description}", + style="bold bright_blue", + ) + download_once( + url=url, + file=file, + raw_size=raw_size, + upload_time=upload_time, + session=session, + console=console, + progress=progress, + task_id=task_id, + ) + except: + console.log( + f"Download Failed: {progress.tasks[task_id].description}", + style="bold bright_red", + ) + else: + console.log( + f"Download Success: {progress.tasks[task_id].description}", + style="bold bright_green", + ) + progress.update(task_id=task_id, visible=False) + return + + +class Downloader: + pool: concurrent.futures.Executor + progress: rich.progress.Progress + + def __init__(self) -> None: + self.pool = concurrent.futures.ThreadPoolExecutor() + self.progress = rich.progress.Progress( + rich.progress.TextColumn( + text_format="{task.description}", style="bold blue" + ), + rich.progress.BarColumn(), + rich.progress.DownloadColumn(), + rich.progress.TaskProgressColumn(), + rich.progress.TransferSpeedColumn(), + rich.progress.TimeRemainingColumn(), + ) + + def schedule_download( + self, + url: str, + file: str, + raw_size: typing.Optional[int] = None, + upload_time: typing.Optional[datetime.datetime] = None, + session: typing.Optional[requests.Session] = None, + console: rich.console.Console = rich.console.Console(), + description: typing.Optional[str] = None, + max_retries: int = 4, + ): + while True: + running_tasks = 0 + for task in self.progress.tasks: + if task.visible: + running_tasks += 1 + if running_tasks < 16: + break + else: + time.sleep(1) + + task_id = self.progress.add_task( + description=description or file, start=False, total=raw_size + ) + self.pool.submit( + download, + url=url, + file=file, + raw_size=raw_size, + upload_time=upload_time, + session=session, + console=console, + progress=self.progress, + task_id=task_id, + max_retries=max_retries, + ) diff --git a/thu_learn_downloader/helper.py b/thu_learn_downloader/helper.py new file mode 100644 index 0000000..f03fa1c --- /dev/null +++ b/thu_learn_downloader/helper.py @@ -0,0 +1,270 @@ +import datetime +import typing +import urllib.parse + +import bs4 +import requests +import requests.adapters + +from . import parser +from . import types +from . import urls + + +class LearnHelper(requests.Session): + username: str + password: str + status: str = "" + + def __init__(self, username: str, password: str) -> None: + super().__init__() + self.username = username + self.password = password + self.mount( + prefix="https://", + adapter=requests.adapters.HTTPAdapter( + max_retries=4, + ), + ) + + @property + def token(self) -> str: + return self.cookies.get(name="XSRF-TOKEN", default="") + + def get_with_token(self, url: types.URL) -> requests.Response: + if url.query: + assert isinstance(url.query, dict) + url.query["_csrf"] = self.token + else: + url.query = { + "_csrf": self.token, + } + return self.get(url.str()) + + def login(self) -> bool: + response: requests.Response = self.get(urls.LEARN_PREFIX.str()) + soup = bs4.BeautifulSoup(response.text, features="html.parser") + form = typing.cast(bs4.Tag, soup.select_one("#loginForm")) + action = typing.cast(str, form["action"]) + payload = urls.id_login_form_data( + username=self.username, + password=self.password, + ).query + response = self.post(url=action, data=payload) + soup = bs4.BeautifulSoup(response.text, features="html.parser") + a = typing.cast(bs4.Tag, soup.select_one("a")) + href = typing.cast(str, a["href"]) + parse_result = urllib.parse.urlparse(url=href) + query = urllib.parse.parse_qs(qs=parse_result.query) + self.status = query["status"][0] + self.ticket = query["ticket"][0] + + response = self.get(href) + + url = urls.learn_auth_roam(ticket=self.ticket) + response = self.get(url.str()) + + url = urls.learn_student_course_list_page() + response = self.get(url.str()) + + return self.status == "SUCCESS" + + def logout(self) -> None: + url = urls.learn_logout() + response = self.post(url.str()) + + def get_semester_id_list(self) -> list[str]: + url = urls.learn_semester_list() + response = self.get_with_token(url) + json = response.json() + return [semester_id for semester_id in json if semester_id] + + def get_current_semester(self) -> types.SemesterInfo: + url = urls.learn_current_semester() + response = self.get_with_token(url) + json = response.json() + result = json["result"] + return types.SemesterInfo( + id=result["id"], + start_date=datetime.datetime.strptime(result["kssj"], r"%Y-%m-%d").date(), + end_date=datetime.datetime.strptime(result["jssj"], r"%Y-%m-%d").date(), + start_year=int(result["xnxq"][0:4]), + end_year=int(result["xnxq"][5:9]), + type=typing.cast(types.SemesterType, int(result["xnxq"][10:])), + ) + + def get_course_list( + self, + semester_id: str, + course_type: types.CourseType = types.CourseType.STUDENT, + ) -> list[types.CourseInfo]: + url = urls.learn_course_list(semester_id, course_type) + response = self.get_with_token(url) + json = response.json() + result = json["resultList"] + courses = [ + parser.parse_course_info(course=course, course_type=course_type) + for course in result + ] + return courses + + def get_file_list( + self, + course_id: str, + course_type: types.CourseType = types.CourseType.STUDENT, + ) -> list[types.File]: + url = urls.learn_file_classify(course_id=course_id) + response = self.get_with_token(url) + json = response.json() + records = json["object"]["rows"] + clazz = {} + for record in records: + clazz[record["kjflid"]] = record["bt"] # 课件分类 ID, 标题 + + url = urls.learn_file_list( + course_id=course_id, + course_type=course_type, + ) + response = self.get_with_token(url) + json = response.json() + result = [] + if course_type == types.CourseType.STUDENT: + result = json["object"] + else: # teacher + result = json["object"]["resultList"] + + files = [ + parser.parse_file( + file=file, course_id=course_id, clazz=clazz, course_type=course_type + ) + for file in result + ] + return files + + def get_homework_list( + self, course_id: str, course_type: types.CourseType = types.CourseType.STUDENT + ) -> list[types.Homework]: + result = [] + url = urls.learn_homework_list_new(course_id=course_id) + result += self.get_homework_list_at_url( + url=url, + status=types.HomeworkStatus(submitted=False, graded=False), + ) + url = urls.learn_homework_list_submitted(course_id=course_id) + result += self.get_homework_list_at_url( + url=url, + status=types.HomeworkStatus(submitted=True, graded=False), + ) + url = urls.learn_homework_list_graded(course_id=course_id) + result += self.get_homework_list_at_url( + url=url, + status=types.HomeworkStatus(submitted=True, graded=True), + ) + + return result + + def get_homework_list_at_url( + self, url: types.URL, status: types.HomeworkStatus + ) -> list[types.Homework]: + response = self.get_with_token(url) + json = response.json() + + result = json["object"]["aaData"] + + def parse_homework(work: dict) -> types.Homework: + detail = self.parse_homework_detail( + course_id=work["wlkcid"], # 课程 ID + homework_id=work["zyid"], # 作业 ID + student_homework_id=work["xszyid"], # 学生作业 ID + ) + + return types.Homework( + id=work["zyid"], # 作业 ID + student_homework_id=work["xszyid"], # 学生作业 ID + title=work["bt"], # 标题 + url=str( + urls.learn_homework_detail( + course_id=work["wlkcid"], # 课程 ID + homework_id=work["zyid"], # 作业 ID + student_homework_id=work["xszyid"], # 学生作业 ID + ) + ), + deadline=datetime.datetime.fromtimestamp(work["jzsj"] / 1000.0) + if work["jzsj"] + else None, # 截止时间 + submit_url=urls.learn_homework_submit( + work["wlkcid"], work["xszyid"] # 课程 ID, 学生作业 ID + ).str(), + submit_time=datetime.datetime.fromtimestamp(work["scsj"] / 1000.0) + if work["scsj"] + else None, # 上传时间 + grade=work["cj"], # 成绩 + grader_name=work["jsm"], # 教师名 + grade_content=work["pynr"], # 批阅内容 + grade_time=datetime.datetime.fromtimestamp(work["pysj"] / 1000.0) + if work["pysj"] + else None, # 批阅时间 + submitted=status.submitted, + graded=status.graded, + description=detail.description, + answer_content=detail.answer_content, + submitted_content=detail.submitted_content, + attachment=detail.attachment, + answer_attachment=detail.answer_attachment, + submitted_attachment=detail.submitted_attachment, + grade_attachment=detail.grade_attachment, + ) + + return [parse_homework(work) for work in result] + + def parse_homework_detail( + self, course_id: str, homework_id: str, student_homework_id: str + ) -> types.HomeworkDetail: + url = urls.learn_homework_detail( + course_id=course_id, + homework_id=homework_id, + student_homework_id=student_homework_id, + ) + response = self.get_with_token(url) + soup = bs4.BeautifulSoup(markup=response.text, features="html.parser") + boxboxs = soup.select(".boxbox") + ( + contents_and_requirements, + my_coursework_submitted, + instructors_comments, + ) = boxboxs + + div_c55s = contents_and_requirements.select( + "div.list.calendar.clearfix > div.fl.right > div.c55" + ) + description = div_c55s[0].get_text() + answer_content = div_c55s[1].get_text() + + submitted_content = my_coursework_submitted.select("div.list > div.right")[ + 2 + ].get_text() + + grade_content = typing.cast( + bs4.Tag, instructors_comments.select_one("div.list.description > div.right") + ).get_text() + + div_list_fujian_clearfix = soup.select("div.list.fujian.clearfix") + + return types.HomeworkDetail( + description=description.strip(), + answer_content=answer_content.strip(), + submitted_content=submitted_content.strip(), + grade_content=grade_content.strip(), + attachment=parser.parse_homework_file(div_list_fujian_clearfix[0]) + if len(div_list_fujian_clearfix) > 0 + else None, + answer_attachment=parser.parse_homework_file(div_list_fujian_clearfix[1]) + if len(div_list_fujian_clearfix) > 1 + else None, + submitted_attachment=parser.parse_homework_file(div_list_fujian_clearfix[2]) + if len(div_list_fujian_clearfix) > 2 + else None, + grade_attachment=parser.parse_homework_file(div_list_fujian_clearfix[3]) + if len(div_list_fujian_clearfix) > 3 + else None, + ) diff --git a/thu_learn_downloader/parser.py b/thu_learn_downloader/parser.py new file mode 100644 index 0000000..de394a6 --- /dev/null +++ b/thu_learn_downloader/parser.py @@ -0,0 +1,100 @@ +import datetime +import html +import typing +import urllib.parse + +import bs4 + +from . import types +from . import urls + + +def parse_course_info( + course: dict, + course_type: types.CourseType = types.CourseType.STUDENT, +): + # url = urls.learn_course_time_location(course["wlkcid"]) + # response = self.get_with_token(url) + return types.CourseInfo( + id=course["wlkcid"], + name=course["kcm"], + english_name=course["ywkcm"], + # time_and_location=response.json(), + url=str(urls.learn_course_url(course["wlkcid"], typing.cast(str, course_type))), + teacher_name=course["jsm"] + if course["jsm"] + else "", # teacher can not fetch this + teacher_number=course["jsh"], + course_number=course["kch"], + course_index=int( + course["kxh"] + ), # course["kxh"] could be string (teacher mode) or number (student mode) + course_type=course_type, + ) + + +def parse_file( + file: dict, + course_id: str, + clazz: dict[str, str], + course_type: types.CourseType = types.CourseType.STUDENT, +) -> types.File: + title: str = html.unescape(file["bt"]) + download_url: str = str( + urls.learn_file_download( + file_id=file["wjid"] + if course_type == types.CourseType.STUDENT + else file["id"], + course_type=typing.cast(str, course_type), + course_id=course_id, + ) + ) + preview_url = None + return types.File( + id=file["wjid"], # 文件 ID + title=title, # 标题 + description=file["ms"], # 描述 + raw_size=file["wjdx"], # 文件大小 + size=file["fileSize"], + upload_time=datetime.datetime.strptime(file["scsj"], r"%Y-%m-%d %H:%M"), # 上传时间 + download_url=str(download_url), + preview_url=str(preview_url), + is_new=file["isNew"], + marked_important=file["sfqd"] == 1, # 是否强调 + visit_count=file["llcs"] or 0, # 浏览次数 + download_count=file["xzcs"] or 0, # 下载次数 + file_type=file["wjlx"], # 文件类型 + remote_file=types.RemoteFile( + id=file["wjid"], # 文件 ID + name=title, + download_url=str(download_url), + preview_url=str(preview_url), + size=file["fileSize"], + ), + clazz=clazz[file["kjflid"]], # 课件分类 ID + ) + + +def parse_homework_file(div: bs4.Tag) -> typing.Optional[types.RemoteFile]: + ftitle = typing.cast( + bs4.Tag, div.select_one("span.ftitle") or div.select_one("span.ft") + ) + if not ftitle: + return None + a = typing.cast(bs4.Tag, ftitle.select_one("a")) + span = typing.cast(bs4.Tag, div.select_one("span.color_999")) + size = span.getText() + href = typing.cast(str, a["href"]) + params = dict(urllib.parse.parse_qsl(urllib.parse.urlparse(href).query)) + attachment_id = params["fileId"] + download_url = types.URL( + netloc=urls.LEARN_PREFIX.netloc, + path=params["downloadUrl"] if "downloadUrl" in params else href, + ) + return types.RemoteFile( + id=attachment_id, + name=a.getText(), + download_url=download_url.str(), + preview_url=None, + size=size.strip(), + ) diff --git a/thu_learn_downloader/readme.py b/thu_learn_downloader/readme.py new file mode 100644 index 0000000..172bf18 --- /dev/null +++ b/thu_learn_downloader/readme.py @@ -0,0 +1,62 @@ +from . import types + + +WORK_README = """## {title} + +### Contents and Requirements + +#### Description + +{description} + +#### ANS + +{ans} + +#### Deadline + +{deadline} + +### My coursework submitted + +#### Date + +{submit_time} + +#### Content + +{submit_content} + +### Instructors' comments + +#### By + +{grader_name} + +#### Date + +{grade_time} + +#### Grade + +{grade} + +#### Comment + +{comment} +""" + + +def dump_work(work: types.Homework) -> str: + return WORK_README.format( + title=work.title, + description=work.description or "", + ans=work.answer_content or "", + deadline=str(work.deadline or ""), + submit_time=str(work.submit_time or ""), + submit_content=work.submitted_content or "", + grader_name=work.grader_name or "", + grade_time=str(work.grade_time or ""), + grade=work.grade or work.grade_level or "", + comment=work.grade_content, + ) diff --git a/thu_learn_downloader/sync.py b/thu_learn_downloader/sync.py new file mode 100644 index 0000000..e84e4e8 --- /dev/null +++ b/thu_learn_downloader/sync.py @@ -0,0 +1,231 @@ +import os +import shutil +import sys +import typing + +import omegaconf +import rich.console +import rich.progress + +from . import readme +from . import types +from . import utils +from .downloader import Downloader +from .helper import LearnHelper + + +def sync_all( + helper: LearnHelper, + downloader: Downloader, + config: omegaconf.DictConfig, + console: rich.console.Console = rich.console.Console(), + overall_progress: rich.progress.Progress = rich.progress.Progress(), + semesters_task_id: rich.progress.TaskID = rich.progress.TaskID(0), + courses_task_id: rich.progress.TaskID = rich.progress.TaskID(1), +) -> None: + semesters = config.get("semesters") or helper.get_semester_id_list() + for semester in overall_progress.track( + semesters, task_id=semesters_task_id, description="Semesters" + ): + sync_semester( + helper=helper, + downloader=downloader, + semester_id=semester, + config=config, + console=console, + overall_progress=overall_progress, + courses_task_id=courses_task_id, + ) + + +def sync_semester( + helper: LearnHelper, + downloader: Downloader, + semester_id: str, + config: omegaconf.DictConfig, + console: rich.console.Console = rich.console.Console(), + overall_progress: rich.progress.Progress = rich.progress.Progress(), + courses_task_id: rich.progress.TaskID = rich.progress.TaskID(1), +): + courses = helper.get_course_list(semester_id=semester_id) + if config.get("courses"): + courses = [ + course + for course in courses + if ( + (course.name in config["courses"]) + or (course.english_name in config["courses"]) + ) + ] + overall_progress.update( + task_id=courses_task_id, + description=f"{semester_id[:-2]} {utils.parse_semester_type(int(semester_id[-1])).value}", + ) + for course in overall_progress.track( + courses, task_id=courses_task_id, description=semester_id + ): + sync_course( + helper=helper, + downloader=downloader, + course=course, + config=config, + console=console, + ) + + +def sync_course( + helper: LearnHelper, + downloader: Downloader, + course: types.CourseInfo, + config: omegaconf.DictConfig, + console: rich.console.Console = rich.console.Console(), +): + sync_files( + helper=helper, + downloader=downloader, + course=course, + config=config, + console=console, + ) + sync_works( + helper=helper, + downloader=downloader, + course=course, + config=config, + console=console, + ) + + +def sync_files( + helper: LearnHelper, + downloader: Downloader, + course: types.CourseInfo, + config: omegaconf.DictConfig, + console: rich.console.Console = rich.console.Console(), +): + prefix = config.get( + key="prefix", default_value=os.path.join(os.getcwd(), "thu-learn") + ) + file_size_limit = config.get(key="file_size_limit") or sys.maxsize + files = helper.get_file_list(course_id=course.id, course_type=course.course_type) + for file in files: + if file.raw_size >= file_size_limit: + console.log( + f"Skip {file.remote_file.name} of size {file.size}", + style="bold bright_yellow", + ) + continue + downloader.schedule_download( + url=file.remote_file.download_url, + file=os.path.join( + prefix, + utils.slugify(course.english_name), + "docs", + utils.slugify(file.clazz), + utils.slugify(f"{file.remote_file.name}.{file.file_type}"), + ), + raw_size=file.raw_size, + upload_time=file.upload_time, + session=helper, + description=f"[bold bright_green]{course.name} > {file.remote_file.name}", + console=console, + ) + + +def sync_works( + helper: LearnHelper, + downloader: Downloader, + course: types.CourseInfo, + config: omegaconf.DictConfig, + console: rich.console.Console = rich.console.Console(), +): + prefix = config.get( + key="prefix", default_value=os.path.join(os.getcwd(), "thu-learn") + ) + works = helper.get_homework_list( + course_id=course.id, course_type=course.course_type + ) + for work in works: + sync_work_detail(course=course, work=work, config=config, console=console) + + def sync_attach( + attachment: typing.Optional[types.RemoteFile], + attachment_type: typing.Literal["attach"] + | typing.Literal["ans"] + | typing.Literal["submit"] + | typing.Literal["comment"], + ) -> None: + sync_work_attachment( + helper=helper, + downloader=downloader, + course=course, + work=work, + attachment=attachment, + attachment_type=attachment_type, + config=config, + console=console, + ) + + sync_attach(work.attachment, "attach") + sync_attach(work.answer_attachment, "ans") + sync_attach(work.submitted_attachment, "submit") + sync_attach(work.grade_attachment, "comment") + + +def sync_work_detail( + course: types.CourseInfo, + work: types.Homework, + config: omegaconf.DictConfig, + console: rich.console.Console = rich.console.Console(), +): + prefix = config.get( + key="prefix", default_value=os.path.join(os.getcwd(), "thu-learn") + ) + file = os.path.join( + prefix, + utils.slugify(course.english_name), + "work", + utils.slugify(work.title), + "README.md", + ) + os.makedirs(name=os.path.dirname(file), exist_ok=True) + with open(file=file, mode="w") as fp: + fp.write(readme.dump_work(work)) + if shutil.which(cmd="prettier"): + os.system(f"prettier --write {file}") + + +def sync_work_attachment( + helper: LearnHelper, + downloader: Downloader, + course: types.CourseInfo, + work: types.Homework, + attachment: typing.Optional[types.RemoteFile], + attachment_type: typing.Literal["attach"] + | typing.Literal["ans"] + | typing.Literal["submit"] + | typing.Literal["comment"], + config: omegaconf.DictConfig, + console: rich.console.Console = rich.console.Console(), +) -> None: + prefix = config.get( + key="prefix", default_value=os.path.join(os.getcwd(), "thu-learn") + ) + if attachment: + title = attachment.name + for p in ["attach", "ans", "submit", "comment"]: + title = title.removeprefix(p + "-") + title = attachment_type + "-" + title + downloader.schedule_download( + url=attachment.download_url, + file=os.path.join( + prefix, + utils.slugify(course.english_name), + "work", + utils.slugify(work.title), + utils.slugify(title), + ), + session=helper, + console=console, + description=f"[bold bright_blue]{course.name} > {title}", + ) diff --git a/thu_learn_downloader/types.py b/thu_learn_downloader/types.py new file mode 100644 index 0000000..41075f0 --- /dev/null +++ b/thu_learn_downloader/types.py @@ -0,0 +1,216 @@ +import dataclasses +import datetime +import enum +import os +import typing +import urllib.parse + + +@dataclasses.dataclass(kw_only=True) +class URL: + scheme: str = "https" + netloc: str = "" + path: str = "" + params: str = "" + query: str | dict | list = "" + fragment: str = "" + + def __str__(self) -> str: + return urllib.parse.urlunparse(self.astuple()) + + def str(self) -> str: + return str(self) + + def astuple(self) -> urllib.parse.ParseResult: + return urllib.parse.ParseResult( + scheme=self.scheme, + netloc=self.netloc, + path=os.path.join(self.path) if isinstance(self.path, list) else self.path, + params=self.params, + query=self.query + if isinstance(self.query, str) + else urllib.parse.urlencode(self.query, doseq=True), + fragment=self.fragment, + ) + + +@dataclasses.dataclass(kw_only=True) +class Credential: + username: str + password: str + + +class FailReason(enum.Enum): + NO_CREDENTIAL = "no credential provided" + ERROR_FETCH_FROM_ID = "could not fetch ticket from id.tsinghua.edu.cn" + BAD_CREDENTIAL = "bad credential" + ERROR_ROAMING = "could not roam to learn.tsinghua.edu.cn" + NOT_LOGGED_IN = "not logged in or login timeout" + NOT_IMPLEMENTED = "not implemented" + INVALID_RESPONSE = "invalid response" + UNEXPECTED_STATUS = "unexpected status" + + +@dataclasses.dataclass(kw_only=True) +class APIError: + reason: FailReason + extra = None + + +class SemesterType(enum.Enum): + FALL = "Autumn Term" + SPRING = "Spring Term" + SUMMER = "Summer Term" + UNKNOWN = "" + + +class ContentType(enum.Enum): + NOTIFICATION = "notification" + FILE = "file" + HOMEWORK = "homework" + DISCUSSION = "discussion" + QUESTION = "question" + + +@dataclasses.dataclass(kw_only=True) +class SemesterInfo: + id: str + start_date: datetime.date + end_date: datetime.date + start_year: int + end_year: int + type: SemesterType + + +class CourseType(enum.Enum): + STUDENT = "student" + TEACHER = "teacher" + + +@dataclasses.dataclass(kw_only=True) +class CourseInfo: + id: str + name: str + english_name: str + time_and_location: typing.Optional[list[str]] = None + url: str + teacher_name: str + teacher_number: str + course_number: str + course_index: int + course_type: CourseType + + +@dataclasses.dataclass(kw_only=True) +class RemoteFile: + id: str + name: str + download_url: str + preview_url: typing.Optional[str] = None + size: str + + +@dataclasses.dataclass(kw_only=True) +class Notification: + id: str + title: str + content: str + has_read: bool + url: str + marked_important: bool + publish_time: datetime.datetime + publisher: str + + # notification detail + attachment: typing.Optional[RemoteFile] = None + + +@dataclasses.dataclass(kw_only=True) +class File: + id: str + raw_size: int # size in byte + size: str # inaccurate size description (like '1M') + title: str + description: str + upload_time: datetime.datetime + download_url: str # for teachers, this url will not initiate download directly + preview_url: str # preview is not supported on all types of files, check before use + is_new: bool + marked_important: bool + visit_count: int + download_count: int + file_type: str + remote_file: RemoteFile # for compatibility + + clazz: str + + +@dataclasses.dataclass(kw_only=True) +class HomeworkStatus: + submitted: bool + graded: bool + + +@dataclasses.dataclass(kw_only=True) +class HomeworkDetail: + description: typing.Optional[str] = None + attachment: typing.Optional[RemoteFile] = None # attachment from teacher + answer_content: typing.Optional[str] = None # answer from teacher + answer_attachment: typing.Optional[RemoteFile] = None + submitted_content: typing.Optional[str] = None # submitted content from student + submitted_attachment: typing.Optional[RemoteFile] = None + grade_content: typing.Optional[str] = None + grade_attachment: typing.Optional[RemoteFile] = None # grade from teacher + + +@dataclasses.dataclass(kw_only=True) +class Homework(HomeworkStatus, HomeworkDetail): + id: str + student_homework_id: str + title: str + deadline: typing.Optional[datetime.datetime] + url: str + submit_url: str + submit_time: typing.Optional[datetime.datetime] = None + grade: typing.Optional[int] = None + # some homework has levels but not grades, like A/B/.../F + grade_level: typing.Optional[str] = None + grade_time: typing.Optional[datetime.datetime] = None + grader_name: typing.Optional[str] = None + + +@dataclasses.dataclass(kw_only=True) +class DiscussionBase: + id: str + title: str + publisher_name: str + publish_date: datetime.datetime + last_replier_name: str + last_reply_time: datetime.datetime + visit_count: int + reply_count: int + + +@dataclasses.dataclass(kw_only=True) +class Discussion(DiscussionBase): + url: str + board_id: str + + +@dataclasses.dataclass(kw_only=True) +class Question(DiscussionBase): + url: str + question: str + + +Content = Notification | File | Homework | Discussion | Question + + +@dataclasses.dataclass(kw_only=True) +class CalendarEvent: + location: str + status: str + start_time: str + end_time: str + date: str + course_name: str diff --git a/thu_learn_downloader/urls.py b/thu_learn_downloader/urls.py new file mode 100644 index 0000000..fcfeaa2 --- /dev/null +++ b/thu_learn_downloader/urls.py @@ -0,0 +1,422 @@ +from . import types +from . import utils + + +LEARN_PREFIX = types.URL(netloc="learn.tsinghua.edu.cn") +REGISTRAR_PREFIX = types.URL(netloc="zhjw.cic.tsinghua.edu.cn") + + +MAX_SIZE = 200 + + +def id_login() -> types.URL: + return types.URL( + netloc=LEARN_PREFIX.netloc, + path="/do/off/ui/auth/login/post/bb5df85216504820be7bba2b0ae1535b/0", + query="/login.do", + ) + + +def id_login_form_data(username: str, password: str) -> types.URL: + return types.URL( + query={ + "i_user": username, + "i_pass": password, + "atOnce": "true", + }, + ) + + +def learn_auth_roam(ticket: str) -> types.URL: + return types.URL( + netloc=LEARN_PREFIX.netloc, + path="/b/j_spring_security_thauth_roaming_entry", + query={ + "ticket": ticket, + }, + ) + + +def learn_logout() -> types.URL: + return types.URL( + netloc=LEARN_PREFIX.netloc, + path="/f/j_spring_security_logout", + ) + + +def learn_student_course_list_page() -> types.URL: + return types.URL( + netloc=LEARN_PREFIX.netloc, + path="/f/wlxt/index/course/student/", + ) + + +def learn_semester_list() -> types.URL: + return types.URL( + netloc=LEARN_PREFIX.netloc, + path="/b/wlxt/kc/v_wlkc_xs_xktjb_coassb/queryxnxq", + ) + + +def learn_current_semester() -> types.URL: + return types.URL( + netloc=LEARN_PREFIX.netloc, + path="/b/kc/zhjw_v_code_xnxq/getCurrentAndNextSemester", + ) + + +def learn_course_list(semester: str, course_type: types.CourseType) -> types.URL: + if course_type == types.CourseType.STUDENT: + return types.URL( + netloc=LEARN_PREFIX.netloc, + path=f"/b/wlxt/kc/v_wlkc_xs_xkb_kcb_extend/student/loadCourseBySemesterId/{semester}", + ) + else: + return types.URL( + netloc=LEARN_PREFIX.netloc, + path=f"/b/kc/v_wlkc_kcb/queryAsorCoCourseList/{semester}/0", + ) + + +def learn_course_url(course_id: str, course_type: str) -> types.URL: + return types.URL( + netloc=LEARN_PREFIX.netloc, + path=f"/f/wlxt/index/course/{course_type}/course", + query={ + "wlkcid": course_id, + }, + ) + + +def learn_course_time_location(course_id: str) -> types.URL: + return types.URL( + netloc=LEARN_PREFIX.netloc, + path="/b/kc/v_wlkc_xk_sjddb/detail", + query={ + "id": course_id, + }, + ) + + +def learn_teacher_course_url(course_id: str) -> types.URL: + return types.URL( + netloc=LEARN_PREFIX.netloc, + path="/f/wlxt/index/course/teacher/course", + query={ + "wlkcid": course_id, + }, + ) + + +def learn_file_list(course_id: str, course_type: types.CourseType) -> types.URL: + if course_type == types.CourseType.STUDENT: + return types.URL( + netloc=LEARN_PREFIX.netloc, + path="/b/wlxt/kj/wlkc_kjxxb/student/kjxxbByWlkcidAndSizeForStudent", + query={ + "wlkcid": course_id, + "size": MAX_SIZE, + }, + ) + else: + return types.URL( + netloc=LEARN_PREFIX.netloc, + path="/b/wlxt/kj/v_kjxxb_wjwjb/teacher/queryByWlkcid", + query={ + "wlkcid": course_id, + "size": MAX_SIZE, + }, + ) + + +def learn_file_classify(course_id: str) -> types.URL: + return types.URL( + netloc=LEARN_PREFIX.netloc, + path="/b/wlxt/kj/wlkc_kjflb/student/pageList", + query={ + "wlkcid": course_id, + }, + ) + + +def learn_file_download(file_id: str, course_type: str, course_id: str) -> types.URL: + if course_type == types.CourseType.STUDENT: + return types.URL( + netloc=LEARN_PREFIX.netloc, + path="/b/wlxt/kj/wlkc_kjxxb/student/downloadFile", + query={ + "sfgk": 0, + "wjid": file_id, + }, + ) + else: + return types.URL( + netloc=LEARN_PREFIX.netloc, + path="/f/wlxt/kj/wlkc_kjxxb/teacher/beforeView", + query={ + "id": file_id, + "wlkcid": course_id, + }, + ) + + +def learn_file_preview( + type: types.ContentType, + file_id: str, + course_type: types.CourseType, + first_page_only: bool = False, +) -> types.URL: + return types.URL( + netloc=LEARN_PREFIX.netloc, + path=f"/f/wlxt/kc/wj_wjb/{course_type}/beforePlay", + query={ + "wjid": file_id, + "mk": utils.get_mk_from_type(type), + "browser": -1, + "sfgk": 0, + "pageType": "first" if first_page_only else "all", + }, + ) + + +def learn_notification_list(course_id: str, course_type: types.CourseType) -> types.URL: + if course_type == types.CourseType.STUDENT: + return types.URL( + netloc=LEARN_PREFIX.netloc, + path="/b/wlxt/kcgg/wlkc_ggb/student/kcggListXs", + query={ + "wlkcid": course_id, + "size": MAX_SIZE, + }, + ) + else: + return types.URL( + netloc=LEARN_PREFIX.netloc, + path="/b/wlxt/kcgg/wlkc_ggb/teacher/kcggList", + query={ + "wlkcid": course_id, + "size": MAX_SIZE, + }, + ) + + +def learn_notification_detail( + course_id: str, notification_id: str, course_type: types.CourseType +) -> types.URL: + if course_type == types.CourseType.STUDENT: + return types.URL( + netloc=LEARN_PREFIX.netloc, + path="/f/wlxt/kcgg/wlkc_ggb/student/beforeViewXs", + query={ + "wlkcid": course_id, + "id": notification_id, + }, + ) + else: + return types.URL( + netloc=LEARN_PREFIX.netloc, + path="/f/wlxt/kcgg/wlkc_ggb/teacher/beforeViewJs", + query={ + "wlkcid": course_id, + "id": notification_id, + }, + ) + + +def learn_notification_edit(course_type: types.CourseType) -> types.URL: + return types.URL( + netloc=LEARN_PREFIX.netloc, + path=f"/b/wlxt/kcgg/wlkc_ggb/{course_type}/editKcgg", + ) + + +def learn_homework_list_source(course_id: str) -> list[dict]: + return [ + { + "url": learn_homework_list_new(course_id), + "status": types.HomeworkStatus(submitted=False, graded=False), + }, + { + "url": learn_homework_list_submitted(course_id), + "status": types.HomeworkStatus(submitted=True, graded=False), + }, + { + "url": learn_homework_list_graded(course_id), + "status": types.HomeworkStatus(submitted=True, graded=True), + }, + ] + + +def learn_homework_list_new(course_id: str) -> types.URL: + return types.URL( + netloc=LEARN_PREFIX.netloc, + path="/b/wlxt/kczy/zy/student/index/zyListWj", + query={ + "wlkcid": course_id, + "size": MAX_SIZE, + }, + ) + + +def learn_homework_list_submitted(course_id: str) -> types.URL: + return types.URL( + netloc=LEARN_PREFIX.netloc, + path="/b/wlxt/kczy/zy/student/index/zyListYjwg", + query={ + "wlkcid": course_id, + "size": MAX_SIZE, + }, + ) + + +def learn_homework_list_graded(course_id: str) -> types.URL: + return types.URL( + netloc=LEARN_PREFIX.netloc, + path="/b/wlxt/kczy/zy/student/index/zyListYpg", + query={ + "wlkcid": course_id, + "size": MAX_SIZE, + }, + ) + + +def learn_homework_detail( + course_id: str, homework_id: str, student_homework_id: str +) -> types.URL: + return types.URL( + netloc=LEARN_PREFIX.netloc, + path="/f/wlxt/kczy/zy/student/viewCj", + query={ + "wlkcid": course_id, + "zyid": homework_id, + "xszyid": student_homework_id, + }, + ) + + +def learn_homework_download(course_id: str, attachment_id: str) -> types.URL: + return types.URL( + netloc=LEARN_PREFIX.netloc, + path=f"/b/wlxt/kczy/zy/student/downloadFile/{course_id}/{attachment_id}", + ) + + +def learn_homework_submit(course_id: str, student_homework_id: str) -> types.URL: + return types.URL( + netloc=LEARN_PREFIX.netloc, + path="/f/wlxt/kczy/zy/student/tijiao", + query={ + "wlkcid": course_id, + "xszyid": student_homework_id, + }, + ) + + +def learn_discussion_list(course_id: str, course_type: types.CourseType) -> types.URL: + return types.URL( + netloc=LEARN_PREFIX.netloc, + path=f"/b/wlxt/bbs/bbs_tltb/{course_type}/kctlList", + query={ + "wlkcid": course_id, + "size": MAX_SIZE, + }, + ) + + +def learn_discussion_detail( + course_id: str, + board_id: str, + discussion_id: str, + course_type: types.CourseType, + tab_id=1, +) -> types.URL: + return types.URL( + netloc=LEARN_PREFIX.netloc, + path=f"/f/wlxt/bbs/bbs_tltb/{course_type}/viewTlById", + query={ + "wlkcid": course_id, + "id": discussion_id, + "tabbh": tab_id, + "bqid": board_id, + }, + ) + + +def learn_question_list_answered( + course_id: str, course_type: types.CourseType +) -> types.URL: + return types.URL( + netloc=LEARN_PREFIX.netloc, + path=f"/b/wlxt/bbs/bbs_tltb/{course_type}/kcdyList", + query={ + "wlkcid": course_id, + "size": MAX_SIZE, + }, + ) + + +def learn_question_detail( + course_id: str, question_id: str, course_type: types.CourseType +) -> types.URL: + if course_type == types.CourseType.STUDENT: + return types.URL( + netloc=LEARN_PREFIX.netloc, + path="/f/wlxt/bbs/bbs_kcdy/student/viewDyById", + query={ + "wlkcid": course_id, + "id": question_id, + }, + ) + else: + return types.URL( + netloc=LEARN_PREFIX.netloc, + path="/f/wlxt/bbs/bbs_kcdy/teacher/beforeEditDy", + query={ + "wlkcid": course_id, + "id": question_id, + }, + ) + + +def registrar_ticket_form_data() -> types.URL: + return types.URL( + query={ + "appId": "ALL_ZHJW", + }, + ) + + +def registrar_ticket() -> types.URL: + return types.URL( + netloc=LEARN_PREFIX.netloc, + path="/b/wlxt/common/auth/gnt", + ) + + +def registrar_auth(ticket: str) -> types.URL: + return types.URL( + netloc=REGISTRAR_PREFIX.netloc, + path="/j_acegi_login.do", + query={ + "url": "/", + "ticket": ticket, + }, + ) + + +def registrar_calendar( + start_date: str, + end_date: str, + graduate: bool = False, + callback_name: str = "unknown", +) -> types.URL: + return types.URL( + netloc=REGISTRAR_PREFIX.netloc, + path="/jxmh_out.do", + query={ + "m": ("yjs" if graduate else "bks") + "_jxrl_all", + "p_start_date": start_date, + "p_end_date": end_date, + "jsoncallback": callback_name, + }, + ) diff --git a/thu_learn_downloader/utils.py b/thu_learn_downloader/utils.py new file mode 100644 index 0000000..3a6b6c2 --- /dev/null +++ b/thu_learn_downloader/utils.py @@ -0,0 +1,36 @@ +import slugify as slug + +from . import types + + +def slugify(text: str) -> str: + return ".".join( + [ + slug.slugify(text=segment, word_boundary=True, allow_unicode=True) + for segment in text.split(".") + ] + ) + + +def parse_semester_type(n: int) -> types.SemesterType: + if n == 1: + return types.SemesterType.FALL + elif n == 2: + return types.SemesterType.SPRING + elif n == 3: + return types.SemesterType.SUMMER + else: + return types.SemesterType.UNKNOWN + + +CONTENT_TYPE_MK_MAP: dict[types.ContentType, str] = { + types.ContentType.NOTIFICATION: "kcgg", # 课程公告 + types.ContentType.FILE: "kcwj", # 课程文件 + types.ContentType.HOMEWORK: "kczy", # 课程作业 + types.ContentType.DISCUSSION: "", + types.ContentType.QUESTION: "", +} + + +def get_mk_from_type(type: types.ContentType) -> str: + return "mk_" + CONTENT_TYPE_MK_MAP.get(type, "UNKNOWN") diff --git a/thu_learn_lib/helper.py b/thu_learn_lib/helper.py deleted file mode 100644 index ce7b882..0000000 --- a/thu_learn_lib/helper.py +++ /dev/null @@ -1,309 +0,0 @@ -import datetime -import typing -import urllib.parse - -import bs4 -import requests -import requests.adapters - -from . import ty -from . import urls -from . import parser - - -class LearnHelper(requests.Session): - username: str - password: str - - def __init__(self, username: str, password: str) -> None: - super().__init__() - self.username = username - self.password = password - - self.mount( - prefix="https://", - adapter=requests.adapters.HTTPAdapter( - max_retries=5, - ), - ) - - @property - def token(self) -> str: - return self.cookies.get(name="XSRF-TOKEN", default="") - - def get_with_token(self, url: ty.URL) -> requests.Response: - if url.query: - url.query["_csrf"] = self.token - else: - url.query = { - "_csrf": self.token, - } - return self.get(url) - - def login(self) -> bool: - response = self.get(urls.LEARN_PREFIX) - soup = bs4.BeautifulSoup(response.text, features="html.parser") - form = soup.find( - name="form", attrs={"class": "w", "id": "loginForm", "method": "post"} - ) - - payload = urls.id_login_form_data( - username=self.username, - password=self.password, - ).query - response = self.post(url=form["action"], data=payload) - soup = bs4.BeautifulSoup(response.text, features="html.parser") - a = soup.find(name="a") - href = a["href"] - parse_result: urllib.parse.ParseResult = urllib.parse.urlparse(url=href) - query = urllib.parse.parse_qs(qs=parse_result.query) - self.status = query["status"][0] - self.ticket = query["ticket"][0] - - response = self.get(a["href"]) - - url = urls.learn_auth_roam(ticket=self.ticket) - response = self.get(url) - - url = urls.learn_student_course_list_page() - response = self.get(url) - - print(f"User {self.username} login {self.status}!") - - return self.status == "SUCCESS" - - def logout(self) -> None: - url = urls.learn_logout() - response = self.post(url) - - def get_semester_id_list(self) -> list[str]: - url = urls.learn_semester_list() - response = self.get_with_token(url) - json = response.json() - return list(filter(bool, json)) - - def get_current_semester(self) -> ty.SemesterInfo: - url = urls.learn_current_semester() - response = self.get_with_token(url) - json = response.json() - result = json["result"] - return ty.SemesterInfo( - id=result["id"], - start_date=datetime.datetime.strptime(result["kssj"], r"%Y-%m-%d").date(), - end_date=datetime.datetime.strptime(result["jssj"], r"%Y-%m-%d").date(), - start_year=int(result["xnxq"][0:4]), - end_year=int(result["xnxq"][5:9]), - type=int(result["xnxq"][10:]), - ) - - def get_course_list( - self, - semester_id: str, - course_type: ty.CourseType = ty.CourseType.STUDENT, - ) -> list[ty.CourseInfo]: - url = urls.learn_course_list(semester_id, course_type) - response = self.get_with_token(url) - json = response.json() - result = json["resultList"] - courses = list( - map( - parser.parse_course_info, - result, - [course_type] * len(result), - ) - ) - return courses - - def get_file_list( - self, - course_id: str, - course_type: ty.CourseType = ty.CourseType.STUDENT, - ) -> list[ty.File]: - url = urls.learn_file_classify( - course_id=course_id, - ) - response = self.get_with_token(url) - json = response.json() - records = json["object"]["rows"] - clazz = {} - for record in records: - clazz[record["kjflid"]] = record["bt"] # 课件分类 ID, 标题 - - url = urls.learn_file_list( - course_id=course_id, - course_type=course_type, - ) - response = self.get_with_token(url) - json = response.json() - result = [] - if course_type == ty.CourseType.STUDENT: - result = json["object"] - else: # teacher - result = json["object"]["resultList"] - - files = list( - map( - parser.parse_file, - result, - [course_id] * len(result), - [clazz] * len(result), - [course_type] * len(result), - ) - ) - return files - - def get_homework_list( - self, course_id: str, course_type: ty.CourseType = ty.CourseType.STUDENT - ) -> list[ty.Homework]: - result = [] - url = urls.learn_homework_list_new(course_id=course_id) - result += self.get_homework_list_at_url( - url=url, - status=ty.HomeworkStatus(submitted=False, graded=False), - ) - url = urls.learn_homework_list_submitted(course_id=course_id) - result += self.get_homework_list_at_url( - url=url, - status=ty.HomeworkStatus(submitted=True, graded=False), - ) - url = urls.learn_homework_list_graded(course_id=course_id) - result += self.get_homework_list_at_url( - url=url, - status=ty.HomeworkStatus(submitted=True, graded=True), - ) - - return result - - def get_homework_list_at_url( - self, url: ty.URL, status: ty.HomeworkStatus - ) -> list[ty.Homework]: - response = self.get_with_token(url) - json = response.json() - - result = json["object"]["aaData"] - - def mapper(work: dict) -> ty.Homework: - detail = self.parse_homework_detail( - course_id=work["wlkcid"], # 课程 ID - homework_id=work["zyid"], # 作业 ID - student_homework_id=work["xszyid"], # 学生作业 ID - ) - - return ty.Homework( - id=work["zyid"], # 作业 ID - student_homework_id=work["xszyid"], # 学生作业 ID - title=work["bt"], # 标题 - url=str( - urls.learn_homework_detail( - course_id=work["wlkcid"], # 课程 ID - homework_id=work["zyid"], # 作业 ID - student_homework_id=work["xszyid"], # 学生作业 ID - ) - ), - deadline=datetime.datetime.fromtimestamp(work["jzsj"] / 1000.0) - if work["jzsj"] - else None, # 截止时间 - submit_url=urls.learn_homework_submit( - work["wlkcid"], work["xszyid"] # 课程 ID, 学生作业 ID - ), - submit_time=datetime.datetime.fromtimestamp(work["scsj"] / 1000.0) - if work["scsj"] - else None, # 上传时间 - grade=work["cj"], # 成绩 - grader_name=work["jsm"], # 教师名 - grade_content=work["pynr"], # 批阅内容 - grade_time=datetime.datetime.fromtimestamp(work["pysj"] / 1000.0) - if work["pysj"] - else None, # 批阅时间 - submitted=status.submitted, - graded=status.graded, - description=detail.description, - answer_content=detail.answer_content, - submitted_content=detail.submitted_content, - attachment=detail.attachment, - answer_attachment=detail.answer_attachment, - submitted_attachment=detail.submitted_attachment, - grade_attachment=detail.grade_attachment, - ) - - return list(map(mapper, result)) - - def parse_homework_detail( - self, course_id: str, homework_id: str, student_homework_id: str - ) -> ty.HomeworkDetail: - url = urls.learn_homework_detail( - course_id=course_id, - homework_id=homework_id, - student_homework_id=student_homework_id, - ) - response = self.get_with_token(url) - text = response.text - soup = bs4.BeautifulSoup(markup=text, features="html.parser") - - div_list_calendar_clearfix: typing.Iterable[bs4.element.Tag] = soup.find_all( - name="div", attrs={"class": "list calendar clearfix"} - ) - div_fl_right: list[bs4.element.Tag] = sum( - [ - div.find_all( - name="div", - attrs={ - "class": "fl right", - }, - ) - for div in div_list_calendar_clearfix - ], - [], - ) - div_c55: list[bs4.element.Tag] = sum( - [ - div.find_all( - name="div", - attrs={ - "class": "c55", - }, - ) - for div in div_fl_right - ], - [], - ) - description: str = div_c55[0].getText() - answer_content: str = div_c55[1].getText() - div_box: typing.Iterable[bs4.element.Tag] = soup.find_all( - name="div", - attrs={ - "class": "boxbox", - }, - ) - div_box: bs4.element.Tag = div_box[1] - div_right: typing.Iterable[bs4.element.Tag] = div_box.find_all( - name="div", - attrs={ - "class": "right", - }, - ) - submitted_content: str = div_right[2].getText() - - div_list_fujian_clearfix: typing.Iterable[bs4.element.Tag] = soup.find_all( - name="div", - attrs={ - "class": "list fujian clearfix", - }, - ) - return ty.HomeworkDetail( - description=description.strip(), - answer_content=answer_content.strip(), - submitted_content=submitted_content.strip(), - attachment=parser.parse_homework_file(div_list_fujian_clearfix[0]) - if len(div_list_fujian_clearfix) > 0 - else None, - answer_attachment=parser.parse_homework_file(div_list_fujian_clearfix[1]) - if len(div_list_fujian_clearfix) > 1 - else None, - submitted_attachment=parser.parse_homework_file(div_list_fujian_clearfix[2]) - if len(div_list_fujian_clearfix) > 2 - else None, - grade_attachment=parser.parse_homework_file(div_list_fujian_clearfix[3]) - if len(div_list_fujian_clearfix) > 3 - else None, - ) diff --git a/thu_learn_lib/parser.py b/thu_learn_lib/parser.py deleted file mode 100644 index 5bbd8fe..0000000 --- a/thu_learn_lib/parser.py +++ /dev/null @@ -1,107 +0,0 @@ -import datetime -import urllib.parse - -import bs4 - -from . import ty -from . import urls - - -def parse_course_info( - course: dict, - course_type: ty.CourseType = ty.CourseType.STUDENT, -): - # url = urls.learn_course_time_location(course["wlkcid"]) - # response = self.get_with_token(url) - return ty.CourseInfo( - id=course["wlkcid"], - name=course["kcm"], - english_name=course["ywkcm"], - # time_and_location=response.json(), - url=str(urls.learn_course_url(course["wlkcid"], course_type)), - teacher_name=course["jsm"] - if course["jsm"] - else "", # teacher can not fetch this - teacher_number=course["jsh"], - course_number=course["kch"], - course_index=int( - course["kxh"] - ), # course["kxh"] could be string (teacher mode) or number (student mode) - course_type=course_type, - ) - - -def parse_file( - file: dict, - course_id: str, - clazz: dict[str, str], - course_type: ty.CourseType = ty.CourseType.STUDENT, -) -> ty.File: - title: str = file["bt"] - download_url: str = urls.learn_file_download( - file_id=file["wjid"] if course_type == ty.CourseType.STUDENT else file["id"], - course_type=course_type, - course_id=course_id, - ) - preview_url = None - return ty.File( - id=file["wjid"], # 文件 ID - title=file["bt"], # 标题 - description=file["ms"], # 描述 - raw_size=file["wjdx"], # 文件大小 - size=file["fileSize"], - upload_time=datetime.datetime.strptime(file["scsj"], r"%Y-%m-%d %H:%M"), # 上传时间 - download_url=str(download_url), - preview_url=str(preview_url), - is_new=file["isNew"], - marked_important=file["sfqd"] == 1, # 是否强调 - visit_count=file["llcs"] or 0, # 浏览次数 - download_count=file["xzcs"] or 0, # 下载次数 - file_type=file["wjlx"], # 文件类型 - remote_file=ty.RemoteFile( - id=file["wjid"], # 文件 ID - name=title, - download_url=str(download_url), - preview_url=str(preview_url), - size=file["fileSize"], - ), - clazz=clazz[file["kjflid"]], # 课件分类 ID - ) - - -def parse_homework_file(div: bs4.element.Tag) -> ty.RemoteFile: - ftitle: bs4.element.Tag = div.find( - name="span", - attrs={ - "class": "ftitle", - }, - ) or div.find( - name="span", - attrs={ - "class", - "ft", - }, - ) - if not ftitle: - return None - a: bs4.element.Tag = ftitle.find(name="a") - size: bs4.element.Tag = div.find( - name="span", - attrs={ - "class": "color_999", - }, - ) - size: str = size.getText() - params = dict(urllib.parse.parse_qsl(urllib.parse.urlparse(a["href"]).query)) - attachment_id = params["fileId"] - download_url = ty.URL( - netloc=urls.LEARN_PREFIX.netloc, - path=params["downloadUrl"] if "downloadUrl" in params else a["href"], - ) - return ty.RemoteFile( - id=attachment_id, - name=a.getText(), - download_url=download_url, - preview_url=None, - size=size.strip(), - ) diff --git a/thu_learn_lib/ty.py b/thu_learn_lib/ty.py deleted file mode 100644 index 60e713c..0000000 --- a/thu_learn_lib/ty.py +++ /dev/null @@ -1,226 +0,0 @@ -import dataclasses -import datetime -import enum -import os -import urllib.parse - - -@dataclasses.dataclass -class URL: - scheme: str = "https" - netloc: str = "" - path: str = "" - params: str = "" - query: str | dict[str, str | list[str]] | list[tuple[str, str | list[str]]] = "" - fragment: str = "" - - def __str__(self) -> str: - return urllib.parse.urlunparse(self.astuple()) - - def astuple(self) -> urllib.parse.ParseResult: - return urllib.parse.ParseResult( - scheme=self.scheme, - netloc=self.netloc, - path=os.path.join(self.path) if isinstance(self.path, list) else self.path, - params=self.params, - query=self.query - if isinstance(self.query, str) - else urllib.parse.urlencode(self.query, doseq=True), - fragment=self.fragment, - ) - - -@dataclasses.dataclass -class Credential: - username: str - password: str - - -class FailReason(enum.Enum): - NO_CREDENTIAL = "no credential provided" - ERROR_FETCH_FROM_ID = "could not fetch ticket from id.tsinghua.edu.cn" - BAD_CREDENTIAL = "bad credential" - ERROR_ROAMING = "could not roam to learn.tsinghua.edu.cn" - NOT_LOGGED_IN = "not logged in or login timeout" - NOT_IMPLEMENTED = "not implemented" - INVALID_RESPONSE = "invalid response" - UNEXPECTED_STATUS = "unexpected status" - - -class SemesterType(enum.Enum): - FALL = "Autumn Term" - SPRING = "Spring Term" - SUMMER = "Summer Term" - UNKNOWN = "" - - -class ContentType(enum.Enum): - NOTIFICATION = "notification" - FILE = "file" - HOMEWORK = "homework" - DISCUSSION = "discussion" - QUESTION = "question" - - -@dataclasses.dataclass -class SemesterInfo: - id: str - start_date: datetime.datetime - end_date: datetime.datetime - start_year: int - end_year: int - type: SemesterType - - -class CourseType(enum.Enum): - STUDENT = "student" - TEACHER = "teacher" - - -@dataclasses.dataclass -class CourseInfo: - id: str = None - name: str = None - english_name: str = None - time_and_location: list[str] = None - url: str = None - teacher_name: str = None - teacher_number: str = None - course_number: str = None - course_index: int = None - course_type: CourseType = None - - -@dataclasses.dataclass -class RemoteFile: - id: str - name: str - download_url: str - preview_url: str - size: str - - -@dataclasses.dataclass -class Notification: - id: str - title: str - content: str - has_read: bool - url: str - marked_important: bool - publish_time: datetime.datetime - publisher: str - - # notification detail - attachment: RemoteFile = None - - -@dataclasses.dataclass -class File: - id: str - raw_size: int # size in byte - size: str # inaccurate size description (like '1M') - title: str - description: str - upload_time: datetime.datetime - download_url: str # for teachers, this url will not initiate download directly - preview_url: str # preview is not supported on all types of files, check before use - is_new: bool - marked_important: bool - visit_count: int - download_count: int - file_type: str - remote_file: RemoteFile # for compatibility - - clazz: str - - -@dataclasses.dataclass -class HomeworkStatus: - submitted: bool = None - graded: bool = None - - -@dataclasses.dataclass -class HomeworkDetail: - description: str = None - attachment: RemoteFile = None # attachment from teacher - answer_content: str = None # answer from teacher - answer_attachment: RemoteFile = None - submitted_content: str = None # submitted content from student - submitted_attachment: RemoteFile = None - grade_attachment: RemoteFile = None # grade from teacher - - -@dataclasses.dataclass -class Homework(HomeworkStatus, HomeworkDetail): - # status - # submitted: bool - # graded: bool - - # homework - id: str = None - student_homework_id: str = None - title: str = None - deadline: datetime.datetime = None - url: str = None - submit_url: str = None - submit_time: datetime.datetime = None - grade: int = None - grade_level: str = None # some homework has levels but not grades, like A/B/.../F - grade_time: datetime.datetime = None - grader_name: str = None - grade_content: str = None - - # detail - # description: str = None - # attachment: RemoteFile = None # attachment from teacher - # answer_content: str = None # answer from teacher - # answer_attachment: RemoteFile = None - # submitted_content: str = None # submitted content from student - # submitted_attachment: RemoteFile = None - # grade_attachment: RemoteFile = None # grade from teacher - - -@dataclasses.dataclass -class Discussion: - # base - id: str - title: str - publisher_name: str - publish_date: datetime.datetime - last_replier_name: str - last_reply_time: datetime.datetime - visit_count: int - reply_count: int - - # discussion - url: str - board_id: str - - -@dataclasses.dataclass -class Question: - # discussion base - id: str - title: str - publisher_name: str - publish_date: datetime.datetime - last_replier_name: str - last_reply_time: datetime.datetime - visit_count: int - reply_count: int - - # question - url: str - question: str - - -@dataclasses.dataclass -class CalendarEvent: - location: str - status: str - start_time: str - end_time: str - date: str - course_name: str