diff --git a/_dot_vercel_copy/output/functions/api/authenticate.func b/_dot_vercel_copy/output/functions/api/authenticate.func deleted file mode 120000 index 2e79e533..00000000 --- a/_dot_vercel_copy/output/functions/api/authenticate.func +++ /dev/null @@ -1 +0,0 @@ -../api.func \ No newline at end of file diff --git a/_dot_vercel_copy/output/functions/api/delete-user.func b/_dot_vercel_copy/output/functions/api/delete-user.func deleted file mode 120000 index 2e79e533..00000000 --- a/_dot_vercel_copy/output/functions/api/delete-user.func +++ /dev/null @@ -1 +0,0 @@ -../api.func \ No newline at end of file diff --git a/_dot_vercel_copy/output/functions/api/downgrade.func b/_dot_vercel_copy/output/functions/api/downgrade.func deleted file mode 120000 index 2e79e533..00000000 --- a/_dot_vercel_copy/output/functions/api/downgrade.func +++ /dev/null @@ -1 +0,0 @@ -../api.func \ No newline at end of file diff --git a/_dot_vercel_copy/output/functions/api/gist.func b/_dot_vercel_copy/output/functions/api/gist.func deleted file mode 120000 index 2e79e533..00000000 --- a/_dot_vercel_copy/output/functions/api/gist.func +++ /dev/null @@ -1 +0,0 @@ -../api.func \ No newline at end of file diff --git a/_dot_vercel_copy/output/functions/api/pin.func b/_dot_vercel_copy/output/functions/api/pin.func deleted file mode 120000 index 2e79e533..00000000 --- a/_dot_vercel_copy/output/functions/api/pin.func +++ /dev/null @@ -1 +0,0 @@ -../api.func \ No newline at end of file diff --git a/_dot_vercel_copy/output/functions/api/private-access.func b/_dot_vercel_copy/output/functions/api/private-access.func deleted file mode 120000 index 2e79e533..00000000 --- a/_dot_vercel_copy/output/functions/api/private-access.func +++ /dev/null @@ -1 +0,0 @@ -../api.func \ No newline at end of file diff --git a/_dot_vercel_copy/output/functions/api/repeat-recent.func b/_dot_vercel_copy/output/functions/api/repeat-recent.func deleted file mode 120000 index 2e79e533..00000000 --- a/_dot_vercel_copy/output/functions/api/repeat-recent.func +++ /dev/null @@ -1 +0,0 @@ -../api.func \ No newline at end of file diff --git a/_dot_vercel_copy/output/functions/api/status/pat-info.func b/_dot_vercel_copy/output/functions/api/status/pat-info.func deleted file mode 120000 index 0f4906ce..00000000 --- a/_dot_vercel_copy/output/functions/api/status/pat-info.func +++ /dev/null @@ -1 +0,0 @@ -../../api.func \ No newline at end of file diff --git a/_dot_vercel_copy/output/functions/api/status/up.func b/_dot_vercel_copy/output/functions/api/status/up.func deleted file mode 120000 index 0f4906ce..00000000 --- a/_dot_vercel_copy/output/functions/api/status/up.func +++ /dev/null @@ -1 +0,0 @@ -../../api.func \ No newline at end of file diff --git a/_dot_vercel_copy/output/functions/api/top-langs.func b/_dot_vercel_copy/output/functions/api/top-langs.func deleted file mode 120000 index 2e79e533..00000000 --- a/_dot_vercel_copy/output/functions/api/top-langs.func +++ /dev/null @@ -1 +0,0 @@ -../api.func \ No newline at end of file diff --git a/_dot_vercel_copy/output/functions/api/wakatime.func b/_dot_vercel_copy/output/functions/api/wakatime.func deleted file mode 120000 index 2e79e533..00000000 --- a/_dot_vercel_copy/output/functions/api/wakatime.func +++ /dev/null @@ -1 +0,0 @@ -../api.func \ No newline at end of file diff --git a/.devcontainer/devcontainer.json b/backend/.devcontainer/devcontainer.json similarity index 100% rename from .devcontainer/devcontainer.json rename to backend/.devcontainer/devcontainer.json diff --git a/.eslintrc.json b/backend/.eslintrc.json similarity index 100% rename from .eslintrc.json rename to backend/.eslintrc.json diff --git a/.gitattributes b/backend/.gitattributes similarity index 100% rename from .gitattributes rename to backend/.gitattributes diff --git a/.github/CODEOWNERS b/backend/.github/CODEOWNERS similarity index 100% rename from .github/CODEOWNERS rename to backend/.github/CODEOWNERS diff --git a/.github/FUNDING.yml b/backend/.github/FUNDING.yml similarity index 100% rename from .github/FUNDING.yml rename to backend/.github/FUNDING.yml diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/backend/.github/ISSUE_TEMPLATE/bug_report.yml similarity index 100% rename from .github/ISSUE_TEMPLATE/bug_report.yml rename to backend/.github/ISSUE_TEMPLATE/bug_report.yml diff --git a/.github/ISSUE_TEMPLATE/config.yml b/backend/.github/ISSUE_TEMPLATE/config.yml similarity index 100% rename from .github/ISSUE_TEMPLATE/config.yml rename to backend/.github/ISSUE_TEMPLATE/config.yml diff --git a/.github/ISSUE_TEMPLATE/feature_request.yml b/backend/.github/ISSUE_TEMPLATE/feature_request.yml similarity index 100% rename from .github/ISSUE_TEMPLATE/feature_request.yml rename to backend/.github/ISSUE_TEMPLATE/feature_request.yml diff --git a/.github/dependabot.yml b/backend/.github/dependabot.yml similarity index 100% rename from .github/dependabot.yml rename to backend/.github/dependabot.yml diff --git a/.github/labeler.yml b/backend/.github/labeler.yml similarity index 100% rename from .github/labeler.yml rename to backend/.github/labeler.yml diff --git a/.github/stale.yml b/backend/.github/stale.yml similarity index 100% rename from .github/stale.yml rename to backend/.github/stale.yml diff --git a/.github/workflows/codeql-analysis.yml b/backend/.github/workflows/codeql-analysis.yml similarity index 100% rename from .github/workflows/codeql-analysis.yml rename to backend/.github/workflows/codeql-analysis.yml diff --git a/.github/workflows/deploy-prep.py b/backend/.github/workflows/deploy-prep.py similarity index 100% rename from .github/workflows/deploy-prep.py rename to backend/.github/workflows/deploy-prep.py diff --git a/.github/workflows/deploy-prep.yml b/backend/.github/workflows/deploy-prep.yml similarity index 100% rename from .github/workflows/deploy-prep.yml rename to backend/.github/workflows/deploy-prep.yml diff --git a/.github/workflows/e2e-test.yml b/backend/.github/workflows/e2e-test.yml similarity index 100% rename from .github/workflows/e2e-test.yml rename to backend/.github/workflows/e2e-test.yml diff --git a/.github/workflows/empty-issues-closer.yml b/backend/.github/workflows/empty-issues-closer.yml similarity index 100% rename from .github/workflows/empty-issues-closer.yml rename to backend/.github/workflows/empty-issues-closer.yml diff --git a/.github/workflows/generate-theme-doc.yml b/backend/.github/workflows/generate-theme-doc.yml similarity index 100% rename from .github/workflows/generate-theme-doc.yml rename to backend/.github/workflows/generate-theme-doc.yml diff --git a/.github/workflows/label-pr.yml b/backend/.github/workflows/label-pr.yml similarity index 100% rename from .github/workflows/label-pr.yml rename to backend/.github/workflows/label-pr.yml diff --git a/.github/workflows/ossf-analysis.yml b/backend/.github/workflows/ossf-analysis.yml similarity index 100% rename from .github/workflows/ossf-analysis.yml rename to backend/.github/workflows/ossf-analysis.yml diff --git a/.github/workflows/preview-theme.yml b/backend/.github/workflows/preview-theme.yml similarity index 100% rename from .github/workflows/preview-theme.yml rename to backend/.github/workflows/preview-theme.yml diff --git a/.github/workflows/prs-cache-clean.yml b/backend/.github/workflows/prs-cache-clean.yml similarity index 100% rename from .github/workflows/prs-cache-clean.yml rename to backend/.github/workflows/prs-cache-clean.yml diff --git a/.github/workflows/repeat-recent-requests.yml b/backend/.github/workflows/repeat-recent-requests.yml similarity index 100% rename from .github/workflows/repeat-recent-requests.yml rename to backend/.github/workflows/repeat-recent-requests.yml diff --git a/.github/workflows/stale-theme-pr-closer.yml b/backend/.github/workflows/stale-theme-pr-closer.yml similarity index 100% rename from .github/workflows/stale-theme-pr-closer.yml rename to backend/.github/workflows/stale-theme-pr-closer.yml diff --git a/.github/workflows/test.yml b/backend/.github/workflows/test.yml similarity index 100% rename from .github/workflows/test.yml rename to backend/.github/workflows/test.yml diff --git a/.github/workflows/theme-prs-closer.yml b/backend/.github/workflows/theme-prs-closer.yml similarity index 100% rename from .github/workflows/theme-prs-closer.yml rename to backend/.github/workflows/theme-prs-closer.yml diff --git a/.github/workflows/top-issues-dashboard.yml b/backend/.github/workflows/top-issues-dashboard.yml similarity index 100% rename from .github/workflows/top-issues-dashboard.yml rename to backend/.github/workflows/top-issues-dashboard.yml diff --git a/.github/workflows/update-langs.yml b/backend/.github/workflows/update-langs.yml similarity index 100% rename from .github/workflows/update-langs.yml rename to backend/.github/workflows/update-langs.yml diff --git a/.gitignore b/backend/.gitignore similarity index 100% rename from .gitignore rename to backend/.gitignore diff --git a/.husky/.gitignore b/backend/.husky/.gitignore similarity index 100% rename from .husky/.gitignore rename to backend/.husky/.gitignore diff --git a/.husky/pre-commit b/backend/.husky/pre-commit old mode 100755 new mode 100644 similarity index 100% rename from .husky/pre-commit rename to backend/.husky/pre-commit diff --git a/.nvmrc b/backend/.nvmrc similarity index 100% rename from .nvmrc rename to backend/.nvmrc diff --git a/.prettierignore b/backend/.prettierignore similarity index 100% rename from .prettierignore rename to backend/.prettierignore diff --git a/.prettierrc.json b/backend/.prettierrc.json similarity index 100% rename from .prettierrc.json rename to backend/.prettierrc.json diff --git a/.vercelignore b/backend/.vercelignore similarity index 100% rename from .vercelignore rename to backend/.vercelignore diff --git a/.vscode/extensions.json b/backend/.vscode/extensions.json similarity index 100% rename from .vscode/extensions.json rename to backend/.vscode/extensions.json diff --git a/.vscode/settings.json b/backend/.vscode/settings.json similarity index 100% rename from .vscode/settings.json rename to backend/.vscode/settings.json diff --git a/CODE_OF_CONDUCT.md b/backend/CODE_OF_CONDUCT.md similarity index 100% rename from CODE_OF_CONDUCT.md rename to backend/CODE_OF_CONDUCT.md diff --git a/CONTRIBUTING.md b/backend/CONTRIBUTING.md similarity index 100% rename from CONTRIBUTING.md rename to backend/CONTRIBUTING.md diff --git a/LICENSE b/backend/LICENSE similarity index 100% rename from LICENSE rename to backend/LICENSE diff --git a/SECURITY.md b/backend/SECURITY.md similarity index 100% rename from SECURITY.md rename to backend/SECURITY.md diff --git a/_dot_vercel_copy/output/config.json b/backend/_dot_vercel_copy/output/config.json similarity index 100% rename from _dot_vercel_copy/output/config.json rename to backend/_dot_vercel_copy/output/config.json diff --git a/_dot_vercel_copy/output/functions/api.func/.vc-config.json b/backend/_dot_vercel_copy/output/functions/api.func/.vc-config.json similarity index 100% rename from _dot_vercel_copy/output/functions/api.func/.vc-config.json rename to backend/_dot_vercel_copy/output/functions/api.func/.vc-config.json diff --git a/_dot_vercel_copy/output/functions/api.func/router.js b/backend/_dot_vercel_copy/output/functions/api.func/router.js similarity index 100% rename from _dot_vercel_copy/output/functions/api.func/router.js rename to backend/_dot_vercel_copy/output/functions/api.func/router.js diff --git a/_dot_vercel_copy/output/functions/api.prerender-config.json b/backend/_dot_vercel_copy/output/functions/api.prerender-config.json similarity index 100% rename from _dot_vercel_copy/output/functions/api.prerender-config.json rename to backend/_dot_vercel_copy/output/functions/api.prerender-config.json diff --git a/backend/_dot_vercel_copy/output/functions/api/authenticate.func b/backend/_dot_vercel_copy/output/functions/api/authenticate.func new file mode 100644 index 00000000..2e79e533 --- /dev/null +++ b/backend/_dot_vercel_copy/output/functions/api/authenticate.func @@ -0,0 +1 @@ +../api.func \ No newline at end of file diff --git a/backend/_dot_vercel_copy/output/functions/api/delete-user.func b/backend/_dot_vercel_copy/output/functions/api/delete-user.func new file mode 100644 index 00000000..2e79e533 --- /dev/null +++ b/backend/_dot_vercel_copy/output/functions/api/delete-user.func @@ -0,0 +1 @@ +../api.func \ No newline at end of file diff --git a/backend/_dot_vercel_copy/output/functions/api/downgrade.func b/backend/_dot_vercel_copy/output/functions/api/downgrade.func new file mode 100644 index 00000000..2e79e533 --- /dev/null +++ b/backend/_dot_vercel_copy/output/functions/api/downgrade.func @@ -0,0 +1 @@ +../api.func \ No newline at end of file diff --git a/backend/_dot_vercel_copy/output/functions/api/gist.func b/backend/_dot_vercel_copy/output/functions/api/gist.func new file mode 100644 index 00000000..2e79e533 --- /dev/null +++ b/backend/_dot_vercel_copy/output/functions/api/gist.func @@ -0,0 +1 @@ +../api.func \ No newline at end of file diff --git a/_dot_vercel_copy/output/functions/api/gist.prerender-config.json b/backend/_dot_vercel_copy/output/functions/api/gist.prerender-config.json similarity index 100% rename from _dot_vercel_copy/output/functions/api/gist.prerender-config.json rename to backend/_dot_vercel_copy/output/functions/api/gist.prerender-config.json diff --git a/backend/_dot_vercel_copy/output/functions/api/pin.func b/backend/_dot_vercel_copy/output/functions/api/pin.func new file mode 100644 index 00000000..2e79e533 --- /dev/null +++ b/backend/_dot_vercel_copy/output/functions/api/pin.func @@ -0,0 +1 @@ +../api.func \ No newline at end of file diff --git a/_dot_vercel_copy/output/functions/api/pin.prerender-config.json b/backend/_dot_vercel_copy/output/functions/api/pin.prerender-config.json similarity index 100% rename from _dot_vercel_copy/output/functions/api/pin.prerender-config.json rename to backend/_dot_vercel_copy/output/functions/api/pin.prerender-config.json diff --git a/backend/_dot_vercel_copy/output/functions/api/private-access.func b/backend/_dot_vercel_copy/output/functions/api/private-access.func new file mode 100644 index 00000000..2e79e533 --- /dev/null +++ b/backend/_dot_vercel_copy/output/functions/api/private-access.func @@ -0,0 +1 @@ +../api.func \ No newline at end of file diff --git a/backend/_dot_vercel_copy/output/functions/api/repeat-recent.func b/backend/_dot_vercel_copy/output/functions/api/repeat-recent.func new file mode 100644 index 00000000..2e79e533 --- /dev/null +++ b/backend/_dot_vercel_copy/output/functions/api/repeat-recent.func @@ -0,0 +1 @@ +../api.func \ No newline at end of file diff --git a/backend/_dot_vercel_copy/output/functions/api/status/pat-info.func b/backend/_dot_vercel_copy/output/functions/api/status/pat-info.func new file mode 100644 index 00000000..0f4906ce --- /dev/null +++ b/backend/_dot_vercel_copy/output/functions/api/status/pat-info.func @@ -0,0 +1 @@ +../../api.func \ No newline at end of file diff --git a/backend/_dot_vercel_copy/output/functions/api/status/up.func b/backend/_dot_vercel_copy/output/functions/api/status/up.func new file mode 100644 index 00000000..0f4906ce --- /dev/null +++ b/backend/_dot_vercel_copy/output/functions/api/status/up.func @@ -0,0 +1 @@ +../../api.func \ No newline at end of file diff --git a/backend/_dot_vercel_copy/output/functions/api/top-langs.func b/backend/_dot_vercel_copy/output/functions/api/top-langs.func new file mode 100644 index 00000000..2e79e533 --- /dev/null +++ b/backend/_dot_vercel_copy/output/functions/api/top-langs.func @@ -0,0 +1 @@ +../api.func \ No newline at end of file diff --git a/_dot_vercel_copy/output/functions/api/top-langs.prerender-config.json b/backend/_dot_vercel_copy/output/functions/api/top-langs.prerender-config.json similarity index 100% rename from _dot_vercel_copy/output/functions/api/top-langs.prerender-config.json rename to backend/_dot_vercel_copy/output/functions/api/top-langs.prerender-config.json diff --git a/backend/_dot_vercel_copy/output/functions/api/wakatime.func b/backend/_dot_vercel_copy/output/functions/api/wakatime.func new file mode 100644 index 00000000..2e79e533 --- /dev/null +++ b/backend/_dot_vercel_copy/output/functions/api/wakatime.func @@ -0,0 +1 @@ +../api.func \ No newline at end of file diff --git a/_dot_vercel_copy/output/functions/api/wakatime.prerender-config.json b/backend/_dot_vercel_copy/output/functions/api/wakatime.prerender-config.json similarity index 100% rename from _dot_vercel_copy/output/functions/api/wakatime.prerender-config.json rename to backend/_dot_vercel_copy/output/functions/api/wakatime.prerender-config.json diff --git a/api-renamed/authenticate.js b/backend/api-renamed/authenticate.js similarity index 100% rename from api-renamed/authenticate.js rename to backend/api-renamed/authenticate.js diff --git a/api-renamed/delete-user.js b/backend/api-renamed/delete-user.js similarity index 100% rename from api-renamed/delete-user.js rename to backend/api-renamed/delete-user.js diff --git a/api-renamed/downgrade.js b/backend/api-renamed/downgrade.js similarity index 100% rename from api-renamed/downgrade.js rename to backend/api-renamed/downgrade.js diff --git a/api-renamed/gist.js b/backend/api-renamed/gist.js similarity index 100% rename from api-renamed/gist.js rename to backend/api-renamed/gist.js diff --git a/api-renamed/index.js b/backend/api-renamed/index.js similarity index 100% rename from api-renamed/index.js rename to backend/api-renamed/index.js diff --git a/api-renamed/pin.js b/backend/api-renamed/pin.js similarity index 100% rename from api-renamed/pin.js rename to backend/api-renamed/pin.js diff --git a/api-renamed/private-access.js b/backend/api-renamed/private-access.js similarity index 100% rename from api-renamed/private-access.js rename to backend/api-renamed/private-access.js diff --git a/api-renamed/repeat-recent.js b/backend/api-renamed/repeat-recent.js similarity index 100% rename from api-renamed/repeat-recent.js rename to backend/api-renamed/repeat-recent.js diff --git a/api-renamed/status/pat-info.js b/backend/api-renamed/status/pat-info.js similarity index 100% rename from api-renamed/status/pat-info.js rename to backend/api-renamed/status/pat-info.js diff --git a/api-renamed/status/up.js b/backend/api-renamed/status/up.js similarity index 100% rename from api-renamed/status/up.js rename to backend/api-renamed/status/up.js diff --git a/api-renamed/top-langs.js b/backend/api-renamed/top-langs.js similarity index 100% rename from api-renamed/top-langs.js rename to backend/api-renamed/top-langs.js diff --git a/api-renamed/wakatime.js b/backend/api-renamed/wakatime.js similarity index 100% rename from api-renamed/wakatime.js rename to backend/api-renamed/wakatime.js diff --git a/codecov.yml b/backend/codecov.yml similarity index 100% rename from codecov.yml rename to backend/codecov.yml diff --git a/eslint.config.mjs b/backend/eslint.config.mjs similarity index 100% rename from eslint.config.mjs rename to backend/eslint.config.mjs diff --git a/express.js b/backend/express.js similarity index 100% rename from express.js rename to backend/express.js diff --git a/jest.bench.config.js b/backend/jest.bench.config.js similarity index 100% rename from jest.bench.config.js rename to backend/jest.bench.config.js diff --git a/jest.config.js b/backend/jest.config.js similarity index 100% rename from jest.config.js rename to backend/jest.config.js diff --git a/jest.e2e.config.js b/backend/jest.e2e.config.js similarity index 100% rename from jest.e2e.config.js rename to backend/jest.e2e.config.js diff --git a/package-lock.json b/backend/package-lock.json similarity index 100% rename from package-lock.json rename to backend/package-lock.json diff --git a/package.json b/backend/package.json similarity index 100% rename from package.json rename to backend/package.json diff --git a/powered-by-vercel.svg b/backend/powered-by-vercel.svg similarity index 100% rename from powered-by-vercel.svg rename to backend/powered-by-vercel.svg diff --git a/readme.md b/backend/readme.md similarity index 100% rename from readme.md rename to backend/readme.md diff --git a/scripts/close-stale-theme-prs.js b/backend/scripts/close-stale-theme-prs.js similarity index 100% rename from scripts/close-stale-theme-prs.js rename to backend/scripts/close-stale-theme-prs.js diff --git a/scripts/generate-langs-json.js b/backend/scripts/generate-langs-json.js similarity index 100% rename from scripts/generate-langs-json.js rename to backend/scripts/generate-langs-json.js diff --git a/scripts/generate-theme-doc.js b/backend/scripts/generate-theme-doc.js similarity index 100% rename from scripts/generate-theme-doc.js rename to backend/scripts/generate-theme-doc.js diff --git a/scripts/helpers.js b/backend/scripts/helpers.js similarity index 100% rename from scripts/helpers.js rename to backend/scripts/helpers.js diff --git a/scripts/preview-theme.js b/backend/scripts/preview-theme.js similarity index 100% rename from scripts/preview-theme.js rename to backend/scripts/preview-theme.js diff --git a/scripts/push-theme-readme.sh b/backend/scripts/push-theme-readme.sh old mode 100755 new mode 100644 similarity index 100% rename from scripts/push-theme-readme.sh rename to backend/scripts/push-theme-readme.sh diff --git a/src/calculateRank.js b/backend/src/calculateRank.js similarity index 100% rename from src/calculateRank.js rename to backend/src/calculateRank.js diff --git a/src/cards/gist.js b/backend/src/cards/gist.js similarity index 100% rename from src/cards/gist.js rename to backend/src/cards/gist.js diff --git a/src/cards/index.js b/backend/src/cards/index.js similarity index 100% rename from src/cards/index.js rename to backend/src/cards/index.js diff --git a/src/cards/repo.js b/backend/src/cards/repo.js similarity index 100% rename from src/cards/repo.js rename to backend/src/cards/repo.js diff --git a/src/cards/stats.js b/backend/src/cards/stats.js similarity index 100% rename from src/cards/stats.js rename to backend/src/cards/stats.js diff --git a/src/cards/top-languages.js b/backend/src/cards/top-languages.js similarity index 100% rename from src/cards/top-languages.js rename to backend/src/cards/top-languages.js diff --git a/src/cards/types.d.ts b/backend/src/cards/types.d.ts similarity index 100% rename from src/cards/types.d.ts rename to backend/src/cards/types.d.ts diff --git a/src/cards/wakatime.js b/backend/src/cards/wakatime.js similarity index 100% rename from src/cards/wakatime.js rename to backend/src/cards/wakatime.js diff --git a/src/common/Card.js b/backend/src/common/Card.js similarity index 100% rename from src/common/Card.js rename to backend/src/common/Card.js diff --git a/src/common/I18n.js b/backend/src/common/I18n.js similarity index 100% rename from src/common/I18n.js rename to backend/src/common/I18n.js diff --git a/src/common/blacklist.js b/backend/src/common/blacklist.js similarity index 100% rename from src/common/blacklist.js rename to backend/src/common/blacklist.js diff --git a/src/common/createProgressNode.js b/backend/src/common/createProgressNode.js similarity index 100% rename from src/common/createProgressNode.js rename to backend/src/common/createProgressNode.js diff --git a/src/common/database.js b/backend/src/common/database.js similarity index 100% rename from src/common/database.js rename to backend/src/common/database.js diff --git a/src/common/icons.js b/backend/src/common/icons.js similarity index 100% rename from src/common/icons.js rename to backend/src/common/icons.js diff --git a/src/common/index.js b/backend/src/common/index.js similarity index 100% rename from src/common/index.js rename to backend/src/common/index.js diff --git a/src/common/languageColors.json b/backend/src/common/languageColors.json similarity index 100% rename from src/common/languageColors.json rename to backend/src/common/languageColors.json diff --git a/src/common/retryer.js b/backend/src/common/retryer.js similarity index 100% rename from src/common/retryer.js rename to backend/src/common/retryer.js diff --git a/src/common/utils.js b/backend/src/common/utils.js similarity index 100% rename from src/common/utils.js rename to backend/src/common/utils.js diff --git a/src/common/whitelist.js b/backend/src/common/whitelist.js similarity index 100% rename from src/common/whitelist.js rename to backend/src/common/whitelist.js diff --git a/src/fetchers/gist.js b/backend/src/fetchers/gist.js similarity index 100% rename from src/fetchers/gist.js rename to backend/src/fetchers/gist.js diff --git a/src/fetchers/repo.js b/backend/src/fetchers/repo.js similarity index 100% rename from src/fetchers/repo.js rename to backend/src/fetchers/repo.js diff --git a/src/fetchers/stats.js b/backend/src/fetchers/stats.js similarity index 100% rename from src/fetchers/stats.js rename to backend/src/fetchers/stats.js diff --git a/src/fetchers/top-languages.js b/backend/src/fetchers/top-languages.js similarity index 100% rename from src/fetchers/top-languages.js rename to backend/src/fetchers/top-languages.js diff --git a/src/fetchers/types.d.ts b/backend/src/fetchers/types.d.ts similarity index 100% rename from src/fetchers/types.d.ts rename to backend/src/fetchers/types.d.ts diff --git a/src/fetchers/wakatime.js b/backend/src/fetchers/wakatime.js similarity index 100% rename from src/fetchers/wakatime.js rename to backend/src/fetchers/wakatime.js diff --git a/src/index.js b/backend/src/index.js similarity index 100% rename from src/index.js rename to backend/src/index.js diff --git a/src/repeatRequests.js b/backend/src/repeatRequests.js similarity index 100% rename from src/repeatRequests.js rename to backend/src/repeatRequests.js diff --git a/src/translations.js b/backend/src/translations.js similarity index 100% rename from src/translations.js rename to backend/src/translations.js diff --git a/src/users.js b/backend/src/users.js similarity index 100% rename from src/users.js rename to backend/src/users.js diff --git a/tests/__snapshots__/renderWakatimeCard.test.js.snap b/backend/tests/__snapshots__/renderWakatimeCard.test.js.snap similarity index 100% rename from tests/__snapshots__/renderWakatimeCard.test.js.snap rename to backend/tests/__snapshots__/renderWakatimeCard.test.js.snap diff --git a/tests/api.test.js b/backend/tests/api.test.js similarity index 100% rename from tests/api.test.js rename to backend/tests/api.test.js diff --git a/tests/bench/api.bench.js b/backend/tests/bench/api.bench.js similarity index 100% rename from tests/bench/api.bench.js rename to backend/tests/bench/api.bench.js diff --git a/tests/bench/calculateRank.bench.js b/backend/tests/bench/calculateRank.bench.js similarity index 100% rename from tests/bench/calculateRank.bench.js rename to backend/tests/bench/calculateRank.bench.js diff --git a/tests/bench/gist.bench.js b/backend/tests/bench/gist.bench.js similarity index 100% rename from tests/bench/gist.bench.js rename to backend/tests/bench/gist.bench.js diff --git a/tests/bench/pin.bench.js b/backend/tests/bench/pin.bench.js similarity index 100% rename from tests/bench/pin.bench.js rename to backend/tests/bench/pin.bench.js diff --git a/tests/calculateRank.test.js b/backend/tests/calculateRank.test.js similarity index 100% rename from tests/calculateRank.test.js rename to backend/tests/calculateRank.test.js diff --git a/tests/card.test.js b/backend/tests/card.test.js similarity index 100% rename from tests/card.test.js rename to backend/tests/card.test.js diff --git a/tests/e2e/e2e.test.js b/backend/tests/e2e/e2e.test.js similarity index 100% rename from tests/e2e/e2e.test.js rename to backend/tests/e2e/e2e.test.js diff --git a/tests/fetchGist.test.js b/backend/tests/fetchGist.test.js similarity index 100% rename from tests/fetchGist.test.js rename to backend/tests/fetchGist.test.js diff --git a/tests/fetchRepo.test.js b/backend/tests/fetchRepo.test.js similarity index 100% rename from tests/fetchRepo.test.js rename to backend/tests/fetchRepo.test.js diff --git a/tests/fetchStats.test.js b/backend/tests/fetchStats.test.js similarity index 100% rename from tests/fetchStats.test.js rename to backend/tests/fetchStats.test.js diff --git a/tests/fetchTopLanguages.test.js b/backend/tests/fetchTopLanguages.test.js similarity index 100% rename from tests/fetchTopLanguages.test.js rename to backend/tests/fetchTopLanguages.test.js diff --git a/tests/fetchWakatime.test.js b/backend/tests/fetchWakatime.test.js similarity index 100% rename from tests/fetchWakatime.test.js rename to backend/tests/fetchWakatime.test.js diff --git a/tests/flexLayout.test.js b/backend/tests/flexLayout.test.js similarity index 100% rename from tests/flexLayout.test.js rename to backend/tests/flexLayout.test.js diff --git a/tests/gist.test.js b/backend/tests/gist.test.js similarity index 100% rename from tests/gist.test.js rename to backend/tests/gist.test.js diff --git a/tests/i18n.test.js b/backend/tests/i18n.test.js similarity index 100% rename from tests/i18n.test.js rename to backend/tests/i18n.test.js diff --git a/tests/pat-info.test.js b/backend/tests/pat-info.test.js similarity index 100% rename from tests/pat-info.test.js rename to backend/tests/pat-info.test.js diff --git a/tests/pin.test.js b/backend/tests/pin.test.js similarity index 100% rename from tests/pin.test.js rename to backend/tests/pin.test.js diff --git a/tests/renderGistCard.test.js b/backend/tests/renderGistCard.test.js similarity index 100% rename from tests/renderGistCard.test.js rename to backend/tests/renderGistCard.test.js diff --git a/tests/renderRepoCard.test.js b/backend/tests/renderRepoCard.test.js similarity index 100% rename from tests/renderRepoCard.test.js rename to backend/tests/renderRepoCard.test.js diff --git a/tests/renderStatsCard.test.js b/backend/tests/renderStatsCard.test.js similarity index 100% rename from tests/renderStatsCard.test.js rename to backend/tests/renderStatsCard.test.js diff --git a/tests/renderTopLanguagesCard.test.js b/backend/tests/renderTopLanguagesCard.test.js similarity index 100% rename from tests/renderTopLanguagesCard.test.js rename to backend/tests/renderTopLanguagesCard.test.js diff --git a/tests/renderWakatimeCard.test.js b/backend/tests/renderWakatimeCard.test.js similarity index 100% rename from tests/renderWakatimeCard.test.js rename to backend/tests/renderWakatimeCard.test.js diff --git a/tests/retryer.test.js b/backend/tests/retryer.test.js similarity index 100% rename from tests/retryer.test.js rename to backend/tests/retryer.test.js diff --git a/tests/setup.jest.js b/backend/tests/setup.jest.js similarity index 100% rename from tests/setup.jest.js rename to backend/tests/setup.jest.js diff --git a/tests/status.up.test.js b/backend/tests/status.up.test.js similarity index 100% rename from tests/status.up.test.js rename to backend/tests/status.up.test.js diff --git a/tests/top-langs.test.js b/backend/tests/top-langs.test.js similarity index 100% rename from tests/top-langs.test.js rename to backend/tests/top-langs.test.js diff --git a/tests/utils.test.js b/backend/tests/utils.test.js similarity index 100% rename from tests/utils.test.js rename to backend/tests/utils.test.js diff --git a/tests/wakatime.test.js b/backend/tests/wakatime.test.js similarity index 100% rename from tests/wakatime.test.js rename to backend/tests/wakatime.test.js diff --git a/themes/README.md b/backend/themes/README.md similarity index 100% rename from themes/README.md rename to backend/themes/README.md diff --git a/themes/index.js b/backend/themes/index.js similarity index 100% rename from themes/index.js rename to backend/themes/index.js diff --git a/vercel.json b/backend/vercel.json similarity index 100% rename from vercel.json rename to backend/vercel.json diff --git a/frontend/.gitattributes b/frontend/.gitattributes new file mode 100644 index 00000000..dfe07704 --- /dev/null +++ b/frontend/.gitattributes @@ -0,0 +1,2 @@ +# Auto detect text files and perform LF normalization +* text=auto diff --git a/frontend/.github/workflows/backend.yaml b/frontend/.github/workflows/backend.yaml new file mode 100644 index 00000000..6c6160cd --- /dev/null +++ b/frontend/.github/workflows/backend.yaml @@ -0,0 +1,39 @@ +name: CI-Backend + +on: + push: + branches: [main] + pull_request: + branches: [main] + +jobs: + build: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v2 + - name: Set up Python 3.11 + uses: actions/setup-python@v2 + with: + python-version: 3.11 + - name: Install dependencies + run: | + cd backend + python -m pip install --upgrade pip + pip install -r requirements.txt + - name: Test with unittest + run: | + cd backend + python -m unittest + env: + AUTH_TOKEN: ${{ secrets.AUTH_TOKEN }} + MONGODB_PASSWORD: ${{ secrets.MONGODB_PASSWORD }} + - name: Upload coverage to Coveralls + run: | + cd backend + coverage run --source=src -m unittest + coveralls + env: + AUTH_TOKEN: ${{ secrets.AUTH_TOKEN }} + COVERALLS_REPO_TOKEN: ${{ secrets.COVERALLS_REPO_TOKEN }} + MONGODB_PASSWORD: ${{ secrets.MONGODB_PASSWORD }} diff --git a/frontend/.github/workflows/frontend.yaml b/frontend/.github/workflows/frontend.yaml new file mode 100644 index 00000000..4e30e58a --- /dev/null +++ b/frontend/.github/workflows/frontend.yaml @@ -0,0 +1,21 @@ +name: CI-Frontend + +on: + push: + branches: [ main ] + pull_request: + branches: [ main ] + +jobs: + build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v2 + - name: Install modules + run: | + cd frontend + yarn + - name: Run ESLint + run: | + cd frontend + yarn eslint . \ No newline at end of file diff --git a/frontend/.gitignore b/frontend/.gitignore new file mode 100644 index 00000000..0b4ea1b1 --- /dev/null +++ b/frontend/.gitignore @@ -0,0 +1,12 @@ +*.pyc +__pycache__ + +.vscode + +backend/.env +backend/.venv +backend/.coverage +backend/gcloud_key.json + +frontend/.env +.DS_Store diff --git a/frontend/LICENSE b/frontend/LICENSE new file mode 100644 index 00000000..15500cbf --- /dev/null +++ b/frontend/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2020 Abhijit Gupta + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/frontend/README.md b/frontend/README.md new file mode 100644 index 00000000..5aaadff6 --- /dev/null +++ b/frontend/README.md @@ -0,0 +1,62 @@ +# GitHub Trends + +## SPECIAL: GitHub Wrapped + +Check out your GitHub Wrapped at `githubwrapped.io`! + +![github-wrapped](https://github.com/avgupta456/github-trends/assets/16708871/bf9406a4-6a49-4dbf-8f60-af221bb84bd6) + +--- + +## What is GitHub Trends + +GitHub Trends dives deep into the GitHub API to bring you exciting and impactful metrics about your code contributions. Generate insights on lines written by language, repository, and time. Easily embed dynamic images into your GitHub profile to share your statistics with the world. Check out some of the examples below: + + + + + + + + +## Quickstart + +First, visit `https://api.githubtrends.io/auth/signup/public` and create an account with GitHub Trends. Then, paste this string into your Markdown content, substituting your username. + +```md +[![GitHub Trends SVG](https://api.githubtrends.io/user/svg/avgupta456/langs)](https://githubtrends.io) +``` + +And voila, you get a card like above. Keep reading to learn more! + +## Why GitHub Trends? + +Unlike other projects which look at just your public repositories, GitHub Trends computes metrics based on your individual commits. If you commit to open-source projects, or have collaborators contribute to your own repositories, GitHub Trends will better measure your own code contributions. Through this method, GitHub Trends is the first project that allows users to surface lines of code written (LOC) by language and repository. Our web interface also allows for easier customization. + +# Usage + +## Website Workflow (Alpha) + +Visit [githubtrends.io](https://www.githubtrends.io) to create an account and get started! + +Have questions? Check out [the demo](https://www.githubtrends.io/demo)! + +![image](https://user-images.githubusercontent.com/16708871/138611082-105e4dbc-8a27-4f68-8045-f9d86c912429.png) + +--- + +## API Workflow (Alpha) + +Alternatively, you can communicate directly with the API to create and customize your cards. Read [docs/API.md](https://github.com/avgupta456/github-trends/blob/main/docs/API.md) to learn more about the API and customizations. + +## FAQ + +See [docs/FAQ.md](https://github.com/avgupta456/github-trends/blob/main/docs/FAQ.md). + +## Contributing + +See [docs/CONTRIBUTING.md](https://github.com/avgupta456/github-trends/blob/main/docs/CONTRIBUTING.md). + +## Acknowledgements + +Much inspiration was taken from [GitHub Readme Stats](https://github.com/anuraghazra/github-readme-stats). If you haven't already, check it out and give it a star! diff --git a/frontend/backend/.coveragerc b/frontend/backend/.coveragerc new file mode 100644 index 00000000..51eca306 --- /dev/null +++ b/frontend/backend/.coveragerc @@ -0,0 +1,15 @@ +[run] +source = src + +omit = + ./.venv/* + ./tests/* + ./models/* + */__init__.py + +[report] +omit = + ./.venv/* + ./tests/* + ./models/* + */__init__.py \ No newline at end of file diff --git a/frontend/backend/.env-template b/frontend/backend/.env-template new file mode 100644 index 00000000..38281c92 --- /dev/null +++ b/frontend/backend/.env-template @@ -0,0 +1,14 @@ +AUTH_TOKEN=abc123 +COVERALLS_REPO_TOKEN=abc123 + +PROD_OAUTH_CLIENT_ID=abc123 +PROD_OAUTH_CLIENT_SECRET=abc123 +PROD_OAUTH_REDIRECT_URI=abc123 + +DEV_OAUTH_CLIENT_ID=abc123 +DEV_OAUTH_CLIENT_SECRET=abc123 +DEV_OAUTH_REDIRECT_URI=abc123 + +GOOGLE_APPLICATION_CREDENTIALS=abc123 + +MONGODB_PASSWORD=abc123 diff --git a/frontend/backend/.flake8 b/frontend/backend/.flake8 new file mode 100644 index 00000000..b685fd68 --- /dev/null +++ b/frontend/backend/.flake8 @@ -0,0 +1,5 @@ +[flake8] +max-line-length = 88 +max-complexity = 100 +select = B,C,E,F,W,T +ignore = E203, W503, E501 \ No newline at end of file diff --git a/frontend/backend/.gcloudignore b/frontend/backend/.gcloudignore new file mode 100644 index 00000000..7eab7ca8 --- /dev/null +++ b/frontend/backend/.gcloudignore @@ -0,0 +1,34 @@ +# This file specifies files that are *not* uploaded to Google Cloud Platform +# using gcloud. It follows the same syntax as .gitignore, with the addition of +# "#!include" directives (which insert the entries of the given .gitignore-style +# file at that point). +# +# For more information, run: +# $ gcloud topic gcloudignore +# +.gcloudignore +# If you would like to upload your .git directory, .gitignore file or files +# from your .gitignore file, remove the corresponding line +# below: +.git +.gitignore + +# Python pycache: +__pycache__/ +.venv +.coverage +.coveragerc +.flake8 +poetry.lock +pyproject.toml +README.md + +# Ignored by the build system +/setup.cfg + +# remove irrelevant files/folders +deploy +tests +.env-template +.pre-commit-config.yaml +gcloud_key.json \ No newline at end of file diff --git a/frontend/backend/.pre-commit-config.yaml b/frontend/backend/.pre-commit-config.yaml new file mode 100644 index 00000000..74a849ac --- /dev/null +++ b/frontend/backend/.pre-commit-config.yaml @@ -0,0 +1,11 @@ +repos: + - repo: https://github.com/ambv/black + rev: 21.10b0 + hooks: + - id: black + language_version: python3.11 + - repo: https://github.com/pre-commit/pre-commit-hooks + rev: v2.3.0 + hooks: + - id: flake8 + args: [--config=./backend/.flake8] diff --git a/frontend/backend/README.md b/frontend/backend/README.md new file mode 100644 index 00000000..532cf057 --- /dev/null +++ b/frontend/backend/README.md @@ -0,0 +1,38 @@ +# Backend + +## Installation + +``` +poetry install +poetry run pre-commit install +``` + +## Run Locally + +Navigate to localhost:8000 + +``` +yarn start +``` + +## Test with Code Coverage + +``` +yarn test +``` + +View coverage with GitHub badge or on coveralls.io + +## Build + +If a new requirement has been added, make sure to update the requirements.txt + +``` +yarn set-reqs +``` + +Then, just commit on the main branch (Google Cloud Run takes care of the rest) + +## Adding a Secret + +Update cloudbuild.yaml, .env, .env-template, and GCP Cloud Run Trigger Substitution Variables. diff --git a/frontend/backend/deploy/app.yaml b/frontend/backend/deploy/app.yaml new file mode 100644 index 00000000..fef26e47 --- /dev/null +++ b/frontend/backend/deploy/app.yaml @@ -0,0 +1,14 @@ +service: default +runtime: python311 +entrypoint: gunicorn -w 2 -k uvicorn.workers.UvicornWorker src.main:app + +#smallest instance class +instance_class: F1 + +#prevents creating additional instances +automatic_scaling: + min_instances: 0 + max_instances: 1 + +env_variables: + PROD: true diff --git a/frontend/backend/deploy/cloudbuild.yaml b/frontend/backend/deploy/cloudbuild.yaml new file mode 100644 index 00000000..15e244cf --- /dev/null +++ b/frontend/backend/deploy/cloudbuild.yaml @@ -0,0 +1,21 @@ +steps: + - name: node:10.15.1 + entrypoint: npm + args: ["install"] + dir: "backend" + - name: node:10.15.1 + entrypoint: npm + args: ["run", "create-env"] + dir: "backend" + env: + - "DEV_OAUTH_CLIENT_ID=${_DEV_OAUTH_CLIENT_ID}" + - "DEV_OAUTH_CLIENT_SECRET=${_DEV_OAUTH_CLIENT_SECRET}" + - "DEV_OAUTH_REDIRECT_URI=${_DEV_OAUTH_REDIRECT_URI}" + - "PROD_OAUTH_CLIENT_ID=${_PROD_OAUTH_CLIENT_ID}" + - "PROD_OAUTH_CLIENT_SECRET=${_PROD_OAUTH_CLIENT_SECRET}" + - "PROD_OAUTH_REDIRECT_URI=${_PROD_OAUTH_REDIRECT_URI}" + - "MONGODB_PASSWORD=${_MONGODB_PASSWORD}" + - "SENTRY_DSN=${_SENTRY_DSN}" + - name: "gcr.io/cloud-builders/gcloud" + args: ["app", "deploy", "--appyaml", "./deploy/app.yaml"] + dir: "backend" diff --git a/frontend/backend/deploy/dispatch.yaml b/frontend/backend/deploy/dispatch.yaml new file mode 100644 index 00000000..c4dc2328 --- /dev/null +++ b/frontend/backend/deploy/dispatch.yaml @@ -0,0 +1,3 @@ +dispatch: + - url: "*/.*" + service: default diff --git a/frontend/backend/package.json b/frontend/backend/package.json new file mode 100644 index 00000000..b7d72d71 --- /dev/null +++ b/frontend/backend/package.json @@ -0,0 +1,13 @@ +{ + "name": "github-trends", + "version": "0.0.1", + "private": true, + "scripts": { + "gen-lang-map": "poetry run python src/data/github/language_map.py", + "start": "poetry run uvicorn src.main:app --reload --port=8000", + "set-reqs": "poetry lock && poetry export -f requirements.txt --output requirements.txt --without-hashes", + "create-env": "printenv > .env", + "test": "poetry run coverage run --source=src -m unittest -v && poetry run coverage report", + "isort": "poetry run isort . --src-path=./src --multi-line=3 --trailing-comma --line-length=88 --combine-as --ensure-newline-before-comments" + } +} diff --git a/frontend/backend/poetry.lock b/frontend/backend/poetry.lock new file mode 100644 index 00000000..46c60268 --- /dev/null +++ b/frontend/backend/poetry.lock @@ -0,0 +1,1673 @@ +# This file is automatically @generated by Poetry 1.4.1 and should not be changed by hand. + +[[package]] +name = "aiofiles" +version = "23.2.1" +description = "File support for asyncio." +category = "main" +optional = false +python-versions = ">=3.7" +files = [ + {file = "aiofiles-23.2.1-py3-none-any.whl", hash = "sha256:19297512c647d4b27a2cf7c34caa7e405c0d60b5560618a29a9fe027b18b0107"}, + {file = "aiofiles-23.2.1.tar.gz", hash = "sha256:84ec2218d8419404abcb9f0c02df3f34c6e0a68ed41072acfb1cef5cbc29051a"}, +] + +[[package]] +name = "aiounittest" +version = "1.4.2" +description = "Test asyncio code more easily." +category = "main" +optional = false +python-versions = "*" +files = [ + {file = "aiounittest-1.4.2-py3-none-any.whl", hash = "sha256:11634918fbbf09b954d97c74d857835750d025d1cafb675391d0099b8d83665a"}, + {file = "aiounittest-1.4.2.tar.gz", hash = "sha256:1478ec8013c70046569cdc76d541083ca7da362e6513cf29c7ecb51330229bdb"}, +] + +[package.dependencies] +wrapt = "*" + +[[package]] +name = "annotated-types" +version = "0.6.0" +description = "Reusable constraint types to use with typing.Annotated" +category = "main" +optional = false +python-versions = ">=3.8" +files = [ + {file = "annotated_types-0.6.0-py3-none-any.whl", hash = "sha256:0641064de18ba7a25dee8f96403ebc39113d0cb953a01429249d5c7564666a43"}, + {file = "annotated_types-0.6.0.tar.gz", hash = "sha256:563339e807e53ffd9c267e99fc6d9ea23eb8443c08f112651963e24e22f84a5d"}, +] + +[[package]] +name = "anyio" +version = "3.7.1" +description = "High level compatibility layer for multiple asynchronous event loop implementations" +category = "main" +optional = false +python-versions = ">=3.7" +files = [ + {file = "anyio-3.7.1-py3-none-any.whl", hash = "sha256:91dee416e570e92c64041bd18b900d1d6fa78dff7048769ce5ac5ddad004fbb5"}, + {file = "anyio-3.7.1.tar.gz", hash = "sha256:44a3c9aba0f5defa43261a8b3efb97891f2bd7d804e0e1f56419befa1adfc780"}, +] + +[package.dependencies] +idna = ">=2.8" +sniffio = ">=1.1" + +[package.extras] +doc = ["Sphinx", "packaging", "sphinx-autodoc-typehints (>=1.2.0)", "sphinx-rtd-theme (>=1.2.2)", "sphinxcontrib-jquery"] +test = ["anyio[trio]", "coverage[toml] (>=4.5)", "hypothesis (>=4.0)", "mock (>=4)", "psutil (>=5.9)", "pytest (>=7.0)", "pytest-mock (>=3.6.1)", "trustme", "uvloop (>=0.17)"] +trio = ["trio (<0.22)"] + +[[package]] +name = "black" +version = "23.11.0" +description = "The uncompromising code formatter." +category = "dev" +optional = false +python-versions = ">=3.8" +files = [ + {file = "black-23.11.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:dbea0bb8575c6b6303cc65017b46351dc5953eea5c0a59d7b7e3a2d2f433a911"}, + {file = "black-23.11.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:412f56bab20ac85927f3a959230331de5614aecda1ede14b373083f62ec24e6f"}, + {file = "black-23.11.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d136ef5b418c81660ad847efe0e55c58c8208b77a57a28a503a5f345ccf01394"}, + {file = "black-23.11.0-cp310-cp310-win_amd64.whl", hash = "sha256:6c1cac07e64433f646a9a838cdc00c9768b3c362805afc3fce341af0e6a9ae9f"}, + {file = "black-23.11.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:cf57719e581cfd48c4efe28543fea3d139c6b6f1238b3f0102a9c73992cbb479"}, + {file = "black-23.11.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:698c1e0d5c43354ec5d6f4d914d0d553a9ada56c85415700b81dc90125aac244"}, + {file = "black-23.11.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:760415ccc20f9e8747084169110ef75d545f3b0932ee21368f63ac0fee86b221"}, + {file = "black-23.11.0-cp311-cp311-win_amd64.whl", hash = "sha256:58e5f4d08a205b11800332920e285bd25e1a75c54953e05502052738fe16b3b5"}, + {file = "black-23.11.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:45aa1d4675964946e53ab81aeec7a37613c1cb71647b5394779e6efb79d6d187"}, + {file = "black-23.11.0-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:4c44b7211a3a0570cc097e81135faa5f261264f4dfaa22bd5ee2875a4e773bd6"}, + {file = "black-23.11.0-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2a9acad1451632021ee0d146c8765782a0c3846e0e0ea46659d7c4f89d9b212b"}, + {file = "black-23.11.0-cp38-cp38-win_amd64.whl", hash = "sha256:fc7f6a44d52747e65a02558e1d807c82df1d66ffa80a601862040a43ec2e3142"}, + {file = "black-23.11.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:7f622b6822f02bfaf2a5cd31fdb7cd86fcf33dab6ced5185c35f5db98260b055"}, + {file = "black-23.11.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:250d7e60f323fcfc8ea6c800d5eba12f7967400eb6c2d21ae85ad31c204fb1f4"}, + {file = "black-23.11.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5133f5507007ba08d8b7b263c7aa0f931af5ba88a29beacc4b2dc23fcefe9c06"}, + {file = "black-23.11.0-cp39-cp39-win_amd64.whl", hash = "sha256:421f3e44aa67138ab1b9bfbc22ee3780b22fa5b291e4db8ab7eee95200726b07"}, + {file = "black-23.11.0-py3-none-any.whl", hash = "sha256:54caaa703227c6e0c87b76326d0862184729a69b73d3b7305b6288e1d830067e"}, + {file = "black-23.11.0.tar.gz", hash = "sha256:4c68855825ff432d197229846f971bc4d6666ce90492e5b02013bcaca4d9ab05"}, +] + +[package.dependencies] +click = ">=8.0.0" +mypy-extensions = ">=0.4.3" +packaging = ">=22.0" +pathspec = ">=0.9.0" +platformdirs = ">=2" + +[package.extras] +colorama = ["colorama (>=0.4.3)"] +d = ["aiohttp (>=3.7.4)"] +jupyter = ["ipython (>=7.8.0)", "tokenize-rt (>=3.2.0)"] +uvloop = ["uvloop (>=0.15.2)"] + +[[package]] +name = "certifi" +version = "2023.11.17" +description = "Python package for providing Mozilla's CA Bundle." +category = "main" +optional = false +python-versions = ">=3.6" +files = [ + {file = "certifi-2023.11.17-py3-none-any.whl", hash = "sha256:e036ab49d5b79556f99cfc2d9320b34cfbe5be05c5871b51de9329f0603b0474"}, + {file = "certifi-2023.11.17.tar.gz", hash = "sha256:9b469f3a900bf28dc19b8cfbf8019bf47f7fdd1a65a1d4ffb98fc14166beb4d1"}, +] + +[[package]] +name = "cfgv" +version = "3.4.0" +description = "Validate configuration and produce human readable error messages." +category = "dev" +optional = false +python-versions = ">=3.8" +files = [ + {file = "cfgv-3.4.0-py2.py3-none-any.whl", hash = "sha256:b7265b1f29fd3316bfcd2b330d63d024f2bfd8bcb8b0272f8e19a504856c48f9"}, + {file = "cfgv-3.4.0.tar.gz", hash = "sha256:e52591d4c5f5dead8e0f673fb16db7949d2cfb3f7da4582893288f0ded8fe560"}, +] + +[[package]] +name = "charset-normalizer" +version = "3.3.2" +description = "The Real First Universal Charset Detector. Open, modern and actively maintained alternative to Chardet." +category = "main" +optional = false +python-versions = ">=3.7.0" +files = [ + {file = "charset-normalizer-3.3.2.tar.gz", hash = "sha256:f30c3cb33b24454a82faecaf01b19c18562b1e89558fb6c56de4d9118a032fd5"}, + {file = "charset_normalizer-3.3.2-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:25baf083bf6f6b341f4121c2f3c548875ee6f5339300e08be3f2b2ba1721cdd3"}, + {file = "charset_normalizer-3.3.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:06435b539f889b1f6f4ac1758871aae42dc3a8c0e24ac9e60c2384973ad73027"}, + {file = "charset_normalizer-3.3.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:9063e24fdb1e498ab71cb7419e24622516c4a04476b17a2dab57e8baa30d6e03"}, + {file = "charset_normalizer-3.3.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6897af51655e3691ff853668779c7bad41579facacf5fd7253b0133308cf000d"}, + {file = "charset_normalizer-3.3.2-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1d3193f4a680c64b4b6a9115943538edb896edc190f0b222e73761716519268e"}, + {file = "charset_normalizer-3.3.2-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:cd70574b12bb8a4d2aaa0094515df2463cb429d8536cfb6c7ce983246983e5a6"}, + {file = "charset_normalizer-3.3.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8465322196c8b4d7ab6d1e049e4c5cb460d0394da4a27d23cc242fbf0034b6b5"}, + {file = "charset_normalizer-3.3.2-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a9a8e9031d613fd2009c182b69c7b2c1ef8239a0efb1df3f7c8da66d5dd3d537"}, + {file = "charset_normalizer-3.3.2-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:beb58fe5cdb101e3a055192ac291b7a21e3b7ef4f67fa1d74e331a7f2124341c"}, + {file = "charset_normalizer-3.3.2-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:e06ed3eb3218bc64786f7db41917d4e686cc4856944f53d5bdf83a6884432e12"}, + {file = "charset_normalizer-3.3.2-cp310-cp310-musllinux_1_1_ppc64le.whl", hash = "sha256:2e81c7b9c8979ce92ed306c249d46894776a909505d8f5a4ba55b14206e3222f"}, + {file = "charset_normalizer-3.3.2-cp310-cp310-musllinux_1_1_s390x.whl", hash = "sha256:572c3763a264ba47b3cf708a44ce965d98555f618ca42c926a9c1616d8f34269"}, + {file = "charset_normalizer-3.3.2-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:fd1abc0d89e30cc4e02e4064dc67fcc51bd941eb395c502aac3ec19fab46b519"}, + {file = "charset_normalizer-3.3.2-cp310-cp310-win32.whl", hash = "sha256:3d47fa203a7bd9c5b6cee4736ee84ca03b8ef23193c0d1ca99b5089f72645c73"}, + {file = "charset_normalizer-3.3.2-cp310-cp310-win_amd64.whl", hash = "sha256:10955842570876604d404661fbccbc9c7e684caf432c09c715ec38fbae45ae09"}, + {file = "charset_normalizer-3.3.2-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:802fe99cca7457642125a8a88a084cef28ff0cf9407060f7b93dca5aa25480db"}, + {file = "charset_normalizer-3.3.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:573f6eac48f4769d667c4442081b1794f52919e7edada77495aaed9236d13a96"}, + {file = "charset_normalizer-3.3.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:549a3a73da901d5bc3ce8d24e0600d1fa85524c10287f6004fbab87672bf3e1e"}, + {file = "charset_normalizer-3.3.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f27273b60488abe721a075bcca6d7f3964f9f6f067c8c4c605743023d7d3944f"}, + {file = "charset_normalizer-3.3.2-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1ceae2f17a9c33cb48e3263960dc5fc8005351ee19db217e9b1bb15d28c02574"}, + {file = "charset_normalizer-3.3.2-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:65f6f63034100ead094b8744b3b97965785388f308a64cf8d7c34f2f2e5be0c4"}, + {file = "charset_normalizer-3.3.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:753f10e867343b4511128c6ed8c82f7bec3bd026875576dfd88483c5c73b2fd8"}, + {file = "charset_normalizer-3.3.2-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:4a78b2b446bd7c934f5dcedc588903fb2f5eec172f3d29e52a9096a43722adfc"}, + {file = "charset_normalizer-3.3.2-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:e537484df0d8f426ce2afb2d0f8e1c3d0b114b83f8850e5f2fbea0e797bd82ae"}, + {file = "charset_normalizer-3.3.2-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:eb6904c354526e758fda7167b33005998fb68c46fbc10e013ca97f21ca5c8887"}, + {file = "charset_normalizer-3.3.2-cp311-cp311-musllinux_1_1_ppc64le.whl", hash = "sha256:deb6be0ac38ece9ba87dea880e438f25ca3eddfac8b002a2ec3d9183a454e8ae"}, + {file = "charset_normalizer-3.3.2-cp311-cp311-musllinux_1_1_s390x.whl", hash = "sha256:4ab2fe47fae9e0f9dee8c04187ce5d09f48eabe611be8259444906793ab7cbce"}, + {file = "charset_normalizer-3.3.2-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:80402cd6ee291dcb72644d6eac93785fe2c8b9cb30893c1af5b8fdd753b9d40f"}, + {file = "charset_normalizer-3.3.2-cp311-cp311-win32.whl", hash = "sha256:7cd13a2e3ddeed6913a65e66e94b51d80a041145a026c27e6bb76c31a853c6ab"}, + {file = "charset_normalizer-3.3.2-cp311-cp311-win_amd64.whl", hash = "sha256:663946639d296df6a2bb2aa51b60a2454ca1cb29835324c640dafb5ff2131a77"}, + {file = "charset_normalizer-3.3.2-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:0b2b64d2bb6d3fb9112bafa732def486049e63de9618b5843bcdd081d8144cd8"}, + {file = "charset_normalizer-3.3.2-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:ddbb2551d7e0102e7252db79ba445cdab71b26640817ab1e3e3648dad515003b"}, + {file = "charset_normalizer-3.3.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:55086ee1064215781fff39a1af09518bc9255b50d6333f2e4c74ca09fac6a8f6"}, + {file = "charset_normalizer-3.3.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8f4a014bc36d3c57402e2977dada34f9c12300af536839dc38c0beab8878f38a"}, + {file = "charset_normalizer-3.3.2-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a10af20b82360ab00827f916a6058451b723b4e65030c5a18577c8b2de5b3389"}, + {file = "charset_normalizer-3.3.2-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8d756e44e94489e49571086ef83b2bb8ce311e730092d2c34ca8f7d925cb20aa"}, + {file = "charset_normalizer-3.3.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:90d558489962fd4918143277a773316e56c72da56ec7aa3dc3dbbe20fdfed15b"}, + {file = "charset_normalizer-3.3.2-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:6ac7ffc7ad6d040517be39eb591cac5ff87416c2537df6ba3cba3bae290c0fed"}, + {file = "charset_normalizer-3.3.2-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:7ed9e526742851e8d5cc9e6cf41427dfc6068d4f5a3bb03659444b4cabf6bc26"}, + {file = "charset_normalizer-3.3.2-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:8bdb58ff7ba23002a4c5808d608e4e6c687175724f54a5dade5fa8c67b604e4d"}, + {file = "charset_normalizer-3.3.2-cp312-cp312-musllinux_1_1_ppc64le.whl", hash = "sha256:6b3251890fff30ee142c44144871185dbe13b11bab478a88887a639655be1068"}, + {file = "charset_normalizer-3.3.2-cp312-cp312-musllinux_1_1_s390x.whl", hash = "sha256:b4a23f61ce87adf89be746c8a8974fe1c823c891d8f86eb218bb957c924bb143"}, + {file = "charset_normalizer-3.3.2-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:efcb3f6676480691518c177e3b465bcddf57cea040302f9f4e6e191af91174d4"}, + {file = "charset_normalizer-3.3.2-cp312-cp312-win32.whl", hash = "sha256:d965bba47ddeec8cd560687584e88cf699fd28f192ceb452d1d7ee807c5597b7"}, + {file = "charset_normalizer-3.3.2-cp312-cp312-win_amd64.whl", hash = "sha256:96b02a3dc4381e5494fad39be677abcb5e6634bf7b4fa83a6dd3112607547001"}, + {file = "charset_normalizer-3.3.2-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:95f2a5796329323b8f0512e09dbb7a1860c46a39da62ecb2324f116fa8fdc85c"}, + {file = "charset_normalizer-3.3.2-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c002b4ffc0be611f0d9da932eb0f704fe2602a9a949d1f738e4c34c75b0863d5"}, + {file = "charset_normalizer-3.3.2-cp37-cp37m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a981a536974bbc7a512cf44ed14938cf01030a99e9b3a06dd59578882f06f985"}, + {file = "charset_normalizer-3.3.2-cp37-cp37m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3287761bc4ee9e33561a7e058c72ac0938c4f57fe49a09eae428fd88aafe7bb6"}, + {file = "charset_normalizer-3.3.2-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:42cb296636fcc8b0644486d15c12376cb9fa75443e00fb25de0b8602e64c1714"}, + {file = "charset_normalizer-3.3.2-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0a55554a2fa0d408816b3b5cedf0045f4b8e1a6065aec45849de2d6f3f8e9786"}, + {file = "charset_normalizer-3.3.2-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:c083af607d2515612056a31f0a8d9e0fcb5876b7bfc0abad3ecd275bc4ebc2d5"}, + {file = "charset_normalizer-3.3.2-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:87d1351268731db79e0f8e745d92493ee2841c974128ef629dc518b937d9194c"}, + {file = "charset_normalizer-3.3.2-cp37-cp37m-musllinux_1_1_ppc64le.whl", hash = "sha256:bd8f7df7d12c2db9fab40bdd87a7c09b1530128315d047a086fa3ae3435cb3a8"}, + {file = "charset_normalizer-3.3.2-cp37-cp37m-musllinux_1_1_s390x.whl", hash = "sha256:c180f51afb394e165eafe4ac2936a14bee3eb10debc9d9e4db8958fe36afe711"}, + {file = "charset_normalizer-3.3.2-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:8c622a5fe39a48f78944a87d4fb8a53ee07344641b0562c540d840748571b811"}, + {file = "charset_normalizer-3.3.2-cp37-cp37m-win32.whl", hash = "sha256:db364eca23f876da6f9e16c9da0df51aa4f104a972735574842618b8c6d999d4"}, + {file = "charset_normalizer-3.3.2-cp37-cp37m-win_amd64.whl", hash = "sha256:86216b5cee4b06df986d214f664305142d9c76df9b6512be2738aa72a2048f99"}, + {file = "charset_normalizer-3.3.2-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:6463effa3186ea09411d50efc7d85360b38d5f09b870c48e4600f63af490e56a"}, + {file = "charset_normalizer-3.3.2-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:6c4caeef8fa63d06bd437cd4bdcf3ffefe6738fb1b25951440d80dc7df8c03ac"}, + {file = "charset_normalizer-3.3.2-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:37e55c8e51c236f95b033f6fb391d7d7970ba5fe7ff453dad675e88cf303377a"}, + {file = "charset_normalizer-3.3.2-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:fb69256e180cb6c8a894fee62b3afebae785babc1ee98b81cdf68bbca1987f33"}, + {file = "charset_normalizer-3.3.2-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ae5f4161f18c61806f411a13b0310bea87f987c7d2ecdbdaad0e94eb2e404238"}, + {file = "charset_normalizer-3.3.2-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b2b0a0c0517616b6869869f8c581d4eb2dd83a4d79e0ebcb7d373ef9956aeb0a"}, + {file = "charset_normalizer-3.3.2-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:45485e01ff4d3630ec0d9617310448a8702f70e9c01906b0d0118bdf9d124cf2"}, + {file = "charset_normalizer-3.3.2-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:eb00ed941194665c332bf8e078baf037d6c35d7c4f3102ea2d4f16ca94a26dc8"}, + {file = "charset_normalizer-3.3.2-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:2127566c664442652f024c837091890cb1942c30937add288223dc895793f898"}, + {file = "charset_normalizer-3.3.2-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:a50aebfa173e157099939b17f18600f72f84eed3049e743b68ad15bd69b6bf99"}, + {file = "charset_normalizer-3.3.2-cp38-cp38-musllinux_1_1_ppc64le.whl", hash = "sha256:4d0d1650369165a14e14e1e47b372cfcb31d6ab44e6e33cb2d4e57265290044d"}, + {file = "charset_normalizer-3.3.2-cp38-cp38-musllinux_1_1_s390x.whl", hash = "sha256:923c0c831b7cfcb071580d3f46c4baf50f174be571576556269530f4bbd79d04"}, + {file = "charset_normalizer-3.3.2-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:06a81e93cd441c56a9b65d8e1d043daeb97a3d0856d177d5c90ba85acb3db087"}, + {file = "charset_normalizer-3.3.2-cp38-cp38-win32.whl", hash = "sha256:6ef1d82a3af9d3eecdba2321dc1b3c238245d890843e040e41e470ffa64c3e25"}, + {file = "charset_normalizer-3.3.2-cp38-cp38-win_amd64.whl", hash = "sha256:eb8821e09e916165e160797a6c17edda0679379a4be5c716c260e836e122f54b"}, + {file = "charset_normalizer-3.3.2-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:c235ebd9baae02f1b77bcea61bce332cb4331dc3617d254df3323aa01ab47bd4"}, + {file = "charset_normalizer-3.3.2-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:5b4c145409bef602a690e7cfad0a15a55c13320ff7a3ad7ca59c13bb8ba4d45d"}, + {file = "charset_normalizer-3.3.2-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:68d1f8a9e9e37c1223b656399be5d6b448dea850bed7d0f87a8311f1ff3dabb0"}, + {file = "charset_normalizer-3.3.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:22afcb9f253dac0696b5a4be4a1c0f8762f8239e21b99680099abd9b2b1b2269"}, + {file = "charset_normalizer-3.3.2-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e27ad930a842b4c5eb8ac0016b0a54f5aebbe679340c26101df33424142c143c"}, + {file = "charset_normalizer-3.3.2-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:1f79682fbe303db92bc2b1136016a38a42e835d932bab5b3b1bfcfbf0640e519"}, + {file = "charset_normalizer-3.3.2-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b261ccdec7821281dade748d088bb6e9b69e6d15b30652b74cbbac25e280b796"}, + {file = "charset_normalizer-3.3.2-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:122c7fa62b130ed55f8f285bfd56d5f4b4a5b503609d181f9ad85e55c89f4185"}, + {file = "charset_normalizer-3.3.2-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:d0eccceffcb53201b5bfebb52600a5fb483a20b61da9dbc885f8b103cbe7598c"}, + {file = "charset_normalizer-3.3.2-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:9f96df6923e21816da7e0ad3fd47dd8f94b2a5ce594e00677c0013018b813458"}, + {file = "charset_normalizer-3.3.2-cp39-cp39-musllinux_1_1_ppc64le.whl", hash = "sha256:7f04c839ed0b6b98b1a7501a002144b76c18fb1c1850c8b98d458ac269e26ed2"}, + {file = "charset_normalizer-3.3.2-cp39-cp39-musllinux_1_1_s390x.whl", hash = "sha256:34d1c8da1e78d2e001f363791c98a272bb734000fcef47a491c1e3b0505657a8"}, + {file = "charset_normalizer-3.3.2-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:ff8fa367d09b717b2a17a052544193ad76cd49979c805768879cb63d9ca50561"}, + {file = "charset_normalizer-3.3.2-cp39-cp39-win32.whl", hash = "sha256:aed38f6e4fb3f5d6bf81bfa990a07806be9d83cf7bacef998ab1a9bd660a581f"}, + {file = "charset_normalizer-3.3.2-cp39-cp39-win_amd64.whl", hash = "sha256:b01b88d45a6fcb69667cd6d2f7a9aeb4bf53760d7fc536bf679ec94fe9f3ff3d"}, + {file = "charset_normalizer-3.3.2-py3-none-any.whl", hash = "sha256:3e4d1f6587322d2788836a99c69062fbb091331ec940e02d12d179c1d53e25fc"}, +] + +[[package]] +name = "click" +version = "8.1.7" +description = "Composable command line interface toolkit" +category = "main" +optional = false +python-versions = ">=3.7" +files = [ + {file = "click-8.1.7-py3-none-any.whl", hash = "sha256:ae74fb96c20a0277a1d615f1e4d73c8414f5a98db8b799a7931d1582f3390c28"}, + {file = "click-8.1.7.tar.gz", hash = "sha256:ca9853ad459e787e2192211578cc907e7594e294c7ccc834310722b41b9ca6de"}, +] + +[package.dependencies] +colorama = {version = "*", markers = "platform_system == \"Windows\""} + +[[package]] +name = "colorama" +version = "0.4.6" +description = "Cross-platform colored terminal text." +category = "main" +optional = false +python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7" +files = [ + {file = "colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6"}, + {file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"}, +] + +[[package]] +name = "coverage" +version = "6.5.0" +description = "Code coverage measurement for Python" +category = "main" +optional = false +python-versions = ">=3.7" +files = [ + {file = "coverage-6.5.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:ef8674b0ee8cc11e2d574e3e2998aea5df5ab242e012286824ea3c6970580e53"}, + {file = "coverage-6.5.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:784f53ebc9f3fd0e2a3f6a78b2be1bd1f5575d7863e10c6e12504f240fd06660"}, + {file = "coverage-6.5.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b4a5be1748d538a710f87542f22c2cad22f80545a847ad91ce45e77417293eb4"}, + {file = "coverage-6.5.0-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:83516205e254a0cb77d2d7bb3632ee019d93d9f4005de31dca0a8c3667d5bc04"}, + {file = "coverage-6.5.0-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:af4fffaffc4067232253715065e30c5a7ec6faac36f8fc8d6f64263b15f74db0"}, + {file = "coverage-6.5.0-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:97117225cdd992a9c2a5515db1f66b59db634f59d0679ca1fa3fe8da32749cae"}, + {file = "coverage-6.5.0-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:a1170fa54185845505fbfa672f1c1ab175446c887cce8212c44149581cf2d466"}, + {file = "coverage-6.5.0-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:11b990d520ea75e7ee8dcab5bc908072aaada194a794db9f6d7d5cfd19661e5a"}, + {file = "coverage-6.5.0-cp310-cp310-win32.whl", hash = "sha256:5dbec3b9095749390c09ab7c89d314727f18800060d8d24e87f01fb9cfb40b32"}, + {file = "coverage-6.5.0-cp310-cp310-win_amd64.whl", hash = "sha256:59f53f1dc5b656cafb1badd0feb428c1e7bc19b867479ff72f7a9dd9b479f10e"}, + {file = "coverage-6.5.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:4a5375e28c5191ac38cca59b38edd33ef4cc914732c916f2929029b4bfb50795"}, + {file = "coverage-6.5.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c4ed2820d919351f4167e52425e096af41bfabacb1857186c1ea32ff9983ed75"}, + {file = "coverage-6.5.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:33a7da4376d5977fbf0a8ed91c4dffaaa8dbf0ddbf4c8eea500a2486d8bc4d7b"}, + {file = "coverage-6.5.0-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a8fb6cf131ac4070c9c5a3e21de0f7dc5a0fbe8bc77c9456ced896c12fcdad91"}, + {file = "coverage-6.5.0-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:a6b7d95969b8845250586f269e81e5dfdd8ff828ddeb8567a4a2eaa7313460c4"}, + {file = "coverage-6.5.0-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:1ef221513e6f68b69ee9e159506d583d31aa3567e0ae84eaad9d6ec1107dddaa"}, + {file = "coverage-6.5.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:cca4435eebea7962a52bdb216dec27215d0df64cf27fc1dd538415f5d2b9da6b"}, + {file = "coverage-6.5.0-cp311-cp311-win32.whl", hash = "sha256:98e8a10b7a314f454d9eff4216a9a94d143a7ee65018dd12442e898ee2310578"}, + {file = "coverage-6.5.0-cp311-cp311-win_amd64.whl", hash = "sha256:bc8ef5e043a2af066fa8cbfc6e708d58017024dc4345a1f9757b329a249f041b"}, + {file = "coverage-6.5.0-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:4433b90fae13f86fafff0b326453dd42fc9a639a0d9e4eec4d366436d1a41b6d"}, + {file = "coverage-6.5.0-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f4f05d88d9a80ad3cac6244d36dd89a3c00abc16371769f1340101d3cb899fc3"}, + {file = "coverage-6.5.0-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:94e2565443291bd778421856bc975d351738963071e9b8839ca1fc08b42d4bef"}, + {file = "coverage-6.5.0-cp37-cp37m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:027018943386e7b942fa832372ebc120155fd970837489896099f5cfa2890f79"}, + {file = "coverage-6.5.0-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:255758a1e3b61db372ec2736c8e2a1fdfaf563977eedbdf131de003ca5779b7d"}, + {file = "coverage-6.5.0-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:851cf4ff24062c6aec510a454b2584f6e998cada52d4cb58c5e233d07172e50c"}, + {file = "coverage-6.5.0-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:12adf310e4aafddc58afdb04d686795f33f4d7a6fa67a7a9d4ce7d6ae24d949f"}, + {file = "coverage-6.5.0-cp37-cp37m-win32.whl", hash = "sha256:b5604380f3415ba69de87a289a2b56687faa4fe04dbee0754bfcae433489316b"}, + {file = "coverage-6.5.0-cp37-cp37m-win_amd64.whl", hash = "sha256:4a8dbc1f0fbb2ae3de73eb0bdbb914180c7abfbf258e90b311dcd4f585d44bd2"}, + {file = "coverage-6.5.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:d900bb429fdfd7f511f868cedd03a6bbb142f3f9118c09b99ef8dc9bf9643c3c"}, + {file = "coverage-6.5.0-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:2198ea6fc548de52adc826f62cb18554caedfb1d26548c1b7c88d8f7faa8f6ba"}, + {file = "coverage-6.5.0-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6c4459b3de97b75e3bd6b7d4b7f0db13f17f504f3d13e2a7c623786289dd670e"}, + {file = "coverage-6.5.0-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:20c8ac5386253717e5ccc827caad43ed66fea0efe255727b1053a8154d952398"}, + {file = "coverage-6.5.0-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6b07130585d54fe8dff3d97b93b0e20290de974dc8177c320aeaf23459219c0b"}, + {file = "coverage-6.5.0-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:dbdb91cd8c048c2b09eb17713b0c12a54fbd587d79adcebad543bc0cd9a3410b"}, + {file = "coverage-6.5.0-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:de3001a203182842a4630e7b8d1a2c7c07ec1b45d3084a83d5d227a3806f530f"}, + {file = "coverage-6.5.0-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:e07f4a4a9b41583d6eabec04f8b68076ab3cd44c20bd29332c6572dda36f372e"}, + {file = "coverage-6.5.0-cp38-cp38-win32.whl", hash = "sha256:6d4817234349a80dbf03640cec6109cd90cba068330703fa65ddf56b60223a6d"}, + {file = "coverage-6.5.0-cp38-cp38-win_amd64.whl", hash = "sha256:7ccf362abd726b0410bf8911c31fbf97f09f8f1061f8c1cf03dfc4b6372848f6"}, + {file = "coverage-6.5.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:633713d70ad6bfc49b34ead4060531658dc6dfc9b3eb7d8a716d5873377ab745"}, + {file = "coverage-6.5.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:95203854f974e07af96358c0b261f1048d8e1083f2de9b1c565e1be4a3a48cfc"}, + {file = "coverage-6.5.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b9023e237f4c02ff739581ef35969c3739445fb059b060ca51771e69101efffe"}, + {file = "coverage-6.5.0-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:265de0fa6778d07de30bcf4d9dc471c3dc4314a23a3c6603d356a3c9abc2dfcf"}, + {file = "coverage-6.5.0-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8f830ed581b45b82451a40faabb89c84e1a998124ee4212d440e9c6cf70083e5"}, + {file = "coverage-6.5.0-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:7b6be138d61e458e18d8e6ddcddd36dd96215edfe5f1168de0b1b32635839b62"}, + {file = "coverage-6.5.0-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:42eafe6778551cf006a7c43153af1211c3aaab658d4d66fa5fcc021613d02518"}, + {file = "coverage-6.5.0-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:723e8130d4ecc8f56e9a611e73b31219595baa3bb252d539206f7bbbab6ffc1f"}, + {file = "coverage-6.5.0-cp39-cp39-win32.whl", hash = "sha256:d9ecf0829c6a62b9b573c7bb6d4dcd6ba8b6f80be9ba4fc7ed50bf4ac9aecd72"}, + {file = "coverage-6.5.0-cp39-cp39-win_amd64.whl", hash = "sha256:fc2af30ed0d5ae0b1abdb4ebdce598eafd5b35397d4d75deb341a614d333d987"}, + {file = "coverage-6.5.0-pp36.pp37.pp38-none-any.whl", hash = "sha256:1431986dac3923c5945271f169f59c45b8802a114c8f548d611f2015133df77a"}, + {file = "coverage-6.5.0.tar.gz", hash = "sha256:f642e90754ee3e06b0e7e51bce3379590e76b7f76b708e1a71ff043f87025c84"}, +] + +[package.extras] +toml = ["tomli"] + +[[package]] +name = "coveralls" +version = "3.3.1" +description = "Show coverage stats online via coveralls.io" +category = "main" +optional = false +python-versions = ">= 3.5" +files = [ + {file = "coveralls-3.3.1-py2.py3-none-any.whl", hash = "sha256:f42015f31d386b351d4226389b387ae173207058832fbf5c8ec4b40e27b16026"}, + {file = "coveralls-3.3.1.tar.gz", hash = "sha256:b32a8bb5d2df585207c119d6c01567b81fba690c9c10a753bfe27a335bfc43ea"}, +] + +[package.dependencies] +coverage = ">=4.1,<6.0.0 || >6.1,<6.1.1 || >6.1.1,<7.0" +docopt = ">=0.6.1" +requests = ">=1.0.0" + +[package.extras] +yaml = ["PyYAML (>=3.10)"] + +[[package]] +name = "distlib" +version = "0.3.7" +description = "Distribution utilities" +category = "dev" +optional = false +python-versions = "*" +files = [ + {file = "distlib-0.3.7-py2.py3-none-any.whl", hash = "sha256:2e24928bc811348f0feb63014e97aaae3037f2cf48712d51ae61df7fd6075057"}, + {file = "distlib-0.3.7.tar.gz", hash = "sha256:9dafe54b34a028eafd95039d5e5d4851a13734540f1331060d31c9916e7147a8"}, +] + +[[package]] +name = "dnspython" +version = "2.4.2" +description = "DNS toolkit" +category = "main" +optional = false +python-versions = ">=3.8,<4.0" +files = [ + {file = "dnspython-2.4.2-py3-none-any.whl", hash = "sha256:57c6fbaaeaaf39c891292012060beb141791735dbb4004798328fc2c467402d8"}, + {file = "dnspython-2.4.2.tar.gz", hash = "sha256:8dcfae8c7460a2f84b4072e26f1c9f4101ca20c071649cb7c34e8b6a93d58984"}, +] + +[package.extras] +dnssec = ["cryptography (>=2.6,<42.0)"] +doh = ["h2 (>=4.1.0)", "httpcore (>=0.17.3)", "httpx (>=0.24.1)"] +doq = ["aioquic (>=0.9.20)"] +idna = ["idna (>=2.1,<4.0)"] +trio = ["trio (>=0.14,<0.23)"] +wmi = ["wmi (>=1.5.1,<2.0.0)"] + +[[package]] +name = "docopt" +version = "0.6.2" +description = "Pythonic argument parser, that will make you smile" +category = "main" +optional = false +python-versions = "*" +files = [ + {file = "docopt-0.6.2.tar.gz", hash = "sha256:49b3a825280bd66b3aa83585ef59c4a8c82f2c8a522dbe754a8bc8d08c85c491"}, +] + +[[package]] +name = "fastapi" +version = "0.104.1" +description = "FastAPI framework, high performance, easy to learn, fast to code, ready for production" +category = "main" +optional = false +python-versions = ">=3.8" +files = [ + {file = "fastapi-0.104.1-py3-none-any.whl", hash = "sha256:752dc31160cdbd0436bb93bad51560b57e525cbb1d4bbf6f4904ceee75548241"}, + {file = "fastapi-0.104.1.tar.gz", hash = "sha256:e5e4540a7c5e1dcfbbcf5b903c234feddcdcd881f191977a1c5dfd917487e7ae"}, +] + +[package.dependencies] +anyio = ">=3.7.1,<4.0.0" +pydantic = ">=1.7.4,<1.8 || >1.8,<1.8.1 || >1.8.1,<2.0.0 || >2.0.0,<2.0.1 || >2.0.1,<2.1.0 || >2.1.0,<3.0.0" +starlette = ">=0.27.0,<0.28.0" +typing-extensions = ">=4.8.0" + +[package.extras] +all = ["email-validator (>=2.0.0)", "httpx (>=0.23.0)", "itsdangerous (>=1.1.0)", "jinja2 (>=2.11.2)", "orjson (>=3.2.1)", "pydantic-extra-types (>=2.0.0)", "pydantic-settings (>=2.0.0)", "python-multipart (>=0.0.5)", "pyyaml (>=5.3.1)", "ujson (>=4.0.1,!=4.0.2,!=4.1.0,!=4.2.0,!=4.3.0,!=5.0.0,!=5.1.0)", "uvicorn[standard] (>=0.12.0)"] + +[[package]] +name = "filelock" +version = "3.13.1" +description = "A platform independent file lock." +category = "dev" +optional = false +python-versions = ">=3.8" +files = [ + {file = "filelock-3.13.1-py3-none-any.whl", hash = "sha256:57dbda9b35157b05fb3e58ee91448612eb674172fab98ee235ccb0b5bee19a1c"}, + {file = "filelock-3.13.1.tar.gz", hash = "sha256:521f5f56c50f8426f5e03ad3b281b490a87ef15bc6c526f168290f0c7148d44e"}, +] + +[package.extras] +docs = ["furo (>=2023.9.10)", "sphinx (>=7.2.6)", "sphinx-autodoc-typehints (>=1.24)"] +testing = ["covdefaults (>=2.3)", "coverage (>=7.3.2)", "diff-cover (>=8)", "pytest (>=7.4.3)", "pytest-cov (>=4.1)", "pytest-mock (>=3.12)", "pytest-timeout (>=2.2)"] +typing = ["typing-extensions (>=4.8)"] + +[[package]] +name = "flake8" +version = "6.1.0" +description = "the modular source code checker: pep8 pyflakes and co" +category = "dev" +optional = false +python-versions = ">=3.8.1" +files = [ + {file = "flake8-6.1.0-py2.py3-none-any.whl", hash = "sha256:ffdfce58ea94c6580c77888a86506937f9a1a227dfcd15f245d694ae20a6b6e5"}, + {file = "flake8-6.1.0.tar.gz", hash = "sha256:d5b3857f07c030bdb5bf41c7f53799571d75c4491748a3adcd47de929e34cd23"}, +] + +[package.dependencies] +mccabe = ">=0.7.0,<0.8.0" +pycodestyle = ">=2.11.0,<2.12.0" +pyflakes = ">=3.1.0,<3.2.0" + +[[package]] +name = "grpcio" +version = "1.59.3" +description = "HTTP/2-based RPC framework" +category = "main" +optional = false +python-versions = ">=3.7" +files = [ + {file = "grpcio-1.59.3-cp310-cp310-linux_armv7l.whl", hash = "sha256:aca028a6c7806e5b61e5f9f4232432c52856f7fcb98e330b20b6bc95d657bdcc"}, + {file = "grpcio-1.59.3-cp310-cp310-macosx_12_0_universal2.whl", hash = "sha256:19ad26a7967f7999c8960d2b9fe382dae74c55b0c508c613a6c2ba21cddf2354"}, + {file = "grpcio-1.59.3-cp310-cp310-manylinux_2_17_aarch64.whl", hash = "sha256:72b71dad2a3d1650e69ad42a5c4edbc59ee017f08c32c95694172bc501def23c"}, + {file = "grpcio-1.59.3-cp310-cp310-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c0f0a11d82d0253656cc42e04b6a149521e02e755fe2e4edd21123de610fd1d4"}, + {file = "grpcio-1.59.3-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:60cddafb70f9a2c81ba251b53b4007e07cca7389e704f86266e22c4bffd8bf1d"}, + {file = "grpcio-1.59.3-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:6c75a1fa0e677c1d2b6d4196ad395a5c381dfb8385f07ed034ef667cdcdbcc25"}, + {file = "grpcio-1.59.3-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:e1d8e01438d5964a11167eec1edb5f85ed8e475648f36c834ed5db4ffba24ac8"}, + {file = "grpcio-1.59.3-cp310-cp310-win32.whl", hash = "sha256:c4b0076f0bf29ee62335b055a9599f52000b7941f577daa001c7ef961a1fbeab"}, + {file = "grpcio-1.59.3-cp310-cp310-win_amd64.whl", hash = "sha256:b1f00a3e6e0c3dccccffb5579fc76ebfe4eb40405ba308505b41ef92f747746a"}, + {file = "grpcio-1.59.3-cp311-cp311-linux_armv7l.whl", hash = "sha256:3996aaa21231451161dc29df6a43fcaa8b332042b6150482c119a678d007dd86"}, + {file = "grpcio-1.59.3-cp311-cp311-macosx_10_10_universal2.whl", hash = "sha256:cb4e9cbd9b7388fcb06412da9f188c7803742d06d6f626304eb838d1707ec7e3"}, + {file = "grpcio-1.59.3-cp311-cp311-manylinux_2_17_aarch64.whl", hash = "sha256:8022ca303d6c694a0d7acfb2b472add920217618d3a99eb4b14edc7c6a7e8fcf"}, + {file = "grpcio-1.59.3-cp311-cp311-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b36683fad5664283755a7f4e2e804e243633634e93cd798a46247b8e54e3cb0d"}, + {file = "grpcio-1.59.3-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8239b853226e4824e769517e1b5232e7c4dda3815b200534500338960fcc6118"}, + {file = "grpcio-1.59.3-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:0511af8653fbda489ff11d542a08505d56023e63cafbda60e6e00d4e0bae86ea"}, + {file = "grpcio-1.59.3-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:e78dc982bda74cef2ddfce1c91d29b96864c4c680c634e279ed204d51e227473"}, + {file = "grpcio-1.59.3-cp311-cp311-win32.whl", hash = "sha256:6a5c3a96405966c023e139c3bcccb2c7c776a6f256ac6d70f8558c9041bdccc3"}, + {file = "grpcio-1.59.3-cp311-cp311-win_amd64.whl", hash = "sha256:ed26826ee423b11477297b187371cdf4fa1eca874eb1156422ef3c9a60590dd9"}, + {file = "grpcio-1.59.3-cp312-cp312-linux_armv7l.whl", hash = "sha256:45dddc5cb5227d30fa43652d8872dc87f086d81ab4b500be99413bad0ae198d7"}, + {file = "grpcio-1.59.3-cp312-cp312-macosx_10_10_universal2.whl", hash = "sha256:1736496d74682e53dd0907fd515f2694d8e6a96c9a359b4080b2504bf2b2d91b"}, + {file = "grpcio-1.59.3-cp312-cp312-manylinux_2_17_aarch64.whl", hash = "sha256:ddbd1a16138e52e66229047624de364f88a948a4d92ba20e4e25ad7d22eef025"}, + {file = "grpcio-1.59.3-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:fcfa56f8d031ffda902c258c84c4b88707f3a4be4827b4e3ab8ec7c24676320d"}, + {file = "grpcio-1.59.3-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f2eb8f0c7c0c62f7a547ad7a91ba627a5aa32a5ae8d930783f7ee61680d7eb8d"}, + {file = "grpcio-1.59.3-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:8d993399cc65e3a34f8fd48dd9ad7a376734564b822e0160dd18b3d00c1a33f9"}, + {file = "grpcio-1.59.3-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:c0bd141f4f41907eb90bda74d969c3cb21c1c62779419782a5b3f5e4b5835718"}, + {file = "grpcio-1.59.3-cp312-cp312-win32.whl", hash = "sha256:33b8fd65d4e97efa62baec6171ce51f9cf68f3a8ba9f866f4abc9d62b5c97b79"}, + {file = "grpcio-1.59.3-cp312-cp312-win_amd64.whl", hash = "sha256:0e735ed002f50d4f3cb9ecfe8ac82403f5d842d274c92d99db64cfc998515e07"}, + {file = "grpcio-1.59.3-cp37-cp37m-linux_armv7l.whl", hash = "sha256:ea40ce4404e7cca0724c91a7404da410f0144148fdd58402a5942971e3469b94"}, + {file = "grpcio-1.59.3-cp37-cp37m-macosx_10_10_universal2.whl", hash = "sha256:83113bcc393477b6f7342b9f48e8a054330c895205517edc66789ceea0796b53"}, + {file = "grpcio-1.59.3-cp37-cp37m-manylinux_2_17_aarch64.whl", hash = "sha256:73afbac602b8f1212a50088193601f869b5073efa9855b3e51aaaec97848fc8a"}, + {file = "grpcio-1.59.3-cp37-cp37m-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:575d61de1950b0b0699917b686b1ca108690702fcc2df127b8c9c9320f93e069"}, + {file = "grpcio-1.59.3-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8cd76057b5c9a4d68814610ef9226925f94c1231bbe533fdf96f6181f7d2ff9e"}, + {file = "grpcio-1.59.3-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:95d6fd804c81efe4879e38bfd84d2b26e339a0a9b797e7615e884ef4686eb47b"}, + {file = "grpcio-1.59.3-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:0d42048b8a3286ea4134faddf1f9a59cf98192b94aaa10d910a25613c5eb5bfb"}, + {file = "grpcio-1.59.3-cp37-cp37m-win_amd64.whl", hash = "sha256:4619fea15c64bcdd9d447cdbdde40e3d5f1da3a2e8ae84103d94a9c1df210d7e"}, + {file = "grpcio-1.59.3-cp38-cp38-linux_armv7l.whl", hash = "sha256:95b5506e70284ac03b2005dd9ffcb6708c9ae660669376f0192a710687a22556"}, + {file = "grpcio-1.59.3-cp38-cp38-macosx_10_10_universal2.whl", hash = "sha256:9e17660947660ccfce56c7869032910c179a5328a77b73b37305cd1ee9301c2e"}, + {file = "grpcio-1.59.3-cp38-cp38-manylinux_2_17_aarch64.whl", hash = "sha256:00912ce19914d038851be5cd380d94a03f9d195643c28e3ad03d355cc02ce7e8"}, + {file = "grpcio-1.59.3-cp38-cp38-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e58b3cadaa3c90f1efca26ba33e0d408b35b497307027d3d707e4bcd8de862a6"}, + {file = "grpcio-1.59.3-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d787ecadea865bdf78f6679f6f5bf4b984f18f659257ba612979df97a298b3c3"}, + {file = "grpcio-1.59.3-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:0814942ba1bba269db4e760a34388640c601dece525c6a01f3b4ff030cc0db69"}, + {file = "grpcio-1.59.3-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:fb111aa99d3180c361a35b5ae1e2c63750220c584a1344229abc139d5c891881"}, + {file = "grpcio-1.59.3-cp38-cp38-win32.whl", hash = "sha256:eb8ba504c726befe40a356ecbe63c6c3c64c9a439b3164f5a718ec53c9874da0"}, + {file = "grpcio-1.59.3-cp38-cp38-win_amd64.whl", hash = "sha256:cdbc6b32fadab9bebc6f49d3e7ec4c70983c71e965497adab7f87de218e84391"}, + {file = "grpcio-1.59.3-cp39-cp39-linux_armv7l.whl", hash = "sha256:c82ca1e4be24a98a253d6dbaa216542e4163f33f38163fc77964b0f0d255b552"}, + {file = "grpcio-1.59.3-cp39-cp39-macosx_10_10_universal2.whl", hash = "sha256:36636babfda14f9e9687f28d5b66d349cf88c1301154dc71c6513de2b6c88c59"}, + {file = "grpcio-1.59.3-cp39-cp39-manylinux_2_17_aarch64.whl", hash = "sha256:5f9b2e591da751ac7fdd316cc25afafb7a626dededa9b414f90faad7f3ccebdb"}, + {file = "grpcio-1.59.3-cp39-cp39-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a93a82876a4926bf451db82ceb725bd87f42292bacc94586045261f501a86994"}, + {file = "grpcio-1.59.3-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ce31fa0bfdd1f2bb15b657c16105c8652186eab304eb512e6ae3b99b2fdd7d13"}, + {file = "grpcio-1.59.3-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:16da0e40573962dab6cba16bec31f25a4f468e6d05b658e589090fe103b03e3d"}, + {file = "grpcio-1.59.3-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:d1d1a17372fd425addd5812049fa7374008ffe689585f27f802d0935522cf4b7"}, + {file = "grpcio-1.59.3-cp39-cp39-win32.whl", hash = "sha256:52cc38a7241b5f7b4a91aaf9000fdd38e26bb00d5e8a71665ce40cfcee716281"}, + {file = "grpcio-1.59.3-cp39-cp39-win_amd64.whl", hash = "sha256:b491e5bbcad3020a96842040421e508780cade35baba30f402df9d321d1c423e"}, + {file = "grpcio-1.59.3.tar.gz", hash = "sha256:7800f99568a74a06ebdccd419dd1b6e639b477dcaf6da77ea702f8fb14ce5f80"}, +] + +[package.extras] +protobuf = ["grpcio-tools (>=1.59.3)"] + +[[package]] +name = "gunicorn" +version = "21.2.0" +description = "WSGI HTTP Server for UNIX" +category = "main" +optional = false +python-versions = ">=3.5" +files = [ + {file = "gunicorn-21.2.0-py3-none-any.whl", hash = "sha256:3213aa5e8c24949e792bcacfc176fef362e7aac80b76c56f6b5122bf350722f0"}, + {file = "gunicorn-21.2.0.tar.gz", hash = "sha256:88ec8bff1d634f98e61b9f65bc4bf3cd918a90806c6f5c48bc5603849ec81033"}, +] + +[package.dependencies] +packaging = "*" + +[package.extras] +eventlet = ["eventlet (>=0.24.1)"] +gevent = ["gevent (>=1.4.0)"] +setproctitle = ["setproctitle"] +tornado = ["tornado (>=0.2)"] + +[[package]] +name = "h11" +version = "0.14.0" +description = "A pure-Python, bring-your-own-I/O implementation of HTTP/1.1" +category = "main" +optional = false +python-versions = ">=3.7" +files = [ + {file = "h11-0.14.0-py3-none-any.whl", hash = "sha256:e3fe4ac4b851c468cc8363d500db52c2ead036020723024a109d37346efaa761"}, + {file = "h11-0.14.0.tar.gz", hash = "sha256:8f19fbbe99e72420ff35c00b27a34cb9937e902a8b810e2c88300c6f0a3b699d"}, +] + +[[package]] +name = "httptools" +version = "0.6.1" +description = "A collection of framework independent HTTP protocol utils." +category = "main" +optional = false +python-versions = ">=3.8.0" +files = [ + {file = "httptools-0.6.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:d2f6c3c4cb1948d912538217838f6e9960bc4a521d7f9b323b3da579cd14532f"}, + {file = "httptools-0.6.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:00d5d4b68a717765b1fabfd9ca755bd12bf44105eeb806c03d1962acd9b8e563"}, + {file = "httptools-0.6.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:639dc4f381a870c9ec860ce5c45921db50205a37cc3334e756269736ff0aac58"}, + {file = "httptools-0.6.1-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e57997ac7fb7ee43140cc03664de5f268813a481dff6245e0075925adc6aa185"}, + {file = "httptools-0.6.1-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:0ac5a0ae3d9f4fe004318d64b8a854edd85ab76cffbf7ef5e32920faef62f142"}, + {file = "httptools-0.6.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:3f30d3ce413088a98b9db71c60a6ada2001a08945cb42dd65a9a9fe228627658"}, + {file = "httptools-0.6.1-cp310-cp310-win_amd64.whl", hash = "sha256:1ed99a373e327f0107cb513b61820102ee4f3675656a37a50083eda05dc9541b"}, + {file = "httptools-0.6.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:7a7ea483c1a4485c71cb5f38be9db078f8b0e8b4c4dc0210f531cdd2ddac1ef1"}, + {file = "httptools-0.6.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:85ed077c995e942b6f1b07583e4eb0a8d324d418954fc6af913d36db7c05a5a0"}, + {file = "httptools-0.6.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8b0bb634338334385351a1600a73e558ce619af390c2b38386206ac6a27fecfc"}, + {file = "httptools-0.6.1-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7d9ceb2c957320def533671fc9c715a80c47025139c8d1f3797477decbc6edd2"}, + {file = "httptools-0.6.1-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:4f0f8271c0a4db459f9dc807acd0eadd4839934a4b9b892f6f160e94da309837"}, + {file = "httptools-0.6.1-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:6a4f5ccead6d18ec072ac0b84420e95d27c1cdf5c9f1bc8fbd8daf86bd94f43d"}, + {file = "httptools-0.6.1-cp311-cp311-win_amd64.whl", hash = "sha256:5cceac09f164bcba55c0500a18fe3c47df29b62353198e4f37bbcc5d591172c3"}, + {file = "httptools-0.6.1-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:75c8022dca7935cba14741a42744eee13ba05db00b27a4b940f0d646bd4d56d0"}, + {file = "httptools-0.6.1-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:48ed8129cd9a0d62cf4d1575fcf90fb37e3ff7d5654d3a5814eb3d55f36478c2"}, + {file = "httptools-0.6.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6f58e335a1402fb5a650e271e8c2d03cfa7cea46ae124649346d17bd30d59c90"}, + {file = "httptools-0.6.1-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:93ad80d7176aa5788902f207a4e79885f0576134695dfb0fefc15b7a4648d503"}, + {file = "httptools-0.6.1-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:9bb68d3a085c2174c2477eb3ffe84ae9fb4fde8792edb7bcd09a1d8467e30a84"}, + {file = "httptools-0.6.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:b512aa728bc02354e5ac086ce76c3ce635b62f5fbc32ab7082b5e582d27867bb"}, + {file = "httptools-0.6.1-cp312-cp312-win_amd64.whl", hash = "sha256:97662ce7fb196c785344d00d638fc9ad69e18ee4bfb4000b35a52efe5adcc949"}, + {file = "httptools-0.6.1-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:8e216a038d2d52ea13fdd9b9c9c7459fb80d78302b257828285eca1c773b99b3"}, + {file = "httptools-0.6.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:3e802e0b2378ade99cd666b5bffb8b2a7cc8f3d28988685dc300469ea8dd86cb"}, + {file = "httptools-0.6.1-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4bd3e488b447046e386a30f07af05f9b38d3d368d1f7b4d8f7e10af85393db97"}, + {file = "httptools-0.6.1-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fe467eb086d80217b7584e61313ebadc8d187a4d95bb62031b7bab4b205c3ba3"}, + {file = "httptools-0.6.1-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:3c3b214ce057c54675b00108ac42bacf2ab8f85c58e3f324a4e963bbc46424f4"}, + {file = "httptools-0.6.1-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:8ae5b97f690badd2ca27cbf668494ee1b6d34cf1c464271ef7bfa9ca6b83ffaf"}, + {file = "httptools-0.6.1-cp38-cp38-win_amd64.whl", hash = "sha256:405784577ba6540fa7d6ff49e37daf104e04f4b4ff2d1ac0469eaa6a20fde084"}, + {file = "httptools-0.6.1-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:95fb92dd3649f9cb139e9c56604cc2d7c7bf0fc2e7c8d7fbd58f96e35eddd2a3"}, + {file = "httptools-0.6.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:dcbab042cc3ef272adc11220517278519adf8f53fd3056d0e68f0a6f891ba94e"}, + {file = "httptools-0.6.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0cf2372e98406efb42e93bfe10f2948e467edfd792b015f1b4ecd897903d3e8d"}, + {file = "httptools-0.6.1-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:678fcbae74477a17d103b7cae78b74800d795d702083867ce160fc202104d0da"}, + {file = "httptools-0.6.1-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:e0b281cf5a125c35f7f6722b65d8542d2e57331be573e9e88bc8b0115c4a7a81"}, + {file = "httptools-0.6.1-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:95658c342529bba4e1d3d2b1a874db16c7cca435e8827422154c9da76ac4e13a"}, + {file = "httptools-0.6.1-cp39-cp39-win_amd64.whl", hash = "sha256:7ebaec1bf683e4bf5e9fbb49b8cc36da482033596a415b3e4ebab5a4c0d7ec5e"}, + {file = "httptools-0.6.1.tar.gz", hash = "sha256:c6e26c30455600b95d94b1b836085138e82f177351454ee841c148f93a9bad5a"}, +] + +[package.extras] +test = ["Cython (>=0.29.24,<0.30.0)"] + +[[package]] +name = "identify" +version = "2.5.32" +description = "File identification library for Python" +category = "dev" +optional = false +python-versions = ">=3.8" +files = [ + {file = "identify-2.5.32-py2.py3-none-any.whl", hash = "sha256:0b7656ef6cba81664b783352c73f8c24b39cf82f926f78f4550eda928e5e0545"}, + {file = "identify-2.5.32.tar.gz", hash = "sha256:5d9979348ec1a21c768ae07e0a652924538e8bce67313a73cb0f681cf08ba407"}, +] + +[package.extras] +license = ["ukkonen"] + +[[package]] +name = "idna" +version = "3.4" +description = "Internationalized Domain Names in Applications (IDNA)" +category = "main" +optional = false +python-versions = ">=3.5" +files = [ + {file = "idna-3.4-py3-none-any.whl", hash = "sha256:90b77e79eaa3eba6de819a0c442c0b4ceefc341a7a2ab77d7562bf49f425c5c2"}, + {file = "idna-3.4.tar.gz", hash = "sha256:814f528e8dead7d329833b91c5faa87d60bf71824cd12a7530b5526063d02cb4"}, +] + +[[package]] +name = "isort" +version = "5.12.0" +description = "A Python utility / library to sort Python imports." +category = "dev" +optional = false +python-versions = ">=3.8.0" +files = [ + {file = "isort-5.12.0-py3-none-any.whl", hash = "sha256:f84c2818376e66cf843d497486ea8fed8700b340f308f076c6fb1229dff318b6"}, + {file = "isort-5.12.0.tar.gz", hash = "sha256:8bef7dde241278824a6d83f44a544709b065191b95b6e50894bdc722fcba0504"}, +] + +[package.extras] +colors = ["colorama (>=0.4.3)"] +pipfile-deprecated-finder = ["pip-shims (>=0.5.2)", "pipreqs", "requirementslib"] +plugins = ["setuptools"] +requirements-deprecated-finder = ["pip-api", "pipreqs"] + +[[package]] +name = "mccabe" +version = "0.7.0" +description = "McCabe checker, plugin for flake8" +category = "dev" +optional = false +python-versions = ">=3.6" +files = [ + {file = "mccabe-0.7.0-py2.py3-none-any.whl", hash = "sha256:6c2d30ab6be0e4a46919781807b4f0d834ebdd6c6e3dca0bda5a15f863427b6e"}, + {file = "mccabe-0.7.0.tar.gz", hash = "sha256:348e0240c33b60bbdf4e523192ef919f28cb2c3d7d5c7794f74009290f236325"}, +] + +[[package]] +name = "motor" +version = "3.3.2" +description = "Non-blocking MongoDB driver for Tornado or asyncio" +category = "main" +optional = false +python-versions = ">=3.7" +files = [ + {file = "motor-3.3.2-py3-none-any.whl", hash = "sha256:6fe7e6f0c4f430b9e030b9d22549b732f7c2226af3ab71ecc309e4a1b7d19953"}, + {file = "motor-3.3.2.tar.gz", hash = "sha256:d2fc38de15f1c8058f389c1a44a4d4105c0405c48c061cd492a654496f7bc26a"}, +] + +[package.dependencies] +pymongo = ">=4.5,<5" + +[package.extras] +aws = ["pymongo[aws] (>=4.5,<5)"] +encryption = ["pymongo[encryption] (>=4.5,<5)"] +gssapi = ["pymongo[gssapi] (>=4.5,<5)"] +ocsp = ["pymongo[ocsp] (>=4.5,<5)"] +snappy = ["pymongo[snappy] (>=4.5,<5)"] +srv = ["pymongo[srv] (>=4.5,<5)"] +test = ["aiohttp (<3.8.6)", "mockupdb", "motor[encryption]", "pytest (>=7)", "tornado (>=5)"] +zstd = ["pymongo[zstd] (>=4.5,<5)"] + +[[package]] +name = "mypy-extensions" +version = "1.0.0" +description = "Type system extensions for programs checked with the mypy type checker." +category = "dev" +optional = false +python-versions = ">=3.5" +files = [ + {file = "mypy_extensions-1.0.0-py3-none-any.whl", hash = "sha256:4392f6c0eb8a5668a69e23d168ffa70f0be9ccfd32b5cc2d26a34ae5b844552d"}, + {file = "mypy_extensions-1.0.0.tar.gz", hash = "sha256:75dbf8955dc00442a438fc4d0666508a9a97b6bd41aa2f0ffe9d2f2725af0782"}, +] + +[[package]] +name = "nodeenv" +version = "1.8.0" +description = "Node.js virtual environment builder" +category = "dev" +optional = false +python-versions = ">=2.7,!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*" +files = [ + {file = "nodeenv-1.8.0-py2.py3-none-any.whl", hash = "sha256:df865724bb3c3adc86b3876fa209771517b0cfe596beff01a92700e0e8be4cec"}, + {file = "nodeenv-1.8.0.tar.gz", hash = "sha256:d51e0c37e64fbf47d017feac3145cdbb58836d7eee8c6f6d3b6880c5456227d2"}, +] + +[package.dependencies] +setuptools = "*" + +[[package]] +name = "packaging" +version = "23.2" +description = "Core utilities for Python packages" +category = "main" +optional = false +python-versions = ">=3.7" +files = [ + {file = "packaging-23.2-py3-none-any.whl", hash = "sha256:8c491190033a9af7e1d931d0b5dacc2ef47509b34dd0de67ed209b5203fc88c7"}, + {file = "packaging-23.2.tar.gz", hash = "sha256:048fb0e9405036518eaaf48a55953c750c11e1a1b68e0dd1a9d62ed0c092cfc5"}, +] + +[[package]] +name = "pathspec" +version = "0.11.2" +description = "Utility library for gitignore style pattern matching of file paths." +category = "dev" +optional = false +python-versions = ">=3.7" +files = [ + {file = "pathspec-0.11.2-py3-none-any.whl", hash = "sha256:1d6ed233af05e679efb96b1851550ea95bbb64b7c490b0f5aa52996c11e92a20"}, + {file = "pathspec-0.11.2.tar.gz", hash = "sha256:e0d8d0ac2f12da61956eb2306b69f9469b42f4deb0f3cb6ed47b9cce9996ced3"}, +] + +[[package]] +name = "platformdirs" +version = "4.0.0" +description = "A small Python package for determining appropriate platform-specific dirs, e.g. a \"user data dir\"." +category = "dev" +optional = false +python-versions = ">=3.7" +files = [ + {file = "platformdirs-4.0.0-py3-none-any.whl", hash = "sha256:118c954d7e949b35437270383a3f2531e99dd93cf7ce4dc8340d3356d30f173b"}, + {file = "platformdirs-4.0.0.tar.gz", hash = "sha256:cb633b2bcf10c51af60beb0ab06d2f1d69064b43abf4c185ca6b28865f3f9731"}, +] + +[package.extras] +docs = ["furo (>=2023.7.26)", "proselint (>=0.13)", "sphinx (>=7.1.1)", "sphinx-autodoc-typehints (>=1.24)"] +test = ["appdirs (==1.4.4)", "covdefaults (>=2.3)", "pytest (>=7.4)", "pytest-cov (>=4.1)", "pytest-mock (>=3.11.1)"] + +[[package]] +name = "pre-commit" +version = "3.5.0" +description = "A framework for managing and maintaining multi-language pre-commit hooks." +category = "dev" +optional = false +python-versions = ">=3.8" +files = [ + {file = "pre_commit-3.5.0-py2.py3-none-any.whl", hash = "sha256:841dc9aef25daba9a0238cd27984041fa0467b4199fc4852e27950664919f660"}, + {file = "pre_commit-3.5.0.tar.gz", hash = "sha256:5804465c675b659b0862f07907f96295d490822a450c4c40e747d0b1c6ebcb32"}, +] + +[package.dependencies] +cfgv = ">=2.0.0" +identify = ">=1.0.0" +nodeenv = ">=0.11.1" +pyyaml = ">=5.1" +virtualenv = ">=20.10.0" + +[[package]] +name = "pycodestyle" +version = "2.11.1" +description = "Python style guide checker" +category = "dev" +optional = false +python-versions = ">=3.8" +files = [ + {file = "pycodestyle-2.11.1-py2.py3-none-any.whl", hash = "sha256:44fe31000b2d866f2e41841b18528a505fbd7fef9017b04eff4e2648a0fadc67"}, + {file = "pycodestyle-2.11.1.tar.gz", hash = "sha256:41ba0e7afc9752dfb53ced5489e89f8186be00e599e712660695b7a75ff2663f"}, +] + +[[package]] +name = "pydantic" +version = "2.5.2" +description = "Data validation using Python type hints" +category = "main" +optional = false +python-versions = ">=3.7" +files = [ + {file = "pydantic-2.5.2-py3-none-any.whl", hash = "sha256:80c50fb8e3dcecfddae1adbcc00ec5822918490c99ab31f6cf6140ca1c1429f0"}, + {file = "pydantic-2.5.2.tar.gz", hash = "sha256:ff177ba64c6faf73d7afa2e8cad38fd456c0dbe01c9954e71038001cd15a6edd"}, +] + +[package.dependencies] +annotated-types = ">=0.4.0" +pydantic-core = "2.14.5" +typing-extensions = ">=4.6.1" + +[package.extras] +email = ["email-validator (>=2.0.0)"] + +[[package]] +name = "pydantic-core" +version = "2.14.5" +description = "" +category = "main" +optional = false +python-versions = ">=3.7" +files = [ + {file = "pydantic_core-2.14.5-cp310-cp310-macosx_10_7_x86_64.whl", hash = "sha256:7e88f5696153dc516ba6e79f82cc4747e87027205f0e02390c21f7cb3bd8abfd"}, + {file = "pydantic_core-2.14.5-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:4641e8ad4efb697f38a9b64ca0523b557c7931c5f84e0fd377a9a3b05121f0de"}, + {file = "pydantic_core-2.14.5-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:774de879d212db5ce02dfbf5b0da9a0ea386aeba12b0b95674a4ce0593df3d07"}, + {file = "pydantic_core-2.14.5-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ebb4e035e28f49b6f1a7032920bb9a0c064aedbbabe52c543343d39341a5b2a3"}, + {file = "pydantic_core-2.14.5-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b53e9ad053cd064f7e473a5f29b37fc4cc9dc6d35f341e6afc0155ea257fc911"}, + {file = "pydantic_core-2.14.5-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8aa1768c151cf562a9992462239dfc356b3d1037cc5a3ac829bb7f3bda7cc1f9"}, + {file = "pydantic_core-2.14.5-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:eac5c82fc632c599f4639a5886f96867ffced74458c7db61bc9a66ccb8ee3113"}, + {file = "pydantic_core-2.14.5-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d2ae91f50ccc5810b2f1b6b858257c9ad2e08da70bf890dee02de1775a387c66"}, + {file = "pydantic_core-2.14.5-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:6b9ff467ffbab9110e80e8c8de3bcfce8e8b0fd5661ac44a09ae5901668ba997"}, + {file = "pydantic_core-2.14.5-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:61ea96a78378e3bd5a0be99b0e5ed00057b71f66115f5404d0dae4819f495093"}, + {file = "pydantic_core-2.14.5-cp310-none-win32.whl", hash = "sha256:bb4c2eda937a5e74c38a41b33d8c77220380a388d689bcdb9b187cf6224c9720"}, + {file = "pydantic_core-2.14.5-cp310-none-win_amd64.whl", hash = "sha256:b7851992faf25eac90bfcb7bfd19e1f5ffa00afd57daec8a0042e63c74a4551b"}, + {file = "pydantic_core-2.14.5-cp311-cp311-macosx_10_7_x86_64.whl", hash = "sha256:4e40f2bd0d57dac3feb3a3aed50f17d83436c9e6b09b16af271b6230a2915459"}, + {file = "pydantic_core-2.14.5-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:ab1cdb0f14dc161ebc268c09db04d2c9e6f70027f3b42446fa11c153521c0e88"}, + {file = "pydantic_core-2.14.5-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:aae7ea3a1c5bb40c93cad361b3e869b180ac174656120c42b9fadebf685d121b"}, + {file = "pydantic_core-2.14.5-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:60b7607753ba62cf0739177913b858140f11b8af72f22860c28eabb2f0a61937"}, + {file = "pydantic_core-2.14.5-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:2248485b0322c75aee7565d95ad0e16f1c67403a470d02f94da7344184be770f"}, + {file = "pydantic_core-2.14.5-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:823fcc638f67035137a5cd3f1584a4542d35a951c3cc68c6ead1df7dac825c26"}, + {file = "pydantic_core-2.14.5-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:96581cfefa9123accc465a5fd0cc833ac4d75d55cc30b633b402e00e7ced00a6"}, + {file = "pydantic_core-2.14.5-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:a33324437018bf6ba1bb0f921788788641439e0ed654b233285b9c69704c27b4"}, + {file = "pydantic_core-2.14.5-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:9bd18fee0923ca10f9a3ff67d4851c9d3e22b7bc63d1eddc12f439f436f2aada"}, + {file = "pydantic_core-2.14.5-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:853a2295c00f1d4429db4c0fb9475958543ee80cfd310814b5c0ef502de24dda"}, + {file = "pydantic_core-2.14.5-cp311-none-win32.whl", hash = "sha256:cb774298da62aea5c80a89bd58c40205ab4c2abf4834453b5de207d59d2e1651"}, + {file = "pydantic_core-2.14.5-cp311-none-win_amd64.whl", hash = "sha256:e87fc540c6cac7f29ede02e0f989d4233f88ad439c5cdee56f693cc9c1c78077"}, + {file = "pydantic_core-2.14.5-cp311-none-win_arm64.whl", hash = "sha256:57d52fa717ff445cb0a5ab5237db502e6be50809b43a596fb569630c665abddf"}, + {file = "pydantic_core-2.14.5-cp312-cp312-macosx_10_7_x86_64.whl", hash = "sha256:e60f112ac88db9261ad3a52032ea46388378034f3279c643499edb982536a093"}, + {file = "pydantic_core-2.14.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:6e227c40c02fd873c2a73a98c1280c10315cbebe26734c196ef4514776120aeb"}, + {file = "pydantic_core-2.14.5-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f0cbc7fff06a90bbd875cc201f94ef0ee3929dfbd5c55a06674b60857b8b85ed"}, + {file = "pydantic_core-2.14.5-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:103ef8d5b58596a731b690112819501ba1db7a36f4ee99f7892c40da02c3e189"}, + {file = "pydantic_core-2.14.5-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c949f04ecad823f81b1ba94e7d189d9dfb81edbb94ed3f8acfce41e682e48cef"}, + {file = "pydantic_core-2.14.5-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c1452a1acdf914d194159439eb21e56b89aa903f2e1c65c60b9d874f9b950e5d"}, + {file = "pydantic_core-2.14.5-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:cb4679d4c2b089e5ef89756bc73e1926745e995d76e11925e3e96a76d5fa51fc"}, + {file = "pydantic_core-2.14.5-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:cf9d3fe53b1ee360e2421be95e62ca9b3296bf3f2fb2d3b83ca49ad3f925835e"}, + {file = "pydantic_core-2.14.5-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:70f4b4851dbb500129681d04cc955be2a90b2248d69273a787dda120d5cf1f69"}, + {file = "pydantic_core-2.14.5-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:59986de5710ad9613ff61dd9b02bdd2f615f1a7052304b79cc8fa2eb4e336d2d"}, + {file = "pydantic_core-2.14.5-cp312-none-win32.whl", hash = "sha256:699156034181e2ce106c89ddb4b6504c30db8caa86e0c30de47b3e0654543260"}, + {file = "pydantic_core-2.14.5-cp312-none-win_amd64.whl", hash = "sha256:5baab5455c7a538ac7e8bf1feec4278a66436197592a9bed538160a2e7d11e36"}, + {file = "pydantic_core-2.14.5-cp312-none-win_arm64.whl", hash = "sha256:e47e9a08bcc04d20975b6434cc50bf82665fbc751bcce739d04a3120428f3e27"}, + {file = "pydantic_core-2.14.5-cp37-cp37m-macosx_10_7_x86_64.whl", hash = "sha256:af36f36538418f3806048f3b242a1777e2540ff9efaa667c27da63d2749dbce0"}, + {file = "pydantic_core-2.14.5-cp37-cp37m-macosx_11_0_arm64.whl", hash = "sha256:45e95333b8418ded64745f14574aa9bfc212cb4fbeed7a687b0c6e53b5e188cd"}, + {file = "pydantic_core-2.14.5-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4e47a76848f92529879ecfc417ff88a2806438f57be4a6a8bf2961e8f9ca9ec7"}, + {file = "pydantic_core-2.14.5-cp37-cp37m-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:d81e6987b27bc7d101c8597e1cd2bcaa2fee5e8e0f356735c7ed34368c471550"}, + {file = "pydantic_core-2.14.5-cp37-cp37m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:34708cc82c330e303f4ce87758828ef6e457681b58ce0e921b6e97937dd1e2a3"}, + {file = "pydantic_core-2.14.5-cp37-cp37m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:652c1988019752138b974c28f43751528116bcceadad85f33a258869e641d753"}, + {file = "pydantic_core-2.14.5-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6e4d090e73e0725b2904fdbdd8d73b8802ddd691ef9254577b708d413bf3006e"}, + {file = "pydantic_core-2.14.5-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:5c7d5b5005f177764e96bd584d7bf28d6e26e96f2a541fdddb934c486e36fd59"}, + {file = "pydantic_core-2.14.5-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:a71891847f0a73b1b9eb86d089baee301477abef45f7eaf303495cd1473613e4"}, + {file = "pydantic_core-2.14.5-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:a717aef6971208f0851a2420b075338e33083111d92041157bbe0e2713b37325"}, + {file = "pydantic_core-2.14.5-cp37-none-win32.whl", hash = "sha256:de790a3b5aa2124b8b78ae5faa033937a72da8efe74b9231698b5a1dd9be3405"}, + {file = "pydantic_core-2.14.5-cp37-none-win_amd64.whl", hash = "sha256:6c327e9cd849b564b234da821236e6bcbe4f359a42ee05050dc79d8ed2a91588"}, + {file = "pydantic_core-2.14.5-cp38-cp38-macosx_10_7_x86_64.whl", hash = "sha256:ef98ca7d5995a82f43ec0ab39c4caf6a9b994cb0b53648ff61716370eadc43cf"}, + {file = "pydantic_core-2.14.5-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:c6eae413494a1c3f89055da7a5515f32e05ebc1a234c27674a6956755fb2236f"}, + {file = "pydantic_core-2.14.5-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:dcf4e6d85614f7a4956c2de5a56531f44efb973d2fe4a444d7251df5d5c4dcfd"}, + {file = "pydantic_core-2.14.5-cp38-cp38-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:6637560562134b0e17de333d18e69e312e0458ee4455bdad12c37100b7cad706"}, + {file = "pydantic_core-2.14.5-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:77fa384d8e118b3077cccfcaf91bf83c31fe4dc850b5e6ee3dc14dc3d61bdba1"}, + {file = "pydantic_core-2.14.5-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:16e29bad40bcf97aac682a58861249ca9dcc57c3f6be22f506501833ddb8939c"}, + {file = "pydantic_core-2.14.5-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:531f4b4252fac6ca476fbe0e6f60f16f5b65d3e6b583bc4d87645e4e5ddde331"}, + {file = "pydantic_core-2.14.5-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:074f3d86f081ce61414d2dc44901f4f83617329c6f3ab49d2bc6c96948b2c26b"}, + {file = "pydantic_core-2.14.5-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:c2adbe22ab4babbca99c75c5d07aaf74f43c3195384ec07ccbd2f9e3bddaecec"}, + {file = "pydantic_core-2.14.5-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:0f6116a558fd06d1b7c2902d1c4cf64a5bd49d67c3540e61eccca93f41418124"}, + {file = "pydantic_core-2.14.5-cp38-none-win32.whl", hash = "sha256:fe0a5a1025eb797752136ac8b4fa21aa891e3d74fd340f864ff982d649691867"}, + {file = "pydantic_core-2.14.5-cp38-none-win_amd64.whl", hash = "sha256:079206491c435b60778cf2b0ee5fd645e61ffd6e70c47806c9ed51fc75af078d"}, + {file = "pydantic_core-2.14.5-cp39-cp39-macosx_10_7_x86_64.whl", hash = "sha256:a6a16f4a527aae4f49c875da3cdc9508ac7eef26e7977952608610104244e1b7"}, + {file = "pydantic_core-2.14.5-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:abf058be9517dc877227ec3223f0300034bd0e9f53aebd63cf4456c8cb1e0863"}, + {file = "pydantic_core-2.14.5-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:49b08aae5013640a3bfa25a8eebbd95638ec3f4b2eaf6ed82cf0c7047133f03b"}, + {file = "pydantic_core-2.14.5-cp39-cp39-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:c2d97e906b4ff36eb464d52a3bc7d720bd6261f64bc4bcdbcd2c557c02081ed2"}, + {file = "pydantic_core-2.14.5-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3128e0bbc8c091ec4375a1828d6118bc20404883169ac95ffa8d983b293611e6"}, + {file = "pydantic_core-2.14.5-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:88e74ab0cdd84ad0614e2750f903bb0d610cc8af2cc17f72c28163acfcf372a4"}, + {file = "pydantic_core-2.14.5-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c339dabd8ee15f8259ee0f202679b6324926e5bc9e9a40bf981ce77c038553db"}, + {file = "pydantic_core-2.14.5-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:3387277f1bf659caf1724e1afe8ee7dbc9952a82d90f858ebb931880216ea955"}, + {file = "pydantic_core-2.14.5-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:ba6b6b3846cfc10fdb4c971980a954e49d447cd215ed5a77ec8190bc93dd7bc5"}, + {file = "pydantic_core-2.14.5-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:ca61d858e4107ce5e1330a74724fe757fc7135190eb5ce5c9d0191729f033209"}, + {file = "pydantic_core-2.14.5-cp39-none-win32.whl", hash = "sha256:ec1e72d6412f7126eb7b2e3bfca42b15e6e389e1bc88ea0069d0cc1742f477c6"}, + {file = "pydantic_core-2.14.5-cp39-none-win_amd64.whl", hash = "sha256:c0b97ec434041827935044bbbe52b03d6018c2897349670ff8fe11ed24d1d4ab"}, + {file = "pydantic_core-2.14.5-pp310-pypy310_pp73-macosx_10_7_x86_64.whl", hash = "sha256:79e0a2cdbdc7af3f4aee3210b1172ab53d7ddb6a2d8c24119b5706e622b346d0"}, + {file = "pydantic_core-2.14.5-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:678265f7b14e138d9a541ddabbe033012a2953315739f8cfa6d754cc8063e8ca"}, + {file = "pydantic_core-2.14.5-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:95b15e855ae44f0c6341ceb74df61b606e11f1087e87dcb7482377374aac6abe"}, + {file = "pydantic_core-2.14.5-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:09b0e985fbaf13e6b06a56d21694d12ebca6ce5414b9211edf6f17738d82b0f8"}, + {file = "pydantic_core-2.14.5-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:3ad873900297bb36e4b6b3f7029d88ff9829ecdc15d5cf20161775ce12306f8a"}, + {file = "pydantic_core-2.14.5-pp310-pypy310_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:2d0ae0d8670164e10accbeb31d5ad45adb71292032d0fdb9079912907f0085f4"}, + {file = "pydantic_core-2.14.5-pp310-pypy310_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:d37f8ec982ead9ba0a22a996129594938138a1503237b87318392a48882d50b7"}, + {file = "pydantic_core-2.14.5-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:35613015f0ba7e14c29ac6c2483a657ec740e5ac5758d993fdd5870b07a61d8b"}, + {file = "pydantic_core-2.14.5-pp37-pypy37_pp73-macosx_10_7_x86_64.whl", hash = "sha256:ab4ea451082e684198636565224bbb179575efc1658c48281b2c866bfd4ddf04"}, + {file = "pydantic_core-2.14.5-pp37-pypy37_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4ce601907e99ea5b4adb807ded3570ea62186b17f88e271569144e8cca4409c7"}, + {file = "pydantic_core-2.14.5-pp37-pypy37_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fb2ed8b3fe4bf4506d6dab3b93b83bbc22237e230cba03866d561c3577517d18"}, + {file = "pydantic_core-2.14.5-pp37-pypy37_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:70f947628e074bb2526ba1b151cee10e4c3b9670af4dbb4d73bc8a89445916b5"}, + {file = "pydantic_core-2.14.5-pp37-pypy37_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:4bc536201426451f06f044dfbf341c09f540b4ebdb9fd8d2c6164d733de5e634"}, + {file = "pydantic_core-2.14.5-pp37-pypy37_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:f4791cf0f8c3104ac668797d8c514afb3431bc3305f5638add0ba1a5a37e0d88"}, + {file = "pydantic_core-2.14.5-pp38-pypy38_pp73-macosx_10_7_x86_64.whl", hash = "sha256:038c9f763e650712b899f983076ce783175397c848da04985658e7628cbe873b"}, + {file = "pydantic_core-2.14.5-pp38-pypy38_pp73-macosx_11_0_arm64.whl", hash = "sha256:27548e16c79702f1e03f5628589c6057c9ae17c95b4c449de3c66b589ead0520"}, + {file = "pydantic_core-2.14.5-pp38-pypy38_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c97bee68898f3f4344eb02fec316db93d9700fb1e6a5b760ffa20d71d9a46ce3"}, + {file = "pydantic_core-2.14.5-pp38-pypy38_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b9b759b77f5337b4ea024f03abc6464c9f35d9718de01cfe6bae9f2e139c397e"}, + {file = "pydantic_core-2.14.5-pp38-pypy38_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:439c9afe34638ace43a49bf72d201e0ffc1a800295bed8420c2a9ca8d5e3dbb3"}, + {file = "pydantic_core-2.14.5-pp38-pypy38_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:ba39688799094c75ea8a16a6b544eb57b5b0f3328697084f3f2790892510d144"}, + {file = "pydantic_core-2.14.5-pp38-pypy38_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:ccd4d5702bb90b84df13bd491be8d900b92016c5a455b7e14630ad7449eb03f8"}, + {file = "pydantic_core-2.14.5-pp38-pypy38_pp73-win_amd64.whl", hash = "sha256:81982d78a45d1e5396819bbb4ece1fadfe5f079335dd28c4ab3427cd95389944"}, + {file = "pydantic_core-2.14.5-pp39-pypy39_pp73-macosx_10_7_x86_64.whl", hash = "sha256:7f8210297b04e53bc3da35db08b7302a6a1f4889c79173af69b72ec9754796b8"}, + {file = "pydantic_core-2.14.5-pp39-pypy39_pp73-macosx_11_0_arm64.whl", hash = "sha256:8c8a8812fe6f43a3a5b054af6ac2d7b8605c7bcab2804a8a7d68b53f3cd86e00"}, + {file = "pydantic_core-2.14.5-pp39-pypy39_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:206ed23aecd67c71daf5c02c3cd19c0501b01ef3cbf7782db9e4e051426b3d0d"}, + {file = "pydantic_core-2.14.5-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c2027d05c8aebe61d898d4cffd774840a9cb82ed356ba47a90d99ad768f39789"}, + {file = "pydantic_core-2.14.5-pp39-pypy39_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:40180930807ce806aa71eda5a5a5447abb6b6a3c0b4b3b1b1962651906484d68"}, + {file = "pydantic_core-2.14.5-pp39-pypy39_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:615a0a4bff11c45eb3c1996ceed5bdaa2f7b432425253a7c2eed33bb86d80abc"}, + {file = "pydantic_core-2.14.5-pp39-pypy39_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:f5e412d717366e0677ef767eac93566582518fe8be923361a5c204c1a62eaafe"}, + {file = "pydantic_core-2.14.5-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:513b07e99c0a267b1d954243845d8a833758a6726a3b5d8948306e3fe14675e3"}, + {file = "pydantic_core-2.14.5.tar.gz", hash = "sha256:6d30226dfc816dd0fdf120cae611dd2215117e4f9b124af8c60ab9093b6e8e71"}, +] + +[package.dependencies] +typing-extensions = ">=4.6.0,<4.7.0 || >4.7.0" + +[[package]] +name = "pyflakes" +version = "3.1.0" +description = "passive checker of Python programs" +category = "dev" +optional = false +python-versions = ">=3.8" +files = [ + {file = "pyflakes-3.1.0-py2.py3-none-any.whl", hash = "sha256:4132f6d49cb4dae6819e5379898f2b8cce3c5f23994194c24b77d5da2e36f774"}, + {file = "pyflakes-3.1.0.tar.gz", hash = "sha256:a0aae034c444db0071aa077972ba4768d40c830d9539fd45bf4cd3f8f6992efc"}, +] + +[[package]] +name = "pyinstrument" +version = "4.6.1" +description = "Call stack profiler for Python. Shows you why your code is slow!" +category = "dev" +optional = false +python-versions = ">=3.7" +files = [ + {file = "pyinstrument-4.6.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:73476e4bc6e467ac1b2c3c0dd1f0b71c9061d4de14626676adfdfbb14aa342b4"}, + {file = "pyinstrument-4.6.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:4d1da8efd974cf9df52ee03edaee2d3875105ddd00de35aa542760f7c612bdf7"}, + {file = "pyinstrument-4.6.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:507be1ee2f2b0c9fba74d622a272640dd6d1b0c9ec3388b2cdeb97ad1e77125f"}, + {file = "pyinstrument-4.6.1-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:95cee6de08eb45754ef4f602ce52b640d1c535d934a6a8733a974daa095def37"}, + {file = "pyinstrument-4.6.1-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c7873e8cec92321251fdf894a72b3c78f4c5c20afdd1fef0baf9042ec843bb04"}, + {file = "pyinstrument-4.6.1-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:a242f6cac40bc83e1f3002b6b53681846dfba007f366971db0bf21e02dbb1903"}, + {file = "pyinstrument-4.6.1-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:97c9660cdb4bd2a43cf4f3ab52cffd22f3ac9a748d913b750178fb34e5e39e64"}, + {file = "pyinstrument-4.6.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:e304cd0723e2b18ada5e63c187abf6d777949454c734f5974d64a0865859f0f4"}, + {file = "pyinstrument-4.6.1-cp310-cp310-win32.whl", hash = "sha256:cee21a2d78187dd8a80f72f5d0f1ddb767b2d9800f8bb4d94b6d11f217c22cdb"}, + {file = "pyinstrument-4.6.1-cp310-cp310-win_amd64.whl", hash = "sha256:2000712f71d693fed2f8a1c1638d37b7919124f367b37976d07128d49f1445eb"}, + {file = "pyinstrument-4.6.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:a366c6f3dfb11f1739bdc1dee75a01c1563ad0bf4047071e5e77598087df457f"}, + {file = "pyinstrument-4.6.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:c6be327be65d934796558aa9cb0f75ce62ebd207d49ad1854610c97b0579ad47"}, + {file = "pyinstrument-4.6.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9e160d9c5d20d3e4ef82269e4e8b246ff09bdf37af5fb8cb8ccca97936d95ad6"}, + {file = "pyinstrument-4.6.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:6ffbf56605ef21c2fcb60de2fa74ff81f417d8be0c5002a407e414d6ef6dee43"}, + {file = "pyinstrument-4.6.1-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c92cc4924596d6e8f30a16182bbe90893b1572d847ae12652f72b34a9a17c24a"}, + {file = "pyinstrument-4.6.1-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:f4b48a94d938cae981f6948d9ec603bab2087b178d2095d042d5a48aabaecaab"}, + {file = "pyinstrument-4.6.1-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:e7a386392275bdef4a1849712dc5b74f0023483fca14ef93d0ca27d453548982"}, + {file = "pyinstrument-4.6.1-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:871b131b83e9b1122f2325061c68ed1e861eebcb568c934d2fb193652f077f77"}, + {file = "pyinstrument-4.6.1-cp311-cp311-win32.whl", hash = "sha256:8d8515156dd91f5652d13b5fcc87e634f8fe1c07b68d1d0840348cdd50bf5ace"}, + {file = "pyinstrument-4.6.1-cp311-cp311-win_amd64.whl", hash = "sha256:fb868fbe089036e9f32525a249f4c78b8dc46967612393f204b8234f439c9cc4"}, + {file = "pyinstrument-4.6.1-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:a18cd234cce4f230f1733807f17a134e64a1f1acabf74a14d27f583cf2b183df"}, + {file = "pyinstrument-4.6.1-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:574cfca69150be4ce4461fb224712fbc0722a49b0dc02fa204d02807adf6b5a0"}, + {file = "pyinstrument-4.6.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2e02cf505e932eb8ccf561b7527550a67ec14fcae1fe0e25319b09c9c166e914"}, + {file = "pyinstrument-4.6.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:832fb2acef9d53701c1ab546564c45fb70a8770c816374f8dd11420d399103c9"}, + {file = "pyinstrument-4.6.1-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:13cb57e9607545623ebe462345b3d0c4caee0125d2d02267043ece8aca8f4ea0"}, + {file = "pyinstrument-4.6.1-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:9be89e7419bcfe8dd6abb0d959d6d9c439c613a4a873514c43d16b48dae697c9"}, + {file = "pyinstrument-4.6.1-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:476785cfbc44e8e1b1ad447398aa3deae81a8df4d37eb2d8bbb0c404eff979cd"}, + {file = "pyinstrument-4.6.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:e9cebd90128a3d2fee36d3ccb665c1b9dce75261061b2046203e45c4a8012d54"}, + {file = "pyinstrument-4.6.1-cp312-cp312-win32.whl", hash = "sha256:1d0b76683df2ad5c40eff73607dc5c13828c92fbca36aff1ddf869a3c5a55fa6"}, + {file = "pyinstrument-4.6.1-cp312-cp312-win_amd64.whl", hash = "sha256:c4b7af1d9d6a523cfbfedebcb69202242d5bd0cb89c4e094cc73d5d6e38279bd"}, + {file = "pyinstrument-4.6.1-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:79ae152f8c6a680a188fb3be5e0f360ac05db5bbf410169a6c40851dfaebcce9"}, + {file = "pyinstrument-4.6.1-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:07cad2745964c174c65aa75f1bf68a4394d1b4d28f33894837cfd315d1e836f0"}, + {file = "pyinstrument-4.6.1-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:cb81f66f7f94045d723069cf317453d42375de9ff3c69089cf6466b078ac1db4"}, + {file = "pyinstrument-4.6.1-cp37-cp37m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0ab30ae75969da99e9a529e21ff497c18fdf958e822753db4ae7ed1e67094040"}, + {file = "pyinstrument-4.6.1-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:f36cb5b644762fb3c86289324bbef17e95f91cd710603ac19444a47f638e8e96"}, + {file = "pyinstrument-4.6.1-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:8b45075d9dbbc977dbc7007fb22bb0054c6990fbe91bf48dd80c0b96c6307ba7"}, + {file = "pyinstrument-4.6.1-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:475ac31477f6302e092463896d6a2055f3e6abcd293bad16ff94fc9185308a88"}, + {file = "pyinstrument-4.6.1-cp37-cp37m-win32.whl", hash = "sha256:29172ab3d8609fdf821c3f2562dc61e14f1a8ff5306607c32ca743582d3a760e"}, + {file = "pyinstrument-4.6.1-cp37-cp37m-win_amd64.whl", hash = "sha256:bd176f297c99035127b264369d2bb97a65255f65f8d4e843836baf55ebb3cee4"}, + {file = "pyinstrument-4.6.1-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:23e9b4526978432e9999021da9a545992cf2ac3df5ee82db7beb6908fc4c978c"}, + {file = "pyinstrument-4.6.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:2dbcaccc9f456ef95557ec501caeb292119c24446d768cb4fb43578b0f3d572c"}, + {file = "pyinstrument-4.6.1-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2097f63c66c2bc9678c826b9ff0c25acde3ed455590d9dcac21220673fe74fbf"}, + {file = "pyinstrument-4.6.1-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:205ac2e76bd65d61b9611a9ce03d5f6393e34ec5b41dd38808f25d54e6b3e067"}, + {file = "pyinstrument-4.6.1-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3f414ddf1161976a40fc0a333000e6a4ad612719eac0b8c9bb73f47153187148"}, + {file = "pyinstrument-4.6.1-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:65e62ebfa2cd8fb57eda90006f4505ac4c70da00fc2f05b6d8337d776ea76d41"}, + {file = "pyinstrument-4.6.1-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:d96309df4df10be7b4885797c5f69bb3a89414680ebaec0722d8156fde5268c3"}, + {file = "pyinstrument-4.6.1-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:f3d1ad3bc8ebb4db925afa706aa865c4bfb40d52509f143491ac0df2440ee5d2"}, + {file = "pyinstrument-4.6.1-cp38-cp38-win32.whl", hash = "sha256:dc37cb988c8854eb42bda2e438aaf553536566657d157c4473cc8aad5692a779"}, + {file = "pyinstrument-4.6.1-cp38-cp38-win_amd64.whl", hash = "sha256:2cd4ce750c34a0318fc2d6c727cc255e9658d12a5cf3f2d0473f1c27157bdaeb"}, + {file = "pyinstrument-4.6.1-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:6ca95b21f022e995e062b371d1f42d901452bcbedd2c02f036de677119503355"}, + {file = "pyinstrument-4.6.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:ac1e1d7e1f1b64054c4eb04eb4869a7a5eef2261440e73943cc1b1bc3c828c18"}, + {file = "pyinstrument-4.6.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0711845e953fce6ab781221aacffa2a66dbc3289f8343e5babd7b2ea34da6c90"}, + {file = "pyinstrument-4.6.1-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:5b7d28582017de35cb64eb4e4fa603e753095108ca03745f5d17295970ee631f"}, + {file = "pyinstrument-4.6.1-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7be57db08bd366a37db3aa3a6187941ee21196e8b14975db337ddc7d1490649d"}, + {file = "pyinstrument-4.6.1-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:9a0ac0f56860398d2628ce389826ce83fb3a557d0c9a2351e8a2eac6eb869983"}, + {file = "pyinstrument-4.6.1-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:a9045186ff13bc826fef16be53736a85029aae3c6adfe52e666cad00d7ca623b"}, + {file = "pyinstrument-4.6.1-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:6c4c56b6eab9004e92ad8a48bb54913fdd71fc8a748ae42a27b9e26041646f8b"}, + {file = "pyinstrument-4.6.1-cp39-cp39-win32.whl", hash = "sha256:37e989c44b51839d0c97466fa2b623638b9470d56d79e329f359f0e8fa6d83db"}, + {file = "pyinstrument-4.6.1-cp39-cp39-win_amd64.whl", hash = "sha256:5494c5a84fee4309d7d973366ca6b8b9f8ba1d6b254e93b7c506264ef74f2cef"}, + {file = "pyinstrument-4.6.1.tar.gz", hash = "sha256:f4731b27121350f5a983d358d2272fe3df2f538aed058f57217eef7801a89288"}, +] + +[package.extras] +bin = ["click", "nox"] +docs = ["furo (==2021.6.18b36)", "myst-parser (==0.15.1)", "sphinx (==4.2.0)", "sphinxcontrib-programoutput (==0.17)"] +examples = ["django", "numpy"] +test = ["flaky", "greenlet (>=3.0.0a1)", "ipython", "pytest", "pytest-asyncio (==0.12.0)", "sphinx-autobuild (==2021.3.14)", "trio"] +types = ["typing-extensions"] + +[[package]] +name = "pymongo" +version = "4.6.0" +description = "Python driver for MongoDB " +category = "main" +optional = false +python-versions = ">=3.7" +files = [ + {file = "pymongo-4.6.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:c011bd5ad03cc096f99ffcfdd18a1817354132c1331bed7a837a25226659845f"}, + {file = "pymongo-4.6.0-cp310-cp310-manylinux1_i686.whl", hash = "sha256:5e63146dbdb1eac207464f6e0cfcdb640c9c5ff0f57b754fa96fe252314a1dc6"}, + {file = "pymongo-4.6.0-cp310-cp310-manylinux2014_aarch64.whl", hash = "sha256:2972dd1f1285866aba027eff2f4a2bbf8aa98563c2ced14cb34ee5602b36afdf"}, + {file = "pymongo-4.6.0-cp310-cp310-manylinux2014_i686.whl", hash = "sha256:a0be99b599da95b7a90a918dd927b20c434bea5e1c9b3efc6a3c6cd67c23f813"}, + {file = "pymongo-4.6.0-cp310-cp310-manylinux2014_ppc64le.whl", hash = "sha256:9b0f98481ad5dc4cb430a60bbb8869f05505283b9ae1c62bdb65eb5e020ee8e3"}, + {file = "pymongo-4.6.0-cp310-cp310-manylinux2014_s390x.whl", hash = "sha256:256c503a75bd71cf7fb9ebf889e7e222d49c6036a48aad5a619f98a0adf0e0d7"}, + {file = "pymongo-4.6.0-cp310-cp310-manylinux2014_x86_64.whl", hash = "sha256:b4ad70d7cac4ca0c7b31444a0148bd3af01a2662fa12b1ad6f57cd4a04e21766"}, + {file = "pymongo-4.6.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5717a308a703dda2886a5796a07489c698b442f5e409cf7dc2ac93de8d61d764"}, + {file = "pymongo-4.6.0-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a8f7f9feecae53fa18d6a3ea7c75f9e9a1d4d20e5c3f9ce3fba83f07bcc4eee2"}, + {file = "pymongo-4.6.0-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:128b1485753106c54af481789cdfea12b90a228afca0b11fb3828309a907e10e"}, + {file = "pymongo-4.6.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d3077a31633beef77d057c6523f5de7271ddef7bde5e019285b00c0cc9cac1e3"}, + {file = "pymongo-4.6.0-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ebf02c32afa6b67e5861a27183dd98ed88419a94a2ab843cc145fb0bafcc5b28"}, + {file = "pymongo-4.6.0-cp310-cp310-win32.whl", hash = "sha256:b14dd73f595199f4275bed4fb509277470d9b9059310537e3b3daba12b30c157"}, + {file = "pymongo-4.6.0-cp310-cp310-win_amd64.whl", hash = "sha256:8adf014f2779992eba3b513e060d06f075f0ab2fb3ad956f413a102312f65cdf"}, + {file = "pymongo-4.6.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:ba51129fcc510824b6ca6e2ce1c27e3e4d048b6e35d3ae6f7e517bed1b8b25ce"}, + {file = "pymongo-4.6.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2973f113e079fb98515722cd728e1820282721ec9fd52830e4b73cabdbf1eb28"}, + {file = "pymongo-4.6.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:af425f323fce1b07755edd783581e7283557296946212f5b1a934441718e7528"}, + {file = "pymongo-4.6.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:1ec71ac633b126c0775ed4604ca8f56c3540f5c21a1220639f299e7a544b55f9"}, + {file = "pymongo-4.6.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3ec6c20385c5a58e16b1ea60c5e4993ea060540671d7d12664f385f2fb32fe79"}, + {file = "pymongo-4.6.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:85f2cdc400ee87f5952ebf2a117488f2525a3fb2e23863a8efe3e4ee9e54e4d1"}, + {file = "pymongo-4.6.0-cp311-cp311-win32.whl", hash = "sha256:7fc2bb8a74dcfcdd32f89528e38dcbf70a3a6594963d60dc9595e3b35b66e414"}, + {file = "pymongo-4.6.0-cp311-cp311-win_amd64.whl", hash = "sha256:6695d7136a435c1305b261a9ddb9b3ecec9863e05aab3935b96038145fd3a977"}, + {file = "pymongo-4.6.0-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:d603edea1ff7408638b2504905c032193b7dcee7af269802dbb35bc8c3310ed5"}, + {file = "pymongo-4.6.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:79f41576b3022c2fe9780ae3e44202b2438128a25284a8ddfa038f0785d87019"}, + {file = "pymongo-4.6.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:49f2af6cf82509b15093ce3569229e0d53c90ad8ae2eef940652d4cf1f81e045"}, + {file = "pymongo-4.6.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ecd9e1fa97aa11bf67472220285775fa15e896da108f425e55d23d7540a712ce"}, + {file = "pymongo-4.6.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2d2be5c9c3488fa8a70f83ed925940f488eac2837a996708d98a0e54a861f212"}, + {file = "pymongo-4.6.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:8ab6bcc8e424e07c1d4ba6df96f7fb963bcb48f590b9456de9ebd03b88084fe8"}, + {file = "pymongo-4.6.0-cp312-cp312-win32.whl", hash = "sha256:47aa128be2e66abd9d1a9b0437c62499d812d291f17b55185cb4aa33a5f710a4"}, + {file = "pymongo-4.6.0-cp312-cp312-win_amd64.whl", hash = "sha256:014e7049dd019a6663747ca7dae328943e14f7261f7c1381045dfc26a04fa330"}, + {file = "pymongo-4.6.0-cp37-cp37m-manylinux1_i686.whl", hash = "sha256:288c21ab9531b037f7efa4e467b33176bc73a0c27223c141b822ab4a0e66ff2a"}, + {file = "pymongo-4.6.0-cp37-cp37m-manylinux1_x86_64.whl", hash = "sha256:747c84f4e690fbe6999c90ac97246c95d31460d890510e4a3fa61b7d2b87aa34"}, + {file = "pymongo-4.6.0-cp37-cp37m-manylinux2014_aarch64.whl", hash = "sha256:055f5c266e2767a88bb585d01137d9c7f778b0195d3dbf4a487ef0638be9b651"}, + {file = "pymongo-4.6.0-cp37-cp37m-manylinux2014_i686.whl", hash = "sha256:82e620842e12e8cb4050d2643a81c8149361cd82c0a920fa5a15dc4ca8a4000f"}, + {file = "pymongo-4.6.0-cp37-cp37m-manylinux2014_ppc64le.whl", hash = "sha256:6b18276f14b4b6d92e707ab6db19b938e112bd2f1dc3f9f1a628df58e4fd3f0d"}, + {file = "pymongo-4.6.0-cp37-cp37m-manylinux2014_s390x.whl", hash = "sha256:680fa0fc719e1a3dcb81130858368f51d83667d431924d0bcf249644bce8f303"}, + {file = "pymongo-4.6.0-cp37-cp37m-manylinux2014_x86_64.whl", hash = "sha256:3919708594b86d0f5cdc713eb6fccd3f9b9532af09ea7a5d843c933825ef56c4"}, + {file = "pymongo-4.6.0-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:db082f728160369d9a6ed2e722438291558fc15ce06d0a7d696a8dad735c236b"}, + {file = "pymongo-4.6.0-cp37-cp37m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1e4ed21029d80c4f62605ab16398fe1ce093fff4b5f22d114055e7d9fbc4adb0"}, + {file = "pymongo-4.6.0-cp37-cp37m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9bea9138b0fc6e2218147e9c6ce1ff76ff8e29dc00bb1b64842bd1ca107aee9f"}, + {file = "pymongo-4.6.0-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4a0269811661ba93c472c8a60ea82640e838c2eb148d252720a09b5123f2c2fe"}, + {file = "pymongo-4.6.0-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:6d6a1b1361f118e7fefa17ae3114e77f10ee1b228b20d50c47c9f351346180c8"}, + {file = "pymongo-4.6.0-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:7e3b0127b260d4abae7b62203c4c7ef0874c901b55155692353db19de4b18bc4"}, + {file = "pymongo-4.6.0-cp37-cp37m-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:a49aca4d961823b2846b739380c847e8964ff7ae0f0a683992b9d926054f0d6d"}, + {file = "pymongo-4.6.0-cp37-cp37m-win32.whl", hash = "sha256:09c7de516b08c57647176b9fc21d929d628e35bcebc7422220c89ae40b62126a"}, + {file = "pymongo-4.6.0-cp37-cp37m-win_amd64.whl", hash = "sha256:81dd1308bd5630d2bb5980f00aa163b986b133f1e9ed66c66ce2a5bc3572e891"}, + {file = "pymongo-4.6.0-cp38-cp38-macosx_11_0_universal2.whl", hash = "sha256:2f8c04277d879146eacda920476e93d520eff8bec6c022ac108cfa6280d84348"}, + {file = "pymongo-4.6.0-cp38-cp38-manylinux1_i686.whl", hash = "sha256:5802acc012bbb4bce4dff92973dff76482f30ef35dd4cb8ab5b0e06aa8f08c80"}, + {file = "pymongo-4.6.0-cp38-cp38-manylinux1_x86_64.whl", hash = "sha256:ccd785fafa1c931deff6a7116e9a0d402d59fabe51644b0d0c268295ff847b25"}, + {file = "pymongo-4.6.0-cp38-cp38-manylinux2014_aarch64.whl", hash = "sha256:fe03bf25fae4b95d8afe40004a321df644400fdcba4c8e5e1a19c1085b740888"}, + {file = "pymongo-4.6.0-cp38-cp38-manylinux2014_i686.whl", hash = "sha256:2ca0ba501898b2ec31e6c3acf90c31910944f01d454ad8e489213a156ccf1bda"}, + {file = "pymongo-4.6.0-cp38-cp38-manylinux2014_ppc64le.whl", hash = "sha256:10a379fb60f1b2406ae57b8899bacfe20567918c8e9d2d545e1b93628fcf2050"}, + {file = "pymongo-4.6.0-cp38-cp38-manylinux2014_s390x.whl", hash = "sha256:a4dc1319d0c162919ee7f4ee6face076becae2abbd351cc14f1fe70af5fb20d9"}, + {file = "pymongo-4.6.0-cp38-cp38-manylinux2014_x86_64.whl", hash = "sha256:ddef295aaf80cefb0c1606f1995899efcb17edc6b327eb6589e234e614b87756"}, + {file = "pymongo-4.6.0-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:518c90bdd6e842c446d01a766b9136fec5ec6cc94f3b8c3f8b4a332786ee6b64"}, + {file = "pymongo-4.6.0-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b80a4ee19b3442c57c38afa978adca546521a8822d663310b63ae2a7d7b13f3a"}, + {file = "pymongo-4.6.0-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:eb438a8bf6b695bf50d57e6a059ff09652a07968b2041178b3744ea785fcef9b"}, + {file = "pymongo-4.6.0-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e3db7d833a7c38c317dc95b54e27f1d27012e031b45a7c24e360b53197d5f6e7"}, + {file = "pymongo-4.6.0-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3729b8db02063da50eeb3db88a27670d85953afb9a7f14c213ac9e3dca93034b"}, + {file = "pymongo-4.6.0-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:39a1cd5d383b37285641d5a7a86be85274466ae336a61b51117155936529f9b3"}, + {file = "pymongo-4.6.0-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:7b0e6361754ac596cd16bfc6ed49f69ffcd9b60b7bc4bcd3ea65c6a83475e4ff"}, + {file = "pymongo-4.6.0-cp38-cp38-win32.whl", hash = "sha256:806e094e9e85d8badc978af8c95b69c556077f11844655cb8cd2d1758769e521"}, + {file = "pymongo-4.6.0-cp38-cp38-win_amd64.whl", hash = "sha256:1394c4737b325166a65ae7c145af1ebdb9fb153ebedd37cf91d676313e4a67b8"}, + {file = "pymongo-4.6.0-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:a8273e1abbcff1d7d29cbbb1ea7e57d38be72f1af3c597c854168508b91516c2"}, + {file = "pymongo-4.6.0-cp39-cp39-manylinux1_i686.whl", hash = "sha256:e16ade71c93f6814d095d25cd6d28a90d63511ea396bd96e9ffcb886b278baaa"}, + {file = "pymongo-4.6.0-cp39-cp39-manylinux1_x86_64.whl", hash = "sha256:325701ae7b56daa5b0692305b7cb505ca50f80a1288abb32ff420a8a209b01ca"}, + {file = "pymongo-4.6.0-cp39-cp39-manylinux2014_aarch64.whl", hash = "sha256:cc94f9fea17a5af8cf1a343597711a26b0117c0b812550d99934acb89d526ed2"}, + {file = "pymongo-4.6.0-cp39-cp39-manylinux2014_i686.whl", hash = "sha256:21812453354b151200034750cd30b0140e82ec2a01fd4357390f67714a1bfbde"}, + {file = "pymongo-4.6.0-cp39-cp39-manylinux2014_ppc64le.whl", hash = "sha256:0634994b026336195778e5693583c060418d4ab453eff21530422690a97e1ee8"}, + {file = "pymongo-4.6.0-cp39-cp39-manylinux2014_s390x.whl", hash = "sha256:ad4f66fbb893b55f96f03020e67dcab49ffde0177c6565ccf9dec4fdf974eb61"}, + {file = "pymongo-4.6.0-cp39-cp39-manylinux2014_x86_64.whl", hash = "sha256:2703a9f8f5767986b4f51c259ff452cc837c5a83c8ed5f5361f6e49933743b2f"}, + {file = "pymongo-4.6.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6bafea6061d63059d8bc2ffc545e2f049221c8a4457d236c5cd6a66678673eab"}, + {file = "pymongo-4.6.0-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f28ae33dc5a0b9cee06e95fd420e42155d83271ab75964baf747ce959cac5f52"}, + {file = "pymongo-4.6.0-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d16a534da0e39785687b7295e2fcf9a339f4a20689024983d11afaa4657f8507"}, + {file = "pymongo-4.6.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ef67fedd863ffffd4adfd46d9d992b0f929c7f61a8307366d664d93517f2c78e"}, + {file = "pymongo-4.6.0-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:05c30fd35cc97f14f354916b45feea535d59060ef867446b5c3c7f9b609dd5dc"}, + {file = "pymongo-4.6.0-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:1c63e3a2e8fb815c4b1f738c284a4579897e37c3cfd95fdb199229a1ccfb638a"}, + {file = "pymongo-4.6.0-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:e5e193f89f4f8c1fe273f9a6e6df915092c9f2af6db2d1afb8bd53855025c11f"}, + {file = "pymongo-4.6.0-cp39-cp39-win32.whl", hash = "sha256:a09bfb51953930e7e838972ddf646c5d5f984992a66d79da6ba7f6a8d8a890cd"}, + {file = "pymongo-4.6.0-cp39-cp39-win_amd64.whl", hash = "sha256:107a234dc55affc5802acb3b6d83cbb8c87355b38a9457fcd8806bdeb8bce161"}, + {file = "pymongo-4.6.0.tar.gz", hash = "sha256:fb1c56d891f9e34303c451998ef62ba52659648bb0d75b03c5e4ac223a3342c2"}, +] + +[package.dependencies] +dnspython = ">=1.16.0,<3.0.0" + +[package.extras] +aws = ["pymongo-auth-aws (<2.0.0)"] +encryption = ["certifi", "pymongo[aws]", "pymongocrypt (>=1.6.0,<2.0.0)"] +gssapi = ["pykerberos", "winkerberos (>=0.5.0)"] +ocsp = ["certifi", "cryptography (>=2.5)", "pyopenssl (>=17.2.0)", "requests (<3.0.0)", "service-identity (>=18.1.0)"] +snappy = ["python-snappy"] +test = ["pytest (>=7)"] +zstd = ["zstandard"] + +[[package]] +name = "python-dotenv" +version = "1.0.0" +description = "Read key-value pairs from a .env file and set them as environment variables" +category = "main" +optional = false +python-versions = ">=3.8" +files = [ + {file = "python-dotenv-1.0.0.tar.gz", hash = "sha256:a8df96034aae6d2d50a4ebe8216326c61c3eb64836776504fcca410e5937a3ba"}, + {file = "python_dotenv-1.0.0-py3-none-any.whl", hash = "sha256:f5971a9226b701070a4bf2c38c89e5a3f0d64de8debda981d1db98583009122a"}, +] + +[package.extras] +cli = ["click (>=5.0)"] + +[[package]] +name = "pytz" +version = "2023.3.post1" +description = "World timezone definitions, modern and historical" +category = "main" +optional = false +python-versions = "*" +files = [ + {file = "pytz-2023.3.post1-py2.py3-none-any.whl", hash = "sha256:ce42d816b81b68506614c11e8937d3aa9e41007ceb50bfdcb0749b921bf646c7"}, + {file = "pytz-2023.3.post1.tar.gz", hash = "sha256:7b4fddbeb94a1eba4b557da24f19fdf9db575192544270a9101d8509f9f43d7b"}, +] + +[[package]] +name = "pyyaml" +version = "6.0.1" +description = "YAML parser and emitter for Python" +category = "main" +optional = false +python-versions = ">=3.6" +files = [ + {file = "PyYAML-6.0.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:d858aa552c999bc8a8d57426ed01e40bef403cd8ccdd0fc5f6f04a00414cac2a"}, + {file = "PyYAML-6.0.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:fd66fc5d0da6d9815ba2cebeb4205f95818ff4b79c3ebe268e75d961704af52f"}, + {file = "PyYAML-6.0.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:69b023b2b4daa7548bcfbd4aa3da05b3a74b772db9e23b982788168117739938"}, + {file = "PyYAML-6.0.1-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:81e0b275a9ecc9c0c0c07b4b90ba548307583c125f54d5b6946cfee6360c733d"}, + {file = "PyYAML-6.0.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ba336e390cd8e4d1739f42dfe9bb83a3cc2e80f567d8805e11b46f4a943f5515"}, + {file = "PyYAML-6.0.1-cp310-cp310-win32.whl", hash = "sha256:bd4af7373a854424dabd882decdc5579653d7868b8fb26dc7d0e99f823aa5924"}, + {file = "PyYAML-6.0.1-cp310-cp310-win_amd64.whl", hash = "sha256:fd1592b3fdf65fff2ad0004b5e363300ef59ced41c2e6b3a99d4089fa8c5435d"}, + {file = "PyYAML-6.0.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:6965a7bc3cf88e5a1c3bd2e0b5c22f8d677dc88a455344035f03399034eb3007"}, + {file = "PyYAML-6.0.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:f003ed9ad21d6a4713f0a9b5a7a0a79e08dd0f221aff4525a2be4c346ee60aab"}, + {file = "PyYAML-6.0.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:42f8152b8dbc4fe7d96729ec2b99c7097d656dc1213a3229ca5383f973a5ed6d"}, + {file = "PyYAML-6.0.1-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:062582fca9fabdd2c8b54a3ef1c978d786e0f6b3a1510e0ac93ef59e0ddae2bc"}, + {file = "PyYAML-6.0.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d2b04aac4d386b172d5b9692e2d2da8de7bfb6c387fa4f801fbf6fb2e6ba4673"}, + {file = "PyYAML-6.0.1-cp311-cp311-win32.whl", hash = "sha256:1635fd110e8d85d55237ab316b5b011de701ea0f29d07611174a1b42f1444741"}, + {file = "PyYAML-6.0.1-cp311-cp311-win_amd64.whl", hash = "sha256:bf07ee2fef7014951eeb99f56f39c9bb4af143d8aa3c21b1677805985307da34"}, + {file = "PyYAML-6.0.1-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:50550eb667afee136e9a77d6dc71ae76a44df8b3e51e41b77f6de2932bfe0f47"}, + {file = "PyYAML-6.0.1-cp36-cp36m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1fe35611261b29bd1de0070f0b2f47cb6ff71fa6595c077e42bd0c419fa27b98"}, + {file = "PyYAML-6.0.1-cp36-cp36m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:704219a11b772aea0d8ecd7058d0082713c3562b4e271b849ad7dc4a5c90c13c"}, + {file = "PyYAML-6.0.1-cp36-cp36m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:afd7e57eddb1a54f0f1a974bc4391af8bcce0b444685d936840f125cf046d5bd"}, + {file = "PyYAML-6.0.1-cp36-cp36m-win32.whl", hash = "sha256:fca0e3a251908a499833aa292323f32437106001d436eca0e6e7833256674585"}, + {file = "PyYAML-6.0.1-cp36-cp36m-win_amd64.whl", hash = "sha256:f22ac1c3cac4dbc50079e965eba2c1058622631e526bd9afd45fedd49ba781fa"}, + {file = "PyYAML-6.0.1-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:b1275ad35a5d18c62a7220633c913e1b42d44b46ee12554e5fd39c70a243d6a3"}, + {file = "PyYAML-6.0.1-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:18aeb1bf9a78867dc38b259769503436b7c72f7a1f1f4c93ff9a17de54319b27"}, + {file = "PyYAML-6.0.1-cp37-cp37m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:596106435fa6ad000c2991a98fa58eeb8656ef2325d7e158344fb33864ed87e3"}, + {file = "PyYAML-6.0.1-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:baa90d3f661d43131ca170712d903e6295d1f7a0f595074f151c0aed377c9b9c"}, + {file = "PyYAML-6.0.1-cp37-cp37m-win32.whl", hash = "sha256:9046c58c4395dff28dd494285c82ba00b546adfc7ef001486fbf0324bc174fba"}, + {file = "PyYAML-6.0.1-cp37-cp37m-win_amd64.whl", hash = "sha256:4fb147e7a67ef577a588a0e2c17b6db51dda102c71de36f8549b6816a96e1867"}, + {file = "PyYAML-6.0.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:1d4c7e777c441b20e32f52bd377e0c409713e8bb1386e1099c2415f26e479595"}, + {file = "PyYAML-6.0.1-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a0cd17c15d3bb3fa06978b4e8958dcdc6e0174ccea823003a106c7d4d7899ac5"}, + {file = "PyYAML-6.0.1-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:28c119d996beec18c05208a8bd78cbe4007878c6dd15091efb73a30e90539696"}, + {file = "PyYAML-6.0.1-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7e07cbde391ba96ab58e532ff4803f79c4129397514e1413a7dc761ccd755735"}, + {file = "PyYAML-6.0.1-cp38-cp38-win32.whl", hash = "sha256:184c5108a2aca3c5b3d3bf9395d50893a7ab82a38004c8f61c258d4428e80206"}, + {file = "PyYAML-6.0.1-cp38-cp38-win_amd64.whl", hash = "sha256:1e2722cc9fbb45d9b87631ac70924c11d3a401b2d7f410cc0e3bbf249f2dca62"}, + {file = "PyYAML-6.0.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:9eb6caa9a297fc2c2fb8862bc5370d0303ddba53ba97e71f08023b6cd73d16a8"}, + {file = "PyYAML-6.0.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:c8098ddcc2a85b61647b2590f825f3db38891662cfc2fc776415143f599bb859"}, + {file = "PyYAML-6.0.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5773183b6446b2c99bb77e77595dd486303b4faab2b086e7b17bc6bef28865f6"}, + {file = "PyYAML-6.0.1-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b786eecbdf8499b9ca1d697215862083bd6d2a99965554781d0d8d1ad31e13a0"}, + {file = "PyYAML-6.0.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bc1bf2925a1ecd43da378f4db9e4f799775d6367bdb94671027b73b393a7c42c"}, + {file = "PyYAML-6.0.1-cp39-cp39-win32.whl", hash = "sha256:faca3bdcf85b2fc05d06ff3fbc1f83e1391b3e724afa3feba7d13eeab355484c"}, + {file = "PyYAML-6.0.1-cp39-cp39-win_amd64.whl", hash = "sha256:510c9deebc5c0225e8c96813043e62b680ba2f9c50a08d3724c7f28a747d1486"}, + {file = "PyYAML-6.0.1.tar.gz", hash = "sha256:bfdf460b1736c775f2ba9f6a92bca30bc2095067b8a9d77876d1fad6cc3b4a43"}, +] + +[[package]] +name = "requests" +version = "2.31.0" +description = "Python HTTP for Humans." +category = "main" +optional = false +python-versions = ">=3.7" +files = [ + {file = "requests-2.31.0-py3-none-any.whl", hash = "sha256:58cd2187c01e70e6e26505bca751777aa9f2ee0b7f4300988b709f44e013003f"}, + {file = "requests-2.31.0.tar.gz", hash = "sha256:942c5a758f98d790eaed1a29cb6eefc7ffb0d1cf7af05c3d2791656dbd6ad1e1"}, +] + +[package.dependencies] +certifi = ">=2017.4.17" +charset-normalizer = ">=2,<4" +idna = ">=2.5,<4" +urllib3 = ">=1.21.1,<3" + +[package.extras] +socks = ["PySocks (>=1.5.6,!=1.5.7)"] +use-chardet-on-py3 = ["chardet (>=3.0.2,<6)"] + +[[package]] +name = "sentry-sdk" +version = "1.36.0" +description = "Python client for Sentry (https://sentry.io)" +category = "main" +optional = false +python-versions = "*" +files = [ + {file = "sentry-sdk-1.36.0.tar.gz", hash = "sha256:f32dd16547f2f45e1c71a96fd4a48925e629541f7ddfe3d5d25ef7d5e94eb3c8"}, + {file = "sentry_sdk-1.36.0-py2.py3-none-any.whl", hash = "sha256:25d574f94fdf72199e331c2401fdac60d01b5be8f32822174c51c3ff0fc2f8cb"}, +] + +[package.dependencies] +certifi = "*" +urllib3 = {version = ">=1.26.11", markers = "python_version >= \"3.6\""} + +[package.extras] +aiohttp = ["aiohttp (>=3.5)"] +arq = ["arq (>=0.23)"] +asyncpg = ["asyncpg (>=0.23)"] +beam = ["apache-beam (>=2.12)"] +bottle = ["bottle (>=0.12.13)"] +celery = ["celery (>=3)"] +chalice = ["chalice (>=1.16.0)"] +clickhouse-driver = ["clickhouse-driver (>=0.2.0)"] +django = ["django (>=1.8)"] +falcon = ["falcon (>=1.4)"] +fastapi = ["fastapi (>=0.79.0)"] +flask = ["blinker (>=1.1)", "flask (>=0.11)", "markupsafe"] +grpcio = ["grpcio (>=1.21.1)"] +httpx = ["httpx (>=0.16.0)"] +huey = ["huey (>=2)"] +loguru = ["loguru (>=0.5)"] +opentelemetry = ["opentelemetry-distro (>=0.35b0)"] +opentelemetry-experimental = ["opentelemetry-distro (>=0.40b0,<1.0)", "opentelemetry-instrumentation-aiohttp-client (>=0.40b0,<1.0)", "opentelemetry-instrumentation-django (>=0.40b0,<1.0)", "opentelemetry-instrumentation-fastapi (>=0.40b0,<1.0)", "opentelemetry-instrumentation-flask (>=0.40b0,<1.0)", "opentelemetry-instrumentation-requests (>=0.40b0,<1.0)", "opentelemetry-instrumentation-sqlite3 (>=0.40b0,<1.0)", "opentelemetry-instrumentation-urllib (>=0.40b0,<1.0)"] +pure-eval = ["asttokens", "executing", "pure-eval"] +pymongo = ["pymongo (>=3.1)"] +pyspark = ["pyspark (>=2.4.4)"] +quart = ["blinker (>=1.1)", "quart (>=0.16.1)"] +rq = ["rq (>=0.6)"] +sanic = ["sanic (>=0.8)"] +sqlalchemy = ["sqlalchemy (>=1.2)"] +starlette = ["starlette (>=0.19.1)"] +starlite = ["starlite (>=1.48)"] +tornado = ["tornado (>=5)"] + +[[package]] +name = "setuptools" +version = "69.0.2" +description = "Easily download, build, install, upgrade, and uninstall Python packages" +category = "dev" +optional = false +python-versions = ">=3.8" +files = [ + {file = "setuptools-69.0.2-py3-none-any.whl", hash = "sha256:1e8fdff6797d3865f37397be788a4e3cba233608e9b509382a2777d25ebde7f2"}, + {file = "setuptools-69.0.2.tar.gz", hash = "sha256:735896e78a4742605974de002ac60562d286fa8051a7e2299445e8e8fbb01aa6"}, +] + +[package.extras] +docs = ["furo", "jaraco.packaging (>=9.3)", "jaraco.tidelift (>=1.4)", "pygments-github-lexers (==0.0.5)", "rst.linker (>=1.9)", "sphinx (<7.2.5)", "sphinx (>=3.5)", "sphinx-favicon", "sphinx-inline-tabs", "sphinx-lint", "sphinx-notfound-page (>=1,<2)", "sphinx-reredirects", "sphinxcontrib-towncrier"] +testing = ["build[virtualenv]", "filelock (>=3.4.0)", "flake8-2020", "ini2toml[lite] (>=0.9)", "jaraco.develop (>=7.21)", "jaraco.envs (>=2.2)", "jaraco.path (>=3.2.0)", "pip (>=19.1)", "pytest (>=6)", "pytest-black (>=0.3.7)", "pytest-checkdocs (>=2.4)", "pytest-cov", "pytest-enabler (>=2.2)", "pytest-mypy (>=0.9.1)", "pytest-perf", "pytest-ruff", "pytest-timeout", "pytest-xdist", "tomli-w (>=1.0.0)", "virtualenv (>=13.0.0)", "wheel"] +testing-integration = ["build[virtualenv] (>=1.0.3)", "filelock (>=3.4.0)", "jaraco.envs (>=2.2)", "jaraco.path (>=3.2.0)", "packaging (>=23.1)", "pytest", "pytest-enabler", "pytest-xdist", "tomli", "virtualenv (>=13.0.0)", "wheel"] + +[[package]] +name = "sniffio" +version = "1.3.0" +description = "Sniff out which async library your code is running under" +category = "main" +optional = false +python-versions = ">=3.7" +files = [ + {file = "sniffio-1.3.0-py3-none-any.whl", hash = "sha256:eecefdce1e5bbfb7ad2eeaabf7c1eeb404d7757c379bd1f7e5cce9d8bf425384"}, + {file = "sniffio-1.3.0.tar.gz", hash = "sha256:e60305c5e5d314f5389259b7f22aaa33d8f7dee49763119234af3755c55b9101"}, +] + +[[package]] +name = "starlette" +version = "0.27.0" +description = "The little ASGI library that shines." +category = "main" +optional = false +python-versions = ">=3.7" +files = [ + {file = "starlette-0.27.0-py3-none-any.whl", hash = "sha256:918416370e846586541235ccd38a474c08b80443ed31c578a418e2209b3eef91"}, + {file = "starlette-0.27.0.tar.gz", hash = "sha256:6a6b0d042acb8d469a01eba54e9cda6cbd24ac602c4cd016723117d6a7e73b75"}, +] + +[package.dependencies] +anyio = ">=3.4.0,<5" + +[package.extras] +full = ["httpx (>=0.22.0)", "itsdangerous", "jinja2", "python-multipart", "pyyaml"] + +[[package]] +name = "svgwrite" +version = "1.4.3" +description = "A Python library to create SVG drawings." +category = "main" +optional = false +python-versions = ">=3.6" +files = [ + {file = "svgwrite-1.4.3-py3-none-any.whl", hash = "sha256:bb6b2b5450f1edbfa597d924f9ac2dd099e625562e492021d7dd614f65f8a22d"}, + {file = "svgwrite-1.4.3.zip", hash = "sha256:a8fbdfd4443302a6619a7f76bc937fc683daf2628d9b737c891ec08b8ce524c3"}, +] + +[[package]] +name = "typing-extensions" +version = "4.8.0" +description = "Backported and Experimental Type Hints for Python 3.8+" +category = "main" +optional = false +python-versions = ">=3.8" +files = [ + {file = "typing_extensions-4.8.0-py3-none-any.whl", hash = "sha256:8f92fc8806f9a6b641eaa5318da32b44d401efaac0f6678c9bc448ba3605faa0"}, + {file = "typing_extensions-4.8.0.tar.gz", hash = "sha256:df8e4339e9cb77357558cbdbceca33c303714cf861d1eef15e1070055ae8b7ef"}, +] + +[[package]] +name = "urllib3" +version = "2.1.0" +description = "HTTP library with thread-safe connection pooling, file post, and more." +category = "main" +optional = false +python-versions = ">=3.8" +files = [ + {file = "urllib3-2.1.0-py3-none-any.whl", hash = "sha256:55901e917a5896a349ff771be919f8bd99aff50b79fe58fec595eb37bbc56bb3"}, + {file = "urllib3-2.1.0.tar.gz", hash = "sha256:df7aa8afb0148fa78488e7899b2c59b5f4ffcfa82e6c54ccb9dd37c1d7b52d54"}, +] + +[package.extras] +brotli = ["brotli (>=1.0.9)", "brotlicffi (>=0.8.0)"] +socks = ["pysocks (>=1.5.6,!=1.5.7,<2.0)"] +zstd = ["zstandard (>=0.18.0)"] + +[[package]] +name = "uvicorn" +version = "0.24.0.post1" +description = "The lightning-fast ASGI server." +category = "main" +optional = false +python-versions = ">=3.8" +files = [ + {file = "uvicorn-0.24.0.post1-py3-none-any.whl", hash = "sha256:7c84fea70c619d4a710153482c0d230929af7bcf76c7bfa6de151f0a3a80121e"}, + {file = "uvicorn-0.24.0.post1.tar.gz", hash = "sha256:09c8e5a79dc466bdf28dead50093957db184de356fcdc48697bad3bde4c2588e"}, +] + +[package.dependencies] +click = ">=7.0" +colorama = {version = ">=0.4", optional = true, markers = "sys_platform == \"win32\" and extra == \"standard\""} +h11 = ">=0.8" +httptools = {version = ">=0.5.0", optional = true, markers = "extra == \"standard\""} +python-dotenv = {version = ">=0.13", optional = true, markers = "extra == \"standard\""} +pyyaml = {version = ">=5.1", optional = true, markers = "extra == \"standard\""} +uvloop = {version = ">=0.14.0,<0.15.0 || >0.15.0,<0.15.1 || >0.15.1", optional = true, markers = "sys_platform != \"win32\" and sys_platform != \"cygwin\" and platform_python_implementation != \"PyPy\" and extra == \"standard\""} +watchfiles = {version = ">=0.13", optional = true, markers = "extra == \"standard\""} +websockets = {version = ">=10.4", optional = true, markers = "extra == \"standard\""} + +[package.extras] +standard = ["colorama (>=0.4)", "httptools (>=0.5.0)", "python-dotenv (>=0.13)", "pyyaml (>=5.1)", "uvloop (>=0.14.0,!=0.15.0,!=0.15.1)", "watchfiles (>=0.13)", "websockets (>=10.4)"] + +[[package]] +name = "uvloop" +version = "0.19.0" +description = "Fast implementation of asyncio event loop on top of libuv" +category = "main" +optional = false +python-versions = ">=3.8.0" +files = [ + {file = "uvloop-0.19.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:de4313d7f575474c8f5a12e163f6d89c0a878bc49219641d49e6f1444369a90e"}, + {file = "uvloop-0.19.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:5588bd21cf1fcf06bded085f37e43ce0e00424197e7c10e77afd4bbefffef428"}, + {file = "uvloop-0.19.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7b1fd71c3843327f3bbc3237bedcdb6504fd50368ab3e04d0410e52ec293f5b8"}, + {file = "uvloop-0.19.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5a05128d315e2912791de6088c34136bfcdd0c7cbc1cf85fd6fd1bb321b7c849"}, + {file = "uvloop-0.19.0-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:cd81bdc2b8219cb4b2556eea39d2e36bfa375a2dd021404f90a62e44efaaf957"}, + {file = "uvloop-0.19.0-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:5f17766fb6da94135526273080f3455a112f82570b2ee5daa64d682387fe0dcd"}, + {file = "uvloop-0.19.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:4ce6b0af8f2729a02a5d1575feacb2a94fc7b2e983868b009d51c9a9d2149bef"}, + {file = "uvloop-0.19.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:31e672bb38b45abc4f26e273be83b72a0d28d074d5b370fc4dcf4c4eb15417d2"}, + {file = "uvloop-0.19.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:570fc0ed613883d8d30ee40397b79207eedd2624891692471808a95069a007c1"}, + {file = "uvloop-0.19.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5138821e40b0c3e6c9478643b4660bd44372ae1e16a322b8fc07478f92684e24"}, + {file = "uvloop-0.19.0-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:91ab01c6cd00e39cde50173ba4ec68a1e578fee9279ba64f5221810a9e786533"}, + {file = "uvloop-0.19.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:47bf3e9312f63684efe283f7342afb414eea4d3011542155c7e625cd799c3b12"}, + {file = "uvloop-0.19.0-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:da8435a3bd498419ee8c13c34b89b5005130a476bda1d6ca8cfdde3de35cd650"}, + {file = "uvloop-0.19.0-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:02506dc23a5d90e04d4f65c7791e65cf44bd91b37f24cfc3ef6cf2aff05dc7ec"}, + {file = "uvloop-0.19.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2693049be9d36fef81741fddb3f441673ba12a34a704e7b4361efb75cf30befc"}, + {file = "uvloop-0.19.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7010271303961c6f0fe37731004335401eb9075a12680738731e9c92ddd96ad6"}, + {file = "uvloop-0.19.0-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:5daa304d2161d2918fa9a17d5635099a2f78ae5b5960e742b2fcfbb7aefaa593"}, + {file = "uvloop-0.19.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:7207272c9520203fea9b93843bb775d03e1cf88a80a936ce760f60bb5add92f3"}, + {file = "uvloop-0.19.0-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:78ab247f0b5671cc887c31d33f9b3abfb88d2614b84e4303f1a63b46c046c8bd"}, + {file = "uvloop-0.19.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:472d61143059c84947aa8bb74eabbace30d577a03a1805b77933d6bd13ddebbd"}, + {file = "uvloop-0.19.0-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:45bf4c24c19fb8a50902ae37c5de50da81de4922af65baf760f7c0c42e1088be"}, + {file = "uvloop-0.19.0-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:271718e26b3e17906b28b67314c45d19106112067205119dddbd834c2b7ce797"}, + {file = "uvloop-0.19.0-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:34175c9fd2a4bc3adc1380e1261f60306344e3407c20a4d684fd5f3be010fa3d"}, + {file = "uvloop-0.19.0-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:e27f100e1ff17f6feeb1f33968bc185bf8ce41ca557deee9d9bbbffeb72030b7"}, + {file = "uvloop-0.19.0-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:13dfdf492af0aa0a0edf66807d2b465607d11c4fa48f4a1fd41cbea5b18e8e8b"}, + {file = "uvloop-0.19.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:6e3d4e85ac060e2342ff85e90d0c04157acb210b9ce508e784a944f852a40e67"}, + {file = "uvloop-0.19.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8ca4956c9ab567d87d59d49fa3704cf29e37109ad348f2d5223c9bf761a332e7"}, + {file = "uvloop-0.19.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f467a5fd23b4fc43ed86342641f3936a68ded707f4627622fa3f82a120e18256"}, + {file = "uvloop-0.19.0-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:492e2c32c2af3f971473bc22f086513cedfc66a130756145a931a90c3958cb17"}, + {file = "uvloop-0.19.0-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:2df95fca285a9f5bfe730e51945ffe2fa71ccbfdde3b0da5772b4ee4f2e770d5"}, + {file = "uvloop-0.19.0.tar.gz", hash = "sha256:0246f4fd1bf2bf702e06b0d45ee91677ee5c31242f39aab4ea6fe0c51aedd0fd"}, +] + +[package.extras] +docs = ["Sphinx (>=4.1.2,<4.2.0)", "sphinx-rtd-theme (>=0.5.2,<0.6.0)", "sphinxcontrib-asyncio (>=0.3.0,<0.4.0)"] +test = ["Cython (>=0.29.36,<0.30.0)", "aiohttp (==3.9.0b0)", "aiohttp (>=3.8.1)", "flake8 (>=5.0,<6.0)", "mypy (>=0.800)", "psutil", "pyOpenSSL (>=23.0.0,<23.1.0)", "pycodestyle (>=2.9.0,<2.10.0)"] + +[[package]] +name = "virtualenv" +version = "20.24.7" +description = "Virtual Python Environment builder" +category = "dev" +optional = false +python-versions = ">=3.7" +files = [ + {file = "virtualenv-20.24.7-py3-none-any.whl", hash = "sha256:a18b3fd0314ca59a2e9f4b556819ed07183b3e9a3702ecfe213f593d44f7b3fd"}, + {file = "virtualenv-20.24.7.tar.gz", hash = "sha256:69050ffb42419c91f6c1284a7b24e0475d793447e35929b488bf6a0aade39353"}, +] + +[package.dependencies] +distlib = ">=0.3.7,<1" +filelock = ">=3.12.2,<4" +platformdirs = ">=3.9.1,<5" + +[package.extras] +docs = ["furo (>=2023.7.26)", "proselint (>=0.13)", "sphinx (>=7.1.2)", "sphinx-argparse (>=0.4)", "sphinxcontrib-towncrier (>=0.2.1a0)", "towncrier (>=23.6)"] +test = ["covdefaults (>=2.3)", "coverage (>=7.2.7)", "coverage-enable-subprocess (>=1)", "flaky (>=3.7)", "packaging (>=23.1)", "pytest (>=7.4)", "pytest-env (>=0.8.2)", "pytest-freezer (>=0.4.8)", "pytest-mock (>=3.11.1)", "pytest-randomly (>=3.12)", "pytest-timeout (>=2.1)", "setuptools (>=68)", "time-machine (>=2.10)"] + +[[package]] +name = "watchfiles" +version = "0.21.0" +description = "Simple, modern and high performance file watching and code reload in python." +category = "main" +optional = false +python-versions = ">=3.8" +files = [ + {file = "watchfiles-0.21.0-cp310-cp310-macosx_10_7_x86_64.whl", hash = "sha256:27b4035013f1ea49c6c0b42d983133b136637a527e48c132d368eb19bf1ac6aa"}, + {file = "watchfiles-0.21.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:c81818595eff6e92535ff32825f31c116f867f64ff8cdf6562cd1d6b2e1e8f3e"}, + {file = "watchfiles-0.21.0-cp310-cp310-manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:6c107ea3cf2bd07199d66f156e3ea756d1b84dfd43b542b2d870b77868c98c03"}, + {file = "watchfiles-0.21.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0d9ac347653ebd95839a7c607608703b20bc07e577e870d824fa4801bc1cb124"}, + {file = "watchfiles-0.21.0-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:5eb86c6acb498208e7663ca22dbe68ca2cf42ab5bf1c776670a50919a56e64ab"}, + {file = "watchfiles-0.21.0-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f564bf68404144ea6b87a78a3f910cc8de216c6b12a4cf0b27718bf4ec38d303"}, + {file = "watchfiles-0.21.0-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3d0f32ebfaa9c6011f8454994f86108c2eb9c79b8b7de00b36d558cadcedaa3d"}, + {file = "watchfiles-0.21.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b6d45d9b699ecbac6c7bd8e0a2609767491540403610962968d258fd6405c17c"}, + {file = "watchfiles-0.21.0-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:aff06b2cac3ef4616e26ba17a9c250c1fe9dd8a5d907d0193f84c499b1b6e6a9"}, + {file = "watchfiles-0.21.0-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:d9792dff410f266051025ecfaa927078b94cc7478954b06796a9756ccc7e14a9"}, + {file = "watchfiles-0.21.0-cp310-none-win32.whl", hash = "sha256:214cee7f9e09150d4fb42e24919a1e74d8c9b8a9306ed1474ecaddcd5479c293"}, + {file = "watchfiles-0.21.0-cp310-none-win_amd64.whl", hash = "sha256:1ad7247d79f9f55bb25ab1778fd47f32d70cf36053941f07de0b7c4e96b5d235"}, + {file = "watchfiles-0.21.0-cp311-cp311-macosx_10_7_x86_64.whl", hash = "sha256:668c265d90de8ae914f860d3eeb164534ba2e836811f91fecc7050416ee70aa7"}, + {file = "watchfiles-0.21.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:3a23092a992e61c3a6a70f350a56db7197242f3490da9c87b500f389b2d01eef"}, + {file = "watchfiles-0.21.0-cp311-cp311-manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:e7941bbcfdded9c26b0bf720cb7e6fd803d95a55d2c14b4bd1f6a2772230c586"}, + {file = "watchfiles-0.21.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:11cd0c3100e2233e9c53106265da31d574355c288e15259c0d40a4405cbae317"}, + {file = "watchfiles-0.21.0-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:d78f30cbe8b2ce770160d3c08cff01b2ae9306fe66ce899b73f0409dc1846c1b"}, + {file = "watchfiles-0.21.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:6674b00b9756b0af620aa2a3346b01f8e2a3dc729d25617e1b89cf6af4a54eb1"}, + {file = "watchfiles-0.21.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:fd7ac678b92b29ba630d8c842d8ad6c555abda1b9ef044d6cc092dacbfc9719d"}, + {file = "watchfiles-0.21.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9c873345680c1b87f1e09e0eaf8cf6c891b9851d8b4d3645e7efe2ec20a20cc7"}, + {file = "watchfiles-0.21.0-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:49f56e6ecc2503e7dbe233fa328b2be1a7797d31548e7a193237dcdf1ad0eee0"}, + {file = "watchfiles-0.21.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:02d91cbac553a3ad141db016e3350b03184deaafeba09b9d6439826ee594b365"}, + {file = "watchfiles-0.21.0-cp311-none-win32.whl", hash = "sha256:ebe684d7d26239e23d102a2bad2a358dedf18e462e8808778703427d1f584400"}, + {file = "watchfiles-0.21.0-cp311-none-win_amd64.whl", hash = "sha256:4566006aa44cb0d21b8ab53baf4b9c667a0ed23efe4aaad8c227bfba0bf15cbe"}, + {file = "watchfiles-0.21.0-cp311-none-win_arm64.whl", hash = "sha256:c550a56bf209a3d987d5a975cdf2063b3389a5d16caf29db4bdddeae49f22078"}, + {file = "watchfiles-0.21.0-cp312-cp312-macosx_10_7_x86_64.whl", hash = "sha256:51ddac60b96a42c15d24fbdc7a4bfcd02b5a29c047b7f8bf63d3f6f5a860949a"}, + {file = "watchfiles-0.21.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:511f0b034120cd1989932bf1e9081aa9fb00f1f949fbd2d9cab6264916ae89b1"}, + {file = "watchfiles-0.21.0-cp312-cp312-manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:cfb92d49dbb95ec7a07511bc9efb0faff8fe24ef3805662b8d6808ba8409a71a"}, + {file = "watchfiles-0.21.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3f92944efc564867bbf841c823c8b71bb0be75e06b8ce45c084b46411475a915"}, + {file = "watchfiles-0.21.0-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:642d66b75eda909fd1112d35c53816d59789a4b38c141a96d62f50a3ef9b3360"}, + {file = "watchfiles-0.21.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d23bcd6c8eaa6324fe109d8cac01b41fe9a54b8c498af9ce464c1aeeb99903d6"}, + {file = "watchfiles-0.21.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:18d5b4da8cf3e41895b34e8c37d13c9ed294954907929aacd95153508d5d89d7"}, + {file = "watchfiles-0.21.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1b8d1eae0f65441963d805f766c7e9cd092f91e0c600c820c764a4ff71a0764c"}, + {file = "watchfiles-0.21.0-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:1fd9a5205139f3c6bb60d11f6072e0552f0a20b712c85f43d42342d162be1235"}, + {file = "watchfiles-0.21.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:a1e3014a625bcf107fbf38eece0e47fa0190e52e45dc6eee5a8265ddc6dc5ea7"}, + {file = "watchfiles-0.21.0-cp312-none-win32.whl", hash = "sha256:9d09869f2c5a6f2d9df50ce3064b3391d3ecb6dced708ad64467b9e4f2c9bef3"}, + {file = "watchfiles-0.21.0-cp312-none-win_amd64.whl", hash = "sha256:18722b50783b5e30a18a8a5db3006bab146d2b705c92eb9a94f78c72beb94094"}, + {file = "watchfiles-0.21.0-cp312-none-win_arm64.whl", hash = "sha256:a3b9bec9579a15fb3ca2d9878deae789df72f2b0fdaf90ad49ee389cad5edab6"}, + {file = "watchfiles-0.21.0-cp38-cp38-macosx_10_7_x86_64.whl", hash = "sha256:4ea10a29aa5de67de02256a28d1bf53d21322295cb00bd2d57fcd19b850ebd99"}, + {file = "watchfiles-0.21.0-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:40bca549fdc929b470dd1dbfcb47b3295cb46a6d2c90e50588b0a1b3bd98f429"}, + {file = "watchfiles-0.21.0-cp38-cp38-manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:9b37a7ba223b2f26122c148bb8d09a9ff312afca998c48c725ff5a0a632145f7"}, + {file = "watchfiles-0.21.0-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ec8c8900dc5c83650a63dd48c4d1d245343f904c4b64b48798c67a3767d7e165"}, + {file = "watchfiles-0.21.0-cp38-cp38-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:8ad3fe0a3567c2f0f629d800409cd528cb6251da12e81a1f765e5c5345fd0137"}, + {file = "watchfiles-0.21.0-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9d353c4cfda586db2a176ce42c88f2fc31ec25e50212650c89fdd0f560ee507b"}, + {file = "watchfiles-0.21.0-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:83a696da8922314ff2aec02987eefb03784f473281d740bf9170181829133765"}, + {file = "watchfiles-0.21.0-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5a03651352fc20975ee2a707cd2d74a386cd303cc688f407296064ad1e6d1562"}, + {file = "watchfiles-0.21.0-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:3ad692bc7792be8c32918c699638b660c0de078a6cbe464c46e1340dadb94c19"}, + {file = "watchfiles-0.21.0-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:06247538e8253975bdb328e7683f8515ff5ff041f43be6c40bff62d989b7d0b0"}, + {file = "watchfiles-0.21.0-cp38-none-win32.whl", hash = "sha256:9a0aa47f94ea9a0b39dd30850b0adf2e1cd32a8b4f9c7aa443d852aacf9ca214"}, + {file = "watchfiles-0.21.0-cp38-none-win_amd64.whl", hash = "sha256:8d5f400326840934e3507701f9f7269247f7c026d1b6cfd49477d2be0933cfca"}, + {file = "watchfiles-0.21.0-cp39-cp39-macosx_10_7_x86_64.whl", hash = "sha256:7f762a1a85a12cc3484f77eee7be87b10f8c50b0b787bb02f4e357403cad0c0e"}, + {file = "watchfiles-0.21.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:6e9be3ef84e2bb9710f3f777accce25556f4a71e15d2b73223788d528fcc2052"}, + {file = "watchfiles-0.21.0-cp39-cp39-manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:4c48a10d17571d1275701e14a601e36959ffada3add8cdbc9e5061a6e3579a5d"}, + {file = "watchfiles-0.21.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6c889025f59884423428c261f212e04d438de865beda0b1e1babab85ef4c0f01"}, + {file = "watchfiles-0.21.0-cp39-cp39-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:66fac0c238ab9a2e72d026b5fb91cb902c146202bbd29a9a1a44e8db7b710b6f"}, + {file = "watchfiles-0.21.0-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b4a21f71885aa2744719459951819e7bf5a906a6448a6b2bbce8e9cc9f2c8128"}, + {file = "watchfiles-0.21.0-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:1c9198c989f47898b2c22201756f73249de3748e0fc9de44adaf54a8b259cc0c"}, + {file = "watchfiles-0.21.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d8f57c4461cd24fda22493109c45b3980863c58a25b8bec885ca8bea6b8d4b28"}, + {file = "watchfiles-0.21.0-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:853853cbf7bf9408b404754b92512ebe3e3a83587503d766d23e6bf83d092ee6"}, + {file = "watchfiles-0.21.0-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:d5b1dc0e708fad9f92c296ab2f948af403bf201db8fb2eb4c8179db143732e49"}, + {file = "watchfiles-0.21.0-cp39-none-win32.whl", hash = "sha256:59137c0c6826bd56c710d1d2bda81553b5e6b7c84d5a676747d80caf0409ad94"}, + {file = "watchfiles-0.21.0-cp39-none-win_amd64.whl", hash = "sha256:6cb8fdc044909e2078c248986f2fc76f911f72b51ea4a4fbbf472e01d14faa58"}, + {file = "watchfiles-0.21.0-pp310-pypy310_pp73-macosx_10_7_x86_64.whl", hash = "sha256:ab03a90b305d2588e8352168e8c5a1520b721d2d367f31e9332c4235b30b8994"}, + {file = "watchfiles-0.21.0-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:927c589500f9f41e370b0125c12ac9e7d3a2fd166b89e9ee2828b3dda20bfe6f"}, + {file = "watchfiles-0.21.0-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1bd467213195e76f838caf2c28cd65e58302d0254e636e7c0fca81efa4a2e62c"}, + {file = "watchfiles-0.21.0-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:02b73130687bc3f6bb79d8a170959042eb56eb3a42df3671c79b428cd73f17cc"}, + {file = "watchfiles-0.21.0-pp38-pypy38_pp73-macosx_10_7_x86_64.whl", hash = "sha256:08dca260e85ffae975448e344834d765983237ad6dc308231aa16e7933db763e"}, + {file = "watchfiles-0.21.0-pp38-pypy38_pp73-macosx_11_0_arm64.whl", hash = "sha256:3ccceb50c611c433145502735e0370877cced72a6c70fd2410238bcbc7fe51d8"}, + {file = "watchfiles-0.21.0-pp38-pypy38_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:57d430f5fb63fea141ab71ca9c064e80de3a20b427ca2febcbfcef70ff0ce895"}, + {file = "watchfiles-0.21.0-pp38-pypy38_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0dd5fad9b9c0dd89904bbdea978ce89a2b692a7ee8a0ce19b940e538c88a809c"}, + {file = "watchfiles-0.21.0-pp39-pypy39_pp73-macosx_10_7_x86_64.whl", hash = "sha256:be6dd5d52b73018b21adc1c5d28ac0c68184a64769052dfeb0c5d9998e7f56a2"}, + {file = "watchfiles-0.21.0-pp39-pypy39_pp73-macosx_11_0_arm64.whl", hash = "sha256:b3cab0e06143768499384a8a5efb9c4dc53e19382952859e4802f294214f36ec"}, + {file = "watchfiles-0.21.0-pp39-pypy39_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8c6ed10c2497e5fedadf61e465b3ca12a19f96004c15dcffe4bd442ebadc2d85"}, + {file = "watchfiles-0.21.0-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:43babacef21c519bc6631c5fce2a61eccdfc011b4bcb9047255e9620732c8097"}, + {file = "watchfiles-0.21.0.tar.gz", hash = "sha256:c76c635fabf542bb78524905718c39f736a98e5ab25b23ec6d4abede1a85a6a3"}, +] + +[package.dependencies] +anyio = ">=3.0.0" + +[[package]] +name = "websockets" +version = "12.0" +description = "An implementation of the WebSocket Protocol (RFC 6455 & 7692)" +category = "main" +optional = false +python-versions = ">=3.8" +files = [ + {file = "websockets-12.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:d554236b2a2006e0ce16315c16eaa0d628dab009c33b63ea03f41c6107958374"}, + {file = "websockets-12.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:2d225bb6886591b1746b17c0573e29804619c8f755b5598d875bb4235ea639be"}, + {file = "websockets-12.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:eb809e816916a3b210bed3c82fb88eaf16e8afcf9c115ebb2bacede1797d2547"}, + {file = "websockets-12.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c588f6abc13f78a67044c6b1273a99e1cf31038ad51815b3b016ce699f0d75c2"}, + {file = "websockets-12.0-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:5aa9348186d79a5f232115ed3fa9020eab66d6c3437d72f9d2c8ac0c6858c558"}, + {file = "websockets-12.0-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6350b14a40c95ddd53e775dbdbbbc59b124a5c8ecd6fbb09c2e52029f7a9f480"}, + {file = "websockets-12.0-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:70ec754cc2a769bcd218ed8d7209055667b30860ffecb8633a834dde27d6307c"}, + {file = "websockets-12.0-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:6e96f5ed1b83a8ddb07909b45bd94833b0710f738115751cdaa9da1fb0cb66e8"}, + {file = "websockets-12.0-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:4d87be612cbef86f994178d5186add3d94e9f31cc3cb499a0482b866ec477603"}, + {file = "websockets-12.0-cp310-cp310-win32.whl", hash = "sha256:befe90632d66caaf72e8b2ed4d7f02b348913813c8b0a32fae1cc5fe3730902f"}, + {file = "websockets-12.0-cp310-cp310-win_amd64.whl", hash = "sha256:363f57ca8bc8576195d0540c648aa58ac18cf85b76ad5202b9f976918f4219cf"}, + {file = "websockets-12.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:5d873c7de42dea355d73f170be0f23788cf3fa9f7bed718fd2830eefedce01b4"}, + {file = "websockets-12.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:3f61726cae9f65b872502ff3c1496abc93ffbe31b278455c418492016e2afc8f"}, + {file = "websockets-12.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:ed2fcf7a07334c77fc8a230755c2209223a7cc44fc27597729b8ef5425aa61a3"}, + {file = "websockets-12.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8e332c210b14b57904869ca9f9bf4ca32f5427a03eeb625da9b616c85a3a506c"}, + {file = "websockets-12.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:5693ef74233122f8ebab026817b1b37fe25c411ecfca084b29bc7d6efc548f45"}, + {file = "websockets-12.0-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6e9e7db18b4539a29cc5ad8c8b252738a30e2b13f033c2d6e9d0549b45841c04"}, + {file = "websockets-12.0-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:6e2df67b8014767d0f785baa98393725739287684b9f8d8a1001eb2839031447"}, + {file = "websockets-12.0-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:bea88d71630c5900690fcb03161ab18f8f244805c59e2e0dc4ffadae0a7ee0ca"}, + {file = "websockets-12.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:dff6cdf35e31d1315790149fee351f9e52978130cef6c87c4b6c9b3baf78bc53"}, + {file = "websockets-12.0-cp311-cp311-win32.whl", hash = "sha256:3e3aa8c468af01d70332a382350ee95f6986db479ce7af14d5e81ec52aa2b402"}, + {file = "websockets-12.0-cp311-cp311-win_amd64.whl", hash = "sha256:25eb766c8ad27da0f79420b2af4b85d29914ba0edf69f547cc4f06ca6f1d403b"}, + {file = "websockets-12.0-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:0e6e2711d5a8e6e482cacb927a49a3d432345dfe7dea8ace7b5790df5932e4df"}, + {file = "websockets-12.0-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:dbcf72a37f0b3316e993e13ecf32f10c0e1259c28ffd0a85cee26e8549595fbc"}, + {file = "websockets-12.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:12743ab88ab2af1d17dd4acb4645677cb7063ef4db93abffbf164218a5d54c6b"}, + {file = "websockets-12.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7b645f491f3c48d3f8a00d1fce07445fab7347fec54a3e65f0725d730d5b99cb"}, + {file = "websockets-12.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:9893d1aa45a7f8b3bc4510f6ccf8db8c3b62120917af15e3de247f0780294b92"}, + {file = "websockets-12.0-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1f38a7b376117ef7aff996e737583172bdf535932c9ca021746573bce40165ed"}, + {file = "websockets-12.0-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:f764ba54e33daf20e167915edc443b6f88956f37fb606449b4a5b10ba42235a5"}, + {file = "websockets-12.0-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:1e4b3f8ea6a9cfa8be8484c9221ec0257508e3a1ec43c36acdefb2a9c3b00aa2"}, + {file = "websockets-12.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:9fdf06fd06c32205a07e47328ab49c40fc1407cdec801d698a7c41167ea45113"}, + {file = "websockets-12.0-cp312-cp312-win32.whl", hash = "sha256:baa386875b70cbd81798fa9f71be689c1bf484f65fd6fb08d051a0ee4e79924d"}, + {file = "websockets-12.0-cp312-cp312-win_amd64.whl", hash = "sha256:ae0a5da8f35a5be197f328d4727dbcfafa53d1824fac3d96cdd3a642fe09394f"}, + {file = "websockets-12.0-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:5f6ffe2c6598f7f7207eef9a1228b6f5c818f9f4d53ee920aacd35cec8110438"}, + {file = "websockets-12.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:9edf3fc590cc2ec20dc9d7a45108b5bbaf21c0d89f9fd3fd1685e223771dc0b2"}, + {file = "websockets-12.0-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:8572132c7be52632201a35f5e08348137f658e5ffd21f51f94572ca6c05ea81d"}, + {file = "websockets-12.0-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:604428d1b87edbf02b233e2c207d7d528460fa978f9e391bd8aaf9c8311de137"}, + {file = "websockets-12.0-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1a9d160fd080c6285e202327aba140fc9a0d910b09e423afff4ae5cbbf1c7205"}, + {file = "websockets-12.0-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:87b4aafed34653e465eb77b7c93ef058516cb5acf3eb21e42f33928616172def"}, + {file = "websockets-12.0-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:b2ee7288b85959797970114deae81ab41b731f19ebcd3bd499ae9ca0e3f1d2c8"}, + {file = "websockets-12.0-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:7fa3d25e81bfe6a89718e9791128398a50dec6d57faf23770787ff441d851967"}, + {file = "websockets-12.0-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:a571f035a47212288e3b3519944f6bf4ac7bc7553243e41eac50dd48552b6df7"}, + {file = "websockets-12.0-cp38-cp38-win32.whl", hash = "sha256:3c6cc1360c10c17463aadd29dd3af332d4a1adaa8796f6b0e9f9df1fdb0bad62"}, + {file = "websockets-12.0-cp38-cp38-win_amd64.whl", hash = "sha256:1bf386089178ea69d720f8db6199a0504a406209a0fc23e603b27b300fdd6892"}, + {file = "websockets-12.0-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:ab3d732ad50a4fbd04a4490ef08acd0517b6ae6b77eb967251f4c263011a990d"}, + {file = "websockets-12.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:a1d9697f3337a89691e3bd8dc56dea45a6f6d975f92e7d5f773bc715c15dde28"}, + {file = "websockets-12.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:1df2fbd2c8a98d38a66f5238484405b8d1d16f929bb7a33ed73e4801222a6f53"}, + {file = "websockets-12.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:23509452b3bc38e3a057382c2e941d5ac2e01e251acce7adc74011d7d8de434c"}, + {file = "websockets-12.0-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:2e5fc14ec6ea568200ea4ef46545073da81900a2b67b3e666f04adf53ad452ec"}, + {file = "websockets-12.0-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:46e71dbbd12850224243f5d2aeec90f0aaa0f2dde5aeeb8fc8df21e04d99eff9"}, + {file = "websockets-12.0-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:b81f90dcc6c85a9b7f29873beb56c94c85d6f0dac2ea8b60d995bd18bf3e2aae"}, + {file = "websockets-12.0-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:a02413bc474feda2849c59ed2dfb2cddb4cd3d2f03a2fedec51d6e959d9b608b"}, + {file = "websockets-12.0-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:bbe6013f9f791944ed31ca08b077e26249309639313fff132bfbf3ba105673b9"}, + {file = "websockets-12.0-cp39-cp39-win32.whl", hash = "sha256:cbe83a6bbdf207ff0541de01e11904827540aa069293696dd528a6640bd6a5f6"}, + {file = "websockets-12.0-cp39-cp39-win_amd64.whl", hash = "sha256:fc4e7fa5414512b481a2483775a8e8be7803a35b30ca805afa4998a84f9fd9e8"}, + {file = "websockets-12.0-pp310-pypy310_pp73-macosx_10_9_x86_64.whl", hash = "sha256:248d8e2446e13c1d4326e0a6a4e9629cb13a11195051a73acf414812700badbd"}, + {file = "websockets-12.0-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f44069528d45a933997a6fef143030d8ca8042f0dfaad753e2906398290e2870"}, + {file = "websockets-12.0-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c4e37d36f0d19f0a4413d3e18c0d03d0c268ada2061868c1e6f5ab1a6d575077"}, + {file = "websockets-12.0-pp310-pypy310_pp73-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3d829f975fc2e527a3ef2f9c8f25e553eb7bc779c6665e8e1d52aa22800bb38b"}, + {file = "websockets-12.0-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:2c71bd45a777433dd9113847af751aae36e448bc6b8c361a566cb043eda6ec30"}, + {file = "websockets-12.0-pp38-pypy38_pp73-macosx_10_9_x86_64.whl", hash = "sha256:0bee75f400895aef54157b36ed6d3b308fcab62e5260703add87f44cee9c82a6"}, + {file = "websockets-12.0-pp38-pypy38_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:423fc1ed29f7512fceb727e2d2aecb952c46aa34895e9ed96071821309951123"}, + {file = "websockets-12.0-pp38-pypy38_pp73-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:27a5e9964ef509016759f2ef3f2c1e13f403725a5e6a1775555994966a66e931"}, + {file = "websockets-12.0-pp38-pypy38_pp73-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c3181df4583c4d3994d31fb235dc681d2aaad744fbdbf94c4802485ececdecf2"}, + {file = "websockets-12.0-pp38-pypy38_pp73-win_amd64.whl", hash = "sha256:b067cb952ce8bf40115f6c19f478dc71c5e719b7fbaa511359795dfd9d1a6468"}, + {file = "websockets-12.0-pp39-pypy39_pp73-macosx_10_9_x86_64.whl", hash = "sha256:00700340c6c7ab788f176d118775202aadea7602c5cc6be6ae127761c16d6b0b"}, + {file = "websockets-12.0-pp39-pypy39_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e469d01137942849cff40517c97a30a93ae79917752b34029f0ec72df6b46399"}, + {file = "websockets-12.0-pp39-pypy39_pp73-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ffefa1374cd508d633646d51a8e9277763a9b78ae71324183693959cf94635a7"}, + {file = "websockets-12.0-pp39-pypy39_pp73-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ba0cab91b3956dfa9f512147860783a1829a8d905ee218a9837c18f683239611"}, + {file = "websockets-12.0-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:2cb388a5bfb56df4d9a406783b7f9dbefb888c09b71629351cc6b036e9259370"}, + {file = "websockets-12.0-py3-none-any.whl", hash = "sha256:dc284bbc8d7c78a6c69e0c7325ab46ee5e40bb4d50e494d8131a07ef47500e9e"}, + {file = "websockets-12.0.tar.gz", hash = "sha256:81df9cbcbb6c260de1e007e58c011bfebe2dafc8435107b0537f393dd38c8b1b"}, +] + +[[package]] +name = "wrapt" +version = "1.16.0" +description = "Module for decorators, wrappers and monkey patching." +category = "main" +optional = false +python-versions = ">=3.6" +files = [ + {file = "wrapt-1.16.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:ffa565331890b90056c01db69c0fe634a776f8019c143a5ae265f9c6bc4bd6d4"}, + {file = "wrapt-1.16.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:e4fdb9275308292e880dcbeb12546df7f3e0f96c6b41197e0cf37d2826359020"}, + {file = "wrapt-1.16.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bb2dee3874a500de01c93d5c71415fcaef1d858370d405824783e7a8ef5db440"}, + {file = "wrapt-1.16.0-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:2a88e6010048489cda82b1326889ec075a8c856c2e6a256072b28eaee3ccf487"}, + {file = "wrapt-1.16.0-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ac83a914ebaf589b69f7d0a1277602ff494e21f4c2f743313414378f8f50a4cf"}, + {file = "wrapt-1.16.0-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:73aa7d98215d39b8455f103de64391cb79dfcad601701a3aa0dddacf74911d72"}, + {file = "wrapt-1.16.0-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:807cc8543a477ab7422f1120a217054f958a66ef7314f76dd9e77d3f02cdccd0"}, + {file = "wrapt-1.16.0-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:bf5703fdeb350e36885f2875d853ce13172ae281c56e509f4e6eca049bdfb136"}, + {file = "wrapt-1.16.0-cp310-cp310-win32.whl", hash = "sha256:f6b2d0c6703c988d334f297aa5df18c45e97b0af3679bb75059e0e0bd8b1069d"}, + {file = "wrapt-1.16.0-cp310-cp310-win_amd64.whl", hash = "sha256:decbfa2f618fa8ed81c95ee18a387ff973143c656ef800c9f24fb7e9c16054e2"}, + {file = "wrapt-1.16.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:1a5db485fe2de4403f13fafdc231b0dbae5eca4359232d2efc79025527375b09"}, + {file = "wrapt-1.16.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:75ea7d0ee2a15733684badb16de6794894ed9c55aa5e9903260922f0482e687d"}, + {file = "wrapt-1.16.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a452f9ca3e3267cd4d0fcf2edd0d035b1934ac2bd7e0e57ac91ad6b95c0c6389"}, + {file = "wrapt-1.16.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:43aa59eadec7890d9958748db829df269f0368521ba6dc68cc172d5d03ed8060"}, + {file = "wrapt-1.16.0-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:72554a23c78a8e7aa02abbd699d129eead8b147a23c56e08d08dfc29cfdddca1"}, + {file = "wrapt-1.16.0-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:d2efee35b4b0a347e0d99d28e884dfd82797852d62fcd7ebdeee26f3ceb72cf3"}, + {file = "wrapt-1.16.0-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:6dcfcffe73710be01d90cae08c3e548d90932d37b39ef83969ae135d36ef3956"}, + {file = "wrapt-1.16.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:eb6e651000a19c96f452c85132811d25e9264d836951022d6e81df2fff38337d"}, + {file = "wrapt-1.16.0-cp311-cp311-win32.whl", hash = "sha256:66027d667efe95cc4fa945af59f92c5a02c6f5bb6012bff9e60542c74c75c362"}, + {file = "wrapt-1.16.0-cp311-cp311-win_amd64.whl", hash = "sha256:aefbc4cb0a54f91af643660a0a150ce2c090d3652cf4052a5397fb2de549cd89"}, + {file = "wrapt-1.16.0-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:5eb404d89131ec9b4f748fa5cfb5346802e5ee8836f57d516576e61f304f3b7b"}, + {file = "wrapt-1.16.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:9090c9e676d5236a6948330e83cb89969f433b1943a558968f659ead07cb3b36"}, + {file = "wrapt-1.16.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:94265b00870aa407bd0cbcfd536f17ecde43b94fb8d228560a1e9d3041462d73"}, + {file = "wrapt-1.16.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f2058f813d4f2b5e3a9eb2eb3faf8f1d99b81c3e51aeda4b168406443e8ba809"}, + {file = "wrapt-1.16.0-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:98b5e1f498a8ca1858a1cdbffb023bfd954da4e3fa2c0cb5853d40014557248b"}, + {file = "wrapt-1.16.0-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:14d7dc606219cdd7405133c713f2c218d4252f2a469003f8c46bb92d5d095d81"}, + {file = "wrapt-1.16.0-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:49aac49dc4782cb04f58986e81ea0b4768e4ff197b57324dcbd7699c5dfb40b9"}, + {file = "wrapt-1.16.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:418abb18146475c310d7a6dc71143d6f7adec5b004ac9ce08dc7a34e2babdc5c"}, + {file = "wrapt-1.16.0-cp312-cp312-win32.whl", hash = "sha256:685f568fa5e627e93f3b52fda002c7ed2fa1800b50ce51f6ed1d572d8ab3e7fc"}, + {file = "wrapt-1.16.0-cp312-cp312-win_amd64.whl", hash = "sha256:dcdba5c86e368442528f7060039eda390cc4091bfd1dca41e8046af7c910dda8"}, + {file = "wrapt-1.16.0-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:d462f28826f4657968ae51d2181a074dfe03c200d6131690b7d65d55b0f360f8"}, + {file = "wrapt-1.16.0-cp36-cp36m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a33a747400b94b6d6b8a165e4480264a64a78c8a4c734b62136062e9a248dd39"}, + {file = "wrapt-1.16.0-cp36-cp36m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b3646eefa23daeba62643a58aac816945cadc0afaf21800a1421eeba5f6cfb9c"}, + {file = "wrapt-1.16.0-cp36-cp36m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3ebf019be5c09d400cf7b024aa52b1f3aeebeff51550d007e92c3c1c4afc2a40"}, + {file = "wrapt-1.16.0-cp36-cp36m-musllinux_1_1_aarch64.whl", hash = "sha256:0d2691979e93d06a95a26257adb7bfd0c93818e89b1406f5a28f36e0d8c1e1fc"}, + {file = "wrapt-1.16.0-cp36-cp36m-musllinux_1_1_i686.whl", hash = "sha256:1acd723ee2a8826f3d53910255643e33673e1d11db84ce5880675954183ec47e"}, + {file = "wrapt-1.16.0-cp36-cp36m-musllinux_1_1_x86_64.whl", hash = "sha256:bc57efac2da352a51cc4658878a68d2b1b67dbe9d33c36cb826ca449d80a8465"}, + {file = "wrapt-1.16.0-cp36-cp36m-win32.whl", hash = "sha256:da4813f751142436b075ed7aa012a8778aa43a99f7b36afe9b742d3ed8bdc95e"}, + {file = "wrapt-1.16.0-cp36-cp36m-win_amd64.whl", hash = "sha256:6f6eac2360f2d543cc875a0e5efd413b6cbd483cb3ad7ebf888884a6e0d2e966"}, + {file = "wrapt-1.16.0-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:a0ea261ce52b5952bf669684a251a66df239ec6d441ccb59ec7afa882265d593"}, + {file = "wrapt-1.16.0-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7bd2d7ff69a2cac767fbf7a2b206add2e9a210e57947dd7ce03e25d03d2de292"}, + {file = "wrapt-1.16.0-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:9159485323798c8dc530a224bd3ffcf76659319ccc7bbd52e01e73bd0241a0c5"}, + {file = "wrapt-1.16.0-cp37-cp37m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a86373cf37cd7764f2201b76496aba58a52e76dedfaa698ef9e9688bfd9e41cf"}, + {file = "wrapt-1.16.0-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:73870c364c11f03ed072dda68ff7aea6d2a3a5c3fe250d917a429c7432e15228"}, + {file = "wrapt-1.16.0-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:b935ae30c6e7400022b50f8d359c03ed233d45b725cfdd299462f41ee5ffba6f"}, + {file = "wrapt-1.16.0-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:db98ad84a55eb09b3c32a96c576476777e87c520a34e2519d3e59c44710c002c"}, + {file = "wrapt-1.16.0-cp37-cp37m-win32.whl", hash = "sha256:9153ed35fc5e4fa3b2fe97bddaa7cbec0ed22412b85bcdaf54aeba92ea37428c"}, + {file = "wrapt-1.16.0-cp37-cp37m-win_amd64.whl", hash = "sha256:66dfbaa7cfa3eb707bbfcd46dab2bc6207b005cbc9caa2199bcbc81d95071a00"}, + {file = "wrapt-1.16.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:1dd50a2696ff89f57bd8847647a1c363b687d3d796dc30d4dd4a9d1689a706f0"}, + {file = "wrapt-1.16.0-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:44a2754372e32ab315734c6c73b24351d06e77ffff6ae27d2ecf14cf3d229202"}, + {file = "wrapt-1.16.0-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8e9723528b9f787dc59168369e42ae1c3b0d3fadb2f1a71de14531d321ee05b0"}, + {file = "wrapt-1.16.0-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:dbed418ba5c3dce92619656802cc5355cb679e58d0d89b50f116e4a9d5a9603e"}, + {file = "wrapt-1.16.0-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:941988b89b4fd6b41c3f0bfb20e92bd23746579736b7343283297c4c8cbae68f"}, + {file = "wrapt-1.16.0-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:6a42cd0cfa8ffc1915aef79cb4284f6383d8a3e9dcca70c445dcfdd639d51267"}, + {file = "wrapt-1.16.0-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:1ca9b6085e4f866bd584fb135a041bfc32cab916e69f714a7d1d397f8c4891ca"}, + {file = "wrapt-1.16.0-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:d5e49454f19ef621089e204f862388d29e6e8d8b162efce05208913dde5b9ad6"}, + {file = "wrapt-1.16.0-cp38-cp38-win32.whl", hash = "sha256:c31f72b1b6624c9d863fc095da460802f43a7c6868c5dda140f51da24fd47d7b"}, + {file = "wrapt-1.16.0-cp38-cp38-win_amd64.whl", hash = "sha256:490b0ee15c1a55be9c1bd8609b8cecd60e325f0575fc98f50058eae366e01f41"}, + {file = "wrapt-1.16.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:9b201ae332c3637a42f02d1045e1d0cccfdc41f1f2f801dafbaa7e9b4797bfc2"}, + {file = "wrapt-1.16.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:2076fad65c6736184e77d7d4729b63a6d1ae0b70da4868adeec40989858eb3fb"}, + {file = "wrapt-1.16.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c5cd603b575ebceca7da5a3a251e69561bec509e0b46e4993e1cac402b7247b8"}, + {file = "wrapt-1.16.0-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b47cfad9e9bbbed2339081f4e346c93ecd7ab504299403320bf85f7f85c7d46c"}, + {file = "wrapt-1.16.0-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f8212564d49c50eb4565e502814f694e240c55551a5f1bc841d4fcaabb0a9b8a"}, + {file = "wrapt-1.16.0-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:5f15814a33e42b04e3de432e573aa557f9f0f56458745c2074952f564c50e664"}, + {file = "wrapt-1.16.0-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:db2e408d983b0e61e238cf579c09ef7020560441906ca990fe8412153e3b291f"}, + {file = "wrapt-1.16.0-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:edfad1d29c73f9b863ebe7082ae9321374ccb10879eeabc84ba3b69f2579d537"}, + {file = "wrapt-1.16.0-cp39-cp39-win32.whl", hash = "sha256:ed867c42c268f876097248e05b6117a65bcd1e63b779e916fe2e33cd6fd0d3c3"}, + {file = "wrapt-1.16.0-cp39-cp39-win_amd64.whl", hash = "sha256:eb1b046be06b0fce7249f1d025cd359b4b80fc1c3e24ad9eca33e0dcdb2e4a35"}, + {file = "wrapt-1.16.0-py3-none-any.whl", hash = "sha256:6906c4100a8fcbf2fa735f6059214bb13b97f75b1a61777fcf6432121ef12ef1"}, + {file = "wrapt-1.16.0.tar.gz", hash = "sha256:5f370f952971e7d17c7d1ead40e49f32345a7f7a5373571ef44d800d06b1899d"}, +] + +[metadata] +lock-version = "2.0" +python-versions = "^3.11" +content-hash = "f195a0e14ec30b2323f58379c62a3c727862443ddf4b0f741d93f94ef621498b" diff --git a/frontend/backend/pyproject.toml b/frontend/backend/pyproject.toml new file mode 100644 index 00000000..37ee2130 --- /dev/null +++ b/frontend/backend/pyproject.toml @@ -0,0 +1,36 @@ +[tool.poetry] +name = "github-trends" +version = "0.1.0" +description = "" +authors = ["Abhijit Gupta "] +license = "MIT" + +[tool.poetry.dependencies] +python = "^3.11" +fastapi = "^0.104.1" +uvicorn = {extras = ["standard"], version = "^0.24.0.post1"} +requests = "^2.31.0" +python-dotenv = "^1.0.0" +motor = "^3.3.1" +aiofiles = "^23.2.1" +aiounittest = "^1.4.2" +coveralls = "^3.3.1" +grpcio = "^1.59.2" +gunicorn = "^21.2.0" +pymongo = {extras = ["srv"], version = "^4.6.0"} +pytz = "^2023.3.post1" +sentry-sdk = "^1.34.0" +svgwrite = "^1.4.3" + +[tool.poetry.dev-dependencies] + +[tool.poetry.group.dev.dependencies] +black = "^23.11.0" +flake8 = "^6.1.0" +isort = "^5.12.0" +pre-commit = "^3.5.0" +pyinstrument = "^4.6.1" + +[build-system] +requires = ["poetry-core>=1.0.0"] +build-backend = "poetry.core.masonry.api" diff --git a/frontend/backend/requirements.txt b/frontend/backend/requirements.txt new file mode 100644 index 00000000..3154fc20 --- /dev/null +++ b/frontend/backend/requirements.txt @@ -0,0 +1,39 @@ +aiofiles==23.2.1 +aiounittest==1.4.2 +annotated-types==0.6.0 +anyio==3.7.1 +certifi==2023.11.17 +charset-normalizer==3.3.2 +click==8.1.7 +colorama==0.4.6 +coverage==6.5.0 +coveralls==3.3.1 +dnspython==2.4.2 +docopt==0.6.2 +fastapi==0.104.1 +grpcio==1.59.3 +gunicorn==21.2.0 +h11==0.14.0 +httptools==0.6.1 +idna==3.4 +motor==3.3.2 +packaging==23.2 +pydantic-core==2.14.5 +pydantic==2.5.2 +pymongo==4.6.0 +pymongo[srv]==4.6.0 +python-dotenv==1.0.0 +pytz==2023.3.post1 +pyyaml==6.0.1 +requests==2.31.0 +sentry-sdk==1.36.0 +sniffio==1.3.0 +starlette==0.27.0 +svgwrite==1.4.3 +typing-extensions==4.8.0 +urllib3==2.1.0 +uvicorn[standard]==0.24.0.post1 +uvloop==0.19.0 +watchfiles==0.21.0 +websockets==12.0 +wrapt==1.16.0 diff --git a/frontend/backend/scripts/__init__.py b/frontend/backend/scripts/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/frontend/backend/scripts/delete_old_data.py b/frontend/backend/scripts/delete_old_data.py new file mode 100644 index 00000000..fcab4171 --- /dev/null +++ b/frontend/backend/scripts/delete_old_data.py @@ -0,0 +1,64 @@ +import asyncio +import os +import sys +from datetime import datetime +from typing import Any + +from dotenv import find_dotenv, load_dotenv + +load_dotenv(find_dotenv()) + +# Add the parent directory to the Python path +sys.path.append(os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))) + +# flake8: noqa E402 + +from src.constants import API_VERSION +from src.data.mongo.main import USER_MONTHS + + +def get_filters(cutoff_date: datetime) -> Any: + return { + "$or": [ + {"month": {"$lte": cutoff_date}}, + {"version": {"$ne": API_VERSION}}, + ], + } + + +async def count_old_rows(cutoff_date: datetime) -> int: + filters = get_filters(cutoff_date) + num_rows = len(await USER_MONTHS.find(filters).to_list(length=None)) # type: ignore + return num_rows + + +async def delete_old_rows(cutoff_date: datetime): + filters = get_filters(cutoff_date) + result = await USER_MONTHS.delete_many(filters) + print(f"Deleted {result.deleted_count} rows") + + +async def main(): + # Replace 'your_date_field' with the actual name of your date field + cutoff_date = datetime(2024, 12, 31) + + count = await count_old_rows(cutoff_date) + if count == 0: + print("No rows to delete.") + return + + print(f"Found {count} rows to delete.") + print() + + confirmation = input("Are you sure you want to delete these rows? (yes/no): ") + if confirmation.lower() != "yes": + print("Operation canceled.") + return + + print() + await delete_old_rows(cutoff_date) + + +if __name__ == "__main__": + loop = asyncio.get_event_loop() + loop.run_until_complete(main()) diff --git a/frontend/backend/scripts/local.py b/frontend/backend/scripts/local.py new file mode 100644 index 00000000..38665478 --- /dev/null +++ b/frontend/backend/scripts/local.py @@ -0,0 +1,104 @@ +import argparse +import asyncio +import json +import os +import sys +from datetime import datetime + +# Add the parent directory to the Python path +sys.path.append(os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))) +os.environ["LOCAL"] = "True" + +# flake8: noqa E402 + +from src.aggregation.layer0 import get_user_data +from src.processing.user import get_top_languages, get_top_repos +from src.processing.wrapped.package import get_wrapped_data + + +def parse_args(): + parser = argparse.ArgumentParser(description="GitHub Trends Script") + + parser.add_argument("--user_id", required=True, help="GitHub user ID", type=str) + parser.add_argument( + "--access_token", required=True, help="GitHub access token", type=str + ) + parser.add_argument( + "--start_date", + default="2023-01-01", + help="Start date in YYYY-MM-DD format", + type=str, + ) + parser.add_argument( + "--end_date", + default="2023-01-31", + help="End date in YYYY-MM-DD format", + type=str, + ) + parser.add_argument( + "--timezone", default="America/New_York", help="Timezone", type=str + ) + parser.add_argument( + "--output_dir", default="./", help="Output directory path", type=str + ) + + return parser.parse_args() + + +async def main(): + args = parse_args() + + start_date = datetime.strptime(args.start_date, "%Y-%m-%d") + end_date = datetime.strptime(args.end_date, "%Y-%m-%d") + + print("Local script running...") + print("User ID:", args.user_id) + print("Access token:", args.access_token) + print("Start date:", start_date) + print("End date:", end_date) + print("Timezone:", args.timezone) + print("Output directory:", args.output_dir) + print() + + raw_output = await get_user_data( + args.user_id, start_date, end_date, args.timezone, args.access_token + ) + + with open(os.path.join(args.output_dir, "raw.json"), "w") as f: + f.write(raw_output.model_dump_json(indent=2)) + + langs_output = get_top_languages( + raw_output, loc_metric="changed", include_private=True + ) + + langs_output = ( + [json.loads(x.model_dump_json()) for x in langs_output[0]], + langs_output[1], + ) + + repos_output = get_top_repos( + raw_output, loc_metric="changed", include_private=True, group="none" + ) + + repos_output = ( + [json.loads(x.model_dump_json()) for x in repos_output[0]], + repos_output[1], + ) + + with open(os.path.join(args.output_dir, "langs.json"), "w") as f: + f.write(json.dumps(langs_output, indent=2)) + + with open(os.path.join(args.output_dir, "repos.json"), "w") as f: + f.write(json.dumps(repos_output, indent=2)) + + wrapped_user = get_wrapped_data(raw_output, 2023) + + with open(os.path.join(args.output_dir, "wrapped.json"), "w") as f: + f.write(wrapped_user.model_dump_json(indent=2)) + + print("Wrote output to", args.output_dir) + + +if __name__ == "__main__": + loop = asyncio.get_event_loop() + loop.run_until_complete(main()) diff --git a/frontend/backend/src/__init__.py b/frontend/backend/src/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/frontend/backend/src/aggregation/layer0/__init__.py b/frontend/backend/src/aggregation/layer0/__init__.py new file mode 100644 index 00000000..9a4ac758 --- /dev/null +++ b/frontend/backend/src/aggregation/layer0/__init__.py @@ -0,0 +1,3 @@ +from src.aggregation.layer0.package import get_user_data + +__all__ = ["get_user_data"] diff --git a/frontend/backend/src/aggregation/layer0/contributions.py b/frontend/backend/src/aggregation/layer0/contributions.py new file mode 100644 index 00000000..9514a9aa --- /dev/null +++ b/frontend/backend/src/aggregation/layer0/contributions.py @@ -0,0 +1,498 @@ +from collections import defaultdict +from datetime import date, datetime +from typing import Any, Dict, List, Optional, Tuple, Union + +import pytz + +from src.aggregation.layer0.languages import CommitLanguages, get_commit_languages +from src.constants import ( + GRAPHQL_NODE_CHUNK_SIZE, + GRAPHQL_NODE_THREADS, + NODE_QUERIES, + PR_FILES, + REST_NODE_THREADS, +) +from src.data.github.graphql import ( + RawCalendar, + RawCommit as GraphQLRawCommit, + RawEventsCommit, + RawEventsEvent, + RawRepo, + get_commits, + get_repo, + get_user_contribution_calendar, + get_user_contribution_events, +) +from src.data.github.rest import ( + RawCommit as RESTRawCommit, + RawCommitFile, + get_commit_files, + get_repo_commits, +) +from src.models import UserContributions +from src.utils import date_to_datetime, gather + + +class ContribsList: + def __init__(self): + self.commits: List[RawEventsCommit] = [] + self.issues: List[RawEventsEvent] = [] + self.prs: List[RawEventsEvent] = [] + self.reviews: List[RawEventsEvent] = [] + self.repos: List[RawEventsEvent] = [] + + def add(self, label: str, event: Union[RawEventsCommit, RawEventsEvent]): + if label == "commit" and isinstance(event, RawEventsCommit): + self.commits.append(event) + elif label == "issue" and isinstance(event, RawEventsEvent): + self.issues.append(event) + elif label == "pr" and isinstance(event, RawEventsEvent): + self.prs.append(event) + elif label == "review" and isinstance(event, RawEventsEvent): + self.reviews.append(event) + elif label == "repo" and isinstance(event, RawEventsEvent): + self.repos.append(event) + + +def get_user_all_contribution_events( + user_id: str, + start_date: datetime, + end_date: datetime, + access_token: Optional[str] = None, +) -> Dict[str, ContribsList]: + repo_contribs: Dict[str, ContribsList] = defaultdict(lambda: ContribsList()) + after: Optional[str] = "" + cont = True + while cont: + after_str = after if isinstance(after, str) else "" + response = get_user_contribution_events( + user_id=user_id, + start_date=start_date, + end_date=end_date, + after=after_str, + access_token=access_token, + ) + + cont = False + node_lists = [ + ("commit", response.commit_contribs_by_repo), + ("issue", response.issue_contribs_by_repo), + ("pr", response.pr_contribs_by_repo), + ("review", response.review_contribs_by_repo), + ] + for event_type, event_list in node_lists: + for repo in event_list: + name = repo.repo.name + for event in repo.contribs.nodes: + repo_contribs[name].add(event_type, event) + if repo.contribs.page_info.has_next_page: + after = repo.contribs.page_info.end_cursor + cont = True + + for repo in response.repo_contribs.nodes: + name = repo.repo.name + node = RawEventsEvent(occurredAt=repo.occurred_at) + repo_contribs[name].add("repo", node) + + return repo_contribs + + +def get_all_commit_info( + user_id: str, + name_with_owner: str, + start_date: datetime, + end_date: datetime, + access_token: Optional[str] = None, +) -> List[RESTRawCommit]: + owner, repo = name_with_owner.split("/") + data: List[RESTRawCommit] = [] + for i in range(10): + if len(data) == 100 * i: + new_data = get_repo_commits( + owner, repo, user_id, start_date, end_date, i + 1, access_token + ) + data.extend(new_data) + + # sort ascending + return sorted(data, key=lambda x: x.timestamp) + + +async def get_all_commit_languages( + commit_infos: List[List[RESTRawCommit]], + repos: List[str], + repo_infos: Dict[str, RawRepo], + access_token: Optional[str] = None, + catch_errors: bool = False, +) -> Tuple[Dict[str, List[datetime]], Dict[str, List[CommitLanguages]]]: + commit_node_ids = [[x.node_id for x in repo] for repo in commit_infos] + commit_times = [[x.timestamp for x in repo] for repo in commit_infos] + + id_mapping: Dict[str, Tuple[int, int]] = {} + repo_mapping: Dict[str, str] = {} + all_node_ids: List[str] = [] + for i, repo_node_ids in enumerate(commit_node_ids): + for j, node_id in enumerate(repo_node_ids): + id_mapping[node_id] = (i, j) + repo_mapping[node_id] = repos[i] + all_node_ids.append(node_id) + + node_id_chunks: List[List[str]] = [ + all_node_ids[i : min(len(all_node_ids), i + GRAPHQL_NODE_CHUNK_SIZE)] + for i in range(0, len(all_node_ids), GRAPHQL_NODE_CHUNK_SIZE) + ] + + commit_language_chunks: List[List[Optional[GraphQLRawCommit]]] = await gather( + funcs=[get_commits for _ in node_id_chunks], + args_dicts=[ + { + "node_ids": node_id_chunk, + "access_token": access_token, + "catch_errors": catch_errors, + } + for node_id_chunk in node_id_chunks + ], + max_threads=GRAPHQL_NODE_THREADS, + ) + + temp_commit_languages: List[Optional[GraphQLRawCommit]] = [] + for commit_language_chunk in commit_language_chunks: + temp_commit_languages.extend(commit_language_chunk) + + # returns commits with no associated PR or incomplete PR + filtered_commits: List[GraphQLRawCommit] = filter( + lambda x: x is not None + and (len(x.prs.nodes) == 0 or x.prs.nodes[0].changed_files > PR_FILES) + and (x.additions + x.deletions > 100), + temp_commit_languages, + ) # type: ignore + + # get NODE_QUERIES largest commits with no associated PR or incomplete PR + sorted_commits = sorted( + filtered_commits, key=lambda x: x.additions + x.deletions, reverse=True + )[:NODE_QUERIES] + + sorted_commit_urls = [commit.url.split("/") for commit in sorted_commits] + commit_files: List[List[RawCommitFile]] = await gather( + funcs=[get_commit_files for _ in sorted_commit_urls], + args_dicts=[ + { + "owner": url[3], + "repo": url[4], + "sha": url[6], + "access_token": access_token, + } + for url in sorted_commit_urls + ], + max_threads=REST_NODE_THREADS, + ) + + commit_files_dict: Dict[str, List[RawCommitFile]] = { + commit.url: commit_file + for commit, commit_file in zip(sorted_commits, commit_files) + } + + commit_languages: List[List[CommitLanguages]] = [ + [CommitLanguages() for _ in repo] for repo in commit_infos + ] + for raw_commits, node_ids in zip(commit_language_chunks, node_id_chunks): + for raw_commit, node_id in zip(raw_commits, node_ids): + curr_commit_files: Optional[List[RawCommitFile]] = None + if raw_commit is not None and raw_commit.url in commit_files_dict: + curr_commit_files = commit_files_dict[raw_commit.url] + lang_breakdown = get_commit_languages( + raw_commit, curr_commit_files, repo_infos[repo_mapping[node_id]] + ) + i, j = id_mapping[node_id] + commit_languages[i][j] = lang_breakdown + + commit_times_dict: Dict[str, List[datetime]] = {} + commit_languages_dict: Dict[str, List[CommitLanguages]] = {} + for repo, times, languages in zip(repos, commit_times, commit_languages): + commit_times_dict[repo] = times + commit_languages_dict[repo] = languages + + return commit_times_dict, commit_languages_dict + + +async def get_cleaned_contributions( + user_id: str, + start_date: datetime, + end_date: datetime, + access_token: Optional[str], + catch_errors: bool = False, +) -> Tuple[ + RawCalendar, + Dict[str, ContribsList], + Dict[str, RawRepo], + Dict[str, List[datetime]], + Dict[str, List[CommitLanguages]], +]: + calendar = get_user_contribution_calendar( + user_id, start_date, end_date, access_token + ) + contrib_events = get_user_all_contribution_events( + user_id, start_date, end_date, access_token + ) + repos: List[str] = list(set(contrib_events.keys())) + + commit_infos: List[List[RESTRawCommit]] = await gather( + funcs=[get_all_commit_info for _ in repos], + args_dicts=[ + { + "user_id": user_id, + "name_with_owner": repo, + "start_date": start_date, + "end_date": end_date, + "access_token": access_token, + } + for repo in repos + ], + max_threads=REST_NODE_THREADS, + ) + + _repo_infos: List[Optional[RawRepo]] = await gather( + funcs=[get_repo for _ in repos], + args_dicts=[ + { + "owner": repo.split("/")[0], + "repo": repo.split("/")[1], + "access_token": access_token, + "catch_errors": catch_errors, + } + for repo in repos + ], + max_threads=REST_NODE_THREADS, + ) + + repo_infos = {k: v for k, v in zip(repos, _repo_infos) if v is not None} + + commit_times_dict, commit_languages_dict = await get_all_commit_languages( + commit_infos, + repos, + repo_infos, + access_token, + catch_errors, + ) + + return ( + calendar, + contrib_events, + repo_infos, + commit_times_dict, + commit_languages_dict, + ) + + +class StatsContainer: + def __init__(self): + self.contribs: int = 0 + self.commits: int = 0 + self.issues: int = 0 + self.prs: int = 0 + self.reviews: int = 0 + self.repos: int = 0 + self.other: int = 0 + self.languages = CommitLanguages() + + def add_stat(self, label: str, count: int, add: bool = False) -> None: + if label == "commit": + self.commits += count + elif label == "issue": + self.issues += count + elif label == "pr": + self.prs += count + elif label == "review": + self.reviews += count + elif label == "repo": + self.repos += count + + if add: + self.contribs += count + else: + self.other -= count + + def to_dict(self) -> Dict[str, Any]: + return { + "contribs_count": self.contribs, + "commits_count": self.commits, + "issues_count": self.issues, + "prs_count": self.prs, + "reviews_count": self.reviews, + "repos_count": self.repos, + "other_count": self.other, + "languages": self.languages.to_dict(), + } + + +class ListsContainer: + def __init__(self): + self.commits: List[datetime] = [] + self.issues: List[datetime] = [] + self.prs: List[datetime] = [] + self.reviews: List[datetime] = [] + self.repos: List[datetime] = [] + + def add_list(self, label: str, times: List[datetime]) -> None: + if label == "commit": + self.commits.extend(times) + elif label == "issue": + self.issues.extend(times) + elif label == "pr": + self.prs.extend(times) + elif label == "review": + self.reviews.extend(times) + elif label == "repo": + self.repos.extend(times) + + def to_dict(self) -> Dict[str, Any]: + return { + "commits": self.commits, + "issues": self.issues, + "prs": self.prs, + "reviews": self.reviews, + "repos": self.repos, + } + + +class DateContainer: + def __init__(self): + self.date = "" + self.weekday = 0 + self.stats = StatsContainer() + self.lists = ListsContainer() + + def add_stat( + self, label: str, count: int, times: List[datetime], add: bool = False + ): + self.stats.add_stat(label, count, add) + self.lists.add_list(label, times) + + def to_dict(self) -> Dict[str, Any]: + return { + "date": self.date, + "weekday": self.weekday, + "stats": self.stats.to_dict(), + "lists": self.lists.to_dict(), + } + + +# assumed one month span, can be no more than one year +async def get_contributions( + user_id: str, + start_date: date, + end_date: date, + timezone_str: str = "US/Eastern", + access_token: Optional[str] = None, + catch_errors: bool = False, +) -> UserContributions: + tz = pytz.timezone(timezone_str) + + start_month = date_to_datetime(start_date) + end_month = date_to_datetime(end_date, hour=23, minute=59, second=59) + ( + calendar, + contrib_events, + repo_infos, + commit_times_dict, + commit_languages_dict, + ) = await get_cleaned_contributions( + user_id, start_month, end_month, access_token, catch_errors + ) + + total_stats = StatsContainer() + public_stats = StatsContainer() + total: Dict[str, DateContainer] = defaultdict(DateContainer) + public: Dict[str, DateContainer] = defaultdict(DateContainer) + repo_stats: Dict[str, StatsContainer] = defaultdict(StatsContainer) + repositories: Dict[str, Dict[str, DateContainer]] = defaultdict( + lambda: defaultdict(DateContainer) + ) + + for week in calendar.weeks: + for day in week.contribution_days: + day_str = str(day.date) + for obj, stats_obj in [(total, total_stats), (public, public_stats)]: + obj[day_str].date = day.date.isoformat() + obj[day_str].weekday = day.weekday + obj[day_str].stats.contribs = day.count + obj[day_str].stats.other = day.count + stats_obj.contribs += day.count + stats_obj.other += day.count + + def update_stats( + date_str: str, repo: str, event: str, count: int, times: List[datetime] + ): + # update global counts for this event + total[date_str].add_stat(event, count, times) + total_stats.add_stat(event, count) + if not repo_infos[repo].is_private: + public[date_str].add_stat(event, count, times) + public_stats.add_stat(event, count) + repositories[repo][date_str].add_stat(event, count, times, add=True) + repo_stats[repo].add_stat(event, count, add=True) + + def update_langs(date_str: str, repo: str, langs: CommitLanguages): + stores = [ + total[date_str].stats.languages, + total_stats.languages, + repositories[repo][date_str].stats.languages, + repo_stats[repo].languages, + ] + if not repo_infos[repo].is_private: + stores.append(public[date_str].stats.languages) + stores.append(public_stats.languages) + + for store in stores: + store += langs + + for repo, repo_events in contrib_events.items(): + for label, events in [ + ("commit", repo_events.commits), + ("issue", repo_events.issues), + ("pr", repo_events.prs), + ("review", repo_events.reviews), + ("repo", repo_events.repos), + ]: + events = sorted(events, key=lambda x: x.occurred_at) + for event in events: + datetime_obj = event.occurred_at.astimezone(tz) + date_str = datetime_obj.date().isoformat() + repositories[repo][date_str].date = date_str + if isinstance(event, RawEventsCommit): + count = 0 + commit_times: List[datetime] = [] + while len(commit_languages_dict[repo]) > 0 and count < event.count: + commit_times.append(commit_times_dict[repo].pop(0)) + langs = commit_languages_dict[repo].pop(0) + update_langs(date_str, repo, langs) + count += 1 + update_stats(date_str, repo, "commit", event.count, commit_times) + else: + update_stats(date_str, repo, label, 1, [datetime_obj]) + + total_stats_dict = total_stats.to_dict() + public_stats_dict = public_stats.to_dict() + repo_stats_dict = {name: stats.to_dict() for name, stats in repo_stats.items()} + + for repo in repo_stats: + repo_stats_dict[repo]["private"] = repo_infos[repo].is_private + + total_list = [v.to_dict() for v in total.values() if v.stats.contribs > 0] + public_list = [v.to_dict() for v in public.values() if v.stats.contribs > 0] + repositories_list = { + name: [v.to_dict() for v in repo.values()] + for name, repo in repositories.items() + } + + output = UserContributions.model_validate( + { + "total_stats": total_stats_dict, + "public_stats": public_stats_dict, + "total": total_list, + "public": public_list, + "repo_stats": repo_stats_dict, + "repos": repositories_list, + } + ) + + return output diff --git a/frontend/backend/src/aggregation/layer0/follows.py b/frontend/backend/src/aggregation/layer0/follows.py new file mode 100644 index 00000000..77dbe345 --- /dev/null +++ b/frontend/backend/src/aggregation/layer0/follows.py @@ -0,0 +1,39 @@ +from typing import List, Optional + +from src.data.github.graphql import ( + get_user_followers as _get_user_followers, + get_user_following as _get_user_following, +) +from src.models import User, UserFollows + + +def get_user_follows(user_id: str, access_token: Optional[str]) -> UserFollows: + """get user followers and users following for given user""" + + followers: List[User] = [] + following: List[User] = [] + + for user_list, get_func in zip( + [followers, following], [_get_user_followers, _get_user_following] + ): + after: Optional[str] = "" + index, cont = 0, True # initialize variables + while cont and index < 10: + after_str: str = after if isinstance(after, str) else "" + data = get_func(user_id, after=after_str, access_token=access_token) + + cont = False + + user_list.extend( + map( + lambda x: User(name=x.name, login=x.login, url=x.url), + data.nodes, + ) + ) + if data.page_info.has_next_page: + after = data.page_info.end_cursor + cont = True + + index += 1 + + return UserFollows(followers=followers, following=following) diff --git a/frontend/backend/src/aggregation/layer0/languages.py b/frontend/backend/src/aggregation/layer0/languages.py new file mode 100644 index 00000000..bb3a37c4 --- /dev/null +++ b/frontend/backend/src/aggregation/layer0/languages.py @@ -0,0 +1,118 @@ +from json import load +from typing import Any, Dict, List, Optional, Union + +from src.constants import BLACKLIST, CUTOFF, DEFAULT_COLOR, FILE_CUTOFF +from src.data.github.graphql import RawCommit, RawRepo +from src.data.github.rest import RawCommitFile + +EXTENSIONS: Dict[str, Dict[str, str]] = load(open("./src/data/github/extensions.json")) + + +class CommitLanguages: + def __init__(self): + self.langs: Dict[str, Dict[str, Union[str, int]]] = {} + + def __repr__(self): + return str(self.langs) + + def add_lines( + self, name: str, color: Optional[str], additions: int, deletions: int + ): + if ( + name not in BLACKLIST + and max(additions, deletions) > 0 + and max(additions, deletions) < FILE_CUTOFF + ): + color = color or DEFAULT_COLOR + if name not in self.langs: + self.langs[name] = {"color": color, "additions": 0, "deletions": 0} + self.langs[name]["additions"] += additions # type: ignore + self.langs[name]["deletions"] += deletions # type: ignore + + def normalize(self, add_ratio: float, del_ratio: float): + for lang in self.langs: + new_add = round(int(self.langs[lang]["additions"]) * add_ratio) + self.langs[lang]["additions"] = new_add + new_del = round(int(self.langs[lang]["deletions"]) * del_ratio) + self.langs[lang]["deletions"] = new_del + + def __add__(self, other: "CommitLanguages"): + for lang in other.langs: + if lang not in self.langs: + self.langs[lang] = other.langs[lang].copy() + else: + self.langs[lang]["additions"] += other.langs[lang]["additions"] # type: ignore + self.langs[lang]["deletions"] += other.langs[lang]["deletions"] # type: ignore + + def to_dict(self) -> Dict[str, Any]: + return self.langs + + +def get_commit_languages( + commit: Optional[RawCommit], + files: Optional[List[RawCommitFile]], + repo: RawRepo, +) -> CommitLanguages: + out = CommitLanguages() + + if commit is None: + return out + + if max(commit.additions, commit.deletions) == 0: + return out + + # assummed to be auto-generated or copied + if max(commit.additions, commit.deletions) > 10 * CUTOFF or ( + max(commit.additions, commit.deletions) > CUTOFF + and min(commit.additions, commit.deletions) == 0 + ): + return out + + pr_coverage = 0 + if len(commit.prs.nodes) > 0: + pr_obj = commit.prs.nodes[0] + pr_files = pr_obj.files.nodes + total_changed = sum(x.additions + x.deletions for x in pr_files) + pr_coverage = total_changed / max(1, (pr_obj.additions + pr_obj.deletions)) + + if files is not None: + for file in files: + filename = file.filename.split(".") + extension = "" if len(filename) <= 1 else filename[-1] + lang = EXTENSIONS.get(f".{extension}", None) + if lang is not None: + out.add_lines( + lang["name"], lang["color"], file.additions, file.deletions + ) + elif len(commit.prs.nodes) > 0 and pr_coverage > 0.25: + pr = commit.prs.nodes[0] + total_additions, total_deletions = 0, 0 + for file in pr.files.nodes: + filename = file.path.split(".") + extension = "" if len(filename) <= 1 else filename[-1] + lang = EXTENSIONS.get(f".{extension}", None) + if lang is not None: + out.add_lines( + lang["name"], lang["color"], file.additions, file.deletions + ) + total_additions += file.additions + total_deletions += file.deletions + add_ratio = min(pr.additions, commit.additions) / max(1, total_additions) + del_ratio = min(pr.deletions, commit.deletions) / max(1, total_deletions) + out.normalize(add_ratio, del_ratio) + elif commit.additions + commit.deletions > 2 * CUTOFF: + # assummed to be auto generated + return out + else: + repo_info = repo.languages.edges + languages = [x for x in repo_info if x.node.name not in BLACKLIST] + total_repo_size = sum(language.size for language in languages) + for language in languages: + lang_name = language.node.name + lang_color = language.node.color + lang_size = language.size + additions = round(commit.additions * lang_size / total_repo_size) + deletions = round(commit.deletions * lang_size / total_repo_size) + out.add_lines(lang_name, lang_color, additions, deletions) + + return out diff --git a/frontend/backend/src/aggregation/layer0/package.py b/frontend/backend/src/aggregation/layer0/package.py new file mode 100644 index 00000000..469996ff --- /dev/null +++ b/frontend/backend/src/aggregation/layer0/package.py @@ -0,0 +1,28 @@ +from datetime import date +from typing import Optional + +from src.aggregation.layer0.contributions import get_contributions +from src.models import UserPackage + +# from src.subscriber.aggregation.user.follows import get_user_follows + + +async def get_user_data( + user_id: str, + start_date: date, + end_date: date, + timezone_str: str, + access_token: Optional[str], + catch_errors: bool = False, +) -> UserPackage: + """packages all processing steps for the user query""" + + contribs = await get_contributions( + user_id=user_id, + start_date=start_date, + end_date=end_date, + timezone_str=timezone_str, + access_token=access_token, + catch_errors=catch_errors, + ) + return UserPackage(contribs=contribs) diff --git a/frontend/backend/src/aggregation/layer1/__init__.py b/frontend/backend/src/aggregation/layer1/__init__.py new file mode 100644 index 00000000..204d5445 --- /dev/null +++ b/frontend/backend/src/aggregation/layer1/__init__.py @@ -0,0 +1,3 @@ +from src.aggregation.layer1.user import query_user + +__all__ = ["query_user"] diff --git a/frontend/backend/src/aggregation/layer1/auth.py b/frontend/backend/src/aggregation/layer1/auth.py new file mode 100644 index 00000000..31a2006c --- /dev/null +++ b/frontend/backend/src/aggregation/layer1/auth.py @@ -0,0 +1,61 @@ +from datetime import timedelta +from typing import List, Optional, Tuple + +from src.constants import OWNER, REPO +from src.data.github.rest import ( + RESTError, + RESTErrorNotFound, + get_repo_stargazers as github_get_repo_stargazers, + get_user as github_get_user, + get_user_starred_repos as github_get_user_starred_repos, +) +from src.data.github.utils import get_access_token +from src.data.mongo.user import get_public_user as db_get_public_user +from src.utils import alru_cache + + +async def get_valid_github_user(user_id: str) -> Optional[str]: + access_token = get_access_token() + try: + return github_get_user(user_id, access_token)["login"] + except (RESTErrorNotFound, KeyError): + # User does not exist + return None + except RESTError: + # Rate limited, so assume user exists + return user_id + + +async def get_valid_db_user(user_id: str) -> bool: + user = await db_get_public_user(user_id) + return user is not None + + +@alru_cache(ttl=timedelta(minutes=15)) +async def get_repo_stargazers( + owner: str = OWNER, repo: str = REPO, no_cache: bool = False +) -> Tuple[bool, List[str]]: + access_token = get_access_token() + data: List[str] = [] + page = 0 + while len(data) == 100 * page: + temp_data = github_get_repo_stargazers(access_token, owner, repo, page=page) + temp_data = [x["user"]["login"] for x in temp_data] + data.extend(temp_data) + page += 1 + + return (True, data) + + +async def get_user_stars(user_id: str) -> List[str]: + access_token = get_access_token() + try: + data = github_get_user_starred_repos(user_id, access_token) + data = [x["repo"]["full_name"] for x in data] + return data + except RESTErrorNotFound: + # User does not exist (and rate limited previously) + return [] + except RESTError: + # Rate limited, so assume user starred repo + return [f"{OWNER}/{REPO}"] diff --git a/frontend/backend/src/aggregation/layer1/user.py b/frontend/backend/src/aggregation/layer1/user.py new file mode 100644 index 00000000..59dc4ea4 --- /dev/null +++ b/frontend/backend/src/aggregation/layer1/user.py @@ -0,0 +1,125 @@ +from calendar import monthrange +from datetime import date, datetime, timedelta +from typing import List, Optional, Tuple + +import requests + +from src.aggregation.layer0.package import get_user_data +from src.constants import API_VERSION # , BACKEND_URL, PROD +from src.data.github.graphql import GraphQLErrorRateLimit +from src.data.mongo.secret import update_keys +from src.data.mongo.user_months import UserMonth, get_user_months, set_user_month +from src.models.user.main import UserPackage +from src.utils import alru_cache, date_to_datetime + +s = requests.Session() + + +# Formerly the subscriber, compute and save new data here + + +async def query_user_month( + user_id: str, + access_token: Optional[str], + private_access: bool, + start_date: date, + retries: int = 0, +) -> Optional[UserMonth]: + year, month = start_date.year, start_date.month + end_day = monthrange(year, month)[1] + end_date = date(year, month, end_day) + + try: + data = await get_user_data( + user_id=user_id, + start_date=start_date, + end_date=end_date, + timezone_str="US/Eastern", + access_token=access_token, + catch_errors=retries > 0, + ) + except GraphQLErrorRateLimit: + return None + except Exception: + # Retry, catching exceptions and marking incomplete this time + if retries < 1: + await query_user_month( + user_id, access_token, private_access, start_date, retries + 1 + ) + return None + + month_completed = datetime.now() > date_to_datetime(end_date) + timedelta(days=1) + user_month = UserMonth.model_validate( + { + "user_id": user_id, + "month": date_to_datetime(start_date), + "version": API_VERSION, + "private": private_access, + "complete": retries == 0 and month_completed, + "data": data, + } + ) + + await set_user_month(user_month) + return user_month + + +@alru_cache(ttl=timedelta(hours=6)) +async def query_user( + user_id: str, + access_token: Optional[str], + private_access: bool = False, + start_date: date = date.today() - timedelta(365), + end_date: date = date.today(), + max_time: int = 3600, # seconds + no_cache: bool = False, +) -> Tuple[bool, UserPackage]: + # Return (possibly incomplete) within max_time seconds + start_time = datetime.now() + incomplete = False + + await update_keys() + + curr_data: List[UserMonth] = await get_user_months( + user_id, private_access, start_date, end_date + ) + curr_months = [x.month for x in curr_data if x.complete] + + month, year = start_date.month, start_date.year + new_months: List[date] = [] + while date(year, month, 1) <= end_date: + start = date(year, month, 1) + if date_to_datetime(start) not in curr_months: + new_months.append(start) + month = month % 12 + 1 + year = year + (month == 1) + + # Start with complete months and add any incomplete months + all_user_packages: List[UserPackage] = [x.data for x in curr_data if x.complete] + for month in new_months: + if datetime.now() - start_time < timedelta(seconds=max_time): + temp = await query_user_month(user_id, access_token, private_access, month) + if temp is not None: + all_user_packages.append(temp.data) + else: + incomplete = True + else: + incomplete = True + + out: UserPackage = UserPackage.empty() + if len(all_user_packages) > 0: + out = all_user_packages[0] + for user_package in all_user_packages[1:]: + out += user_package + out.incomplete = incomplete + + if incomplete or len(new_months) > 1: + # TODO: figure out why this causes an infinite loop + # # cache buster for publisher + # if PROD: + # s.get(f"{BACKEND_URL}/user/{user_id}?no_cache=True") + + return (False, out) + + # only cache if just the current month updated + return (True, out) diff --git a/frontend/backend/src/aggregation/layer2/__init__.py b/frontend/backend/src/aggregation/layer2/__init__.py new file mode 100644 index 00000000..13c2a315 --- /dev/null +++ b/frontend/backend/src/aggregation/layer2/__init__.py @@ -0,0 +1,4 @@ +from src.aggregation.layer2.auth import get_is_valid_user +from src.aggregation.layer2.user import get_user, get_user_demo + +__all__ = ["get_is_valid_user", "get_user", "get_user_demo"] diff --git a/frontend/backend/src/aggregation/layer2/auth.py b/frontend/backend/src/aggregation/layer2/auth.py new file mode 100644 index 00000000..25bae8d9 --- /dev/null +++ b/frontend/backend/src/aggregation/layer2/auth.py @@ -0,0 +1,57 @@ +from datetime import timedelta +from typing import Optional, Tuple + +from src.aggregation.layer1.auth import ( + get_repo_stargazers, + get_user_stars, + get_valid_db_user, + get_valid_github_user, +) +from src.constants import OWNER, REPO, USER_BLACKLIST, USER_WHITELIST +from src.data.github.rest import RESTError +from src.utils import alru_cache + + +async def check_github_user_exists(user_id: str) -> Optional[str]: + return await get_valid_github_user(user_id) + + +async def check_db_user_exists(user_id: str) -> bool: + return await get_valid_db_user(user_id) + + +async def check_user_starred_repo( + user_id: str, owner: str = OWNER, repo: str = REPO +) -> bool: + # Checks the repo's starred users (with cache) + try: + repo_stargazers = await get_repo_stargazers(owner, repo) + if user_id in repo_stargazers: + return True + except RESTError: + return True # Assume the user has starred the repo + + # Checks the user's 30 most recent starred repos (no cache) + user_stars = await get_user_stars(user_id) + return f"{owner}/{repo}" in user_stars + + +@alru_cache(ttl=timedelta(hours=1)) +async def get_is_valid_user(user_id: str) -> Tuple[bool, str]: + if user_id.lower() in USER_BLACKLIST: + # TODO: change error message + return (False, "GitHub user not found") + + if user_id.lower() in USER_WHITELIST: + return (True, f"Valid user {user_id.lower()}") + + valid_user_id = await check_github_user_exists(user_id) + if valid_user_id is None: + return (False, "GitHub user not found") + + valid_db_user = await check_db_user_exists(valid_user_id) + user_starred = await check_user_starred_repo(valid_user_id) + if not (user_starred or valid_db_user): + return (False, "Repo not starred") + + return (True, f"Valid user {valid_user_id}") diff --git a/frontend/backend/src/aggregation/layer2/user.py b/frontend/backend/src/aggregation/layer2/user.py new file mode 100644 index 00000000..cf256601 --- /dev/null +++ b/frontend/backend/src/aggregation/layer2/user.py @@ -0,0 +1,79 @@ +from datetime import date, timedelta +from typing import Optional, Tuple + +from src.aggregation.layer0 import get_user_data +from src.constants import USER_BLACKLIST +from src.data.mongo.secret.functions import update_keys +from src.data.mongo.user import PublicUserModel, get_public_user as db_get_public_user +from src.data.mongo.user_months import get_user_months +from src.models import UserPackage +from src.models.background import UpdateUserBackgroundTask +from src.utils import alru_cache + +# Formerly the publisher, loads existing data here + + +async def _get_user( + user_id: str, private_access: bool, start_date: date, end_date: date +) -> Tuple[Optional[UserPackage], bool]: + user_months = await get_user_months(user_id, private_access, start_date, end_date) + if len(user_months) == 0: + return None, False + + expected_num_months = ( + (end_date.year - start_date.year) * 12 + (end_date.month - start_date.month) + 1 + ) + complete = len(user_months) == expected_num_months + + user_data = user_months[0].data + for user_month in user_months[1:]: + user_data += user_month.data + + # TODO: handle timezone_str here + return user_data.trim(start_date, end_date), complete + + +@alru_cache() +async def get_user( + user_id: str, + start_date: date, + end_date: date, + no_cache: bool = False, +) -> Tuple[ + bool, Tuple[Optional[UserPackage], bool, Optional[UpdateUserBackgroundTask]] +]: + if user_id in USER_BLACKLIST: + return (False, (None, False, None)) + + user: Optional[PublicUserModel] = await db_get_public_user(user_id) + if user is None: + return (False, (None, False, None)) + + private_access = user.private_access or False + user_data, complete = await _get_user(user_id, private_access, start_date, end_date) + background_task = UpdateUserBackgroundTask( + user_id=user_id, + access_token=user.access_token, + private_access=private_access, + start_date=start_date, + end_date=end_date, + ) + return (complete, (user_data, complete, background_task)) + + +@alru_cache(ttl=timedelta(minutes=15)) +async def get_user_demo( + user_id: str, start_date: date, end_date: date, no_cache: bool = False +) -> Tuple[bool, UserPackage]: + await update_keys() + timezone_str = "US/Eastern" + # recompute/cache but don't save to db + data = await get_user_data( + user_id=user_id, + start_date=start_date, + end_date=end_date, + timezone_str=timezone_str, + access_token=None, + catch_errors=True, + ) + return (True, data) diff --git a/frontend/backend/src/constants.py b/frontend/backend/src/constants.py new file mode 100644 index 00000000..9066fe05 --- /dev/null +++ b/frontend/backend/src/constants.py @@ -0,0 +1,74 @@ +import os + +# GLOBAL +LOCAL = os.getenv("LOCAL", "False") == "True" +PROD = os.getenv("PROD", "False") == "True" +PROJECT_ID = "github-334619" +BACKEND_URL = "https://api.githubtrends.io" if PROD else "http://localhost:8000" + +OWNER = "avgupta456" +REPO = "github-trends" + +# API +# https://docs.github.com/en/rest/reference/rate-limit +# https://docs.github.com/en/rest/guides/best-practices-for-integrators#dealing-with-secondary-rate-limits +# https://docs.github.com/en/graphql/overview/resource-limitations +TIMEOUT = 15 # max seconds to wait for api response +GRAPHQL_NODE_CHUNK_SIZE = 50 # number of nodes (commits) to query (max 100) +GRAPHQL_NODE_THREADS = 5 # number of node queries simultaneously (avoid blacklisting) +REST_NODE_THREADS = 50 # number of node queries simultaneously (avoid blacklisting) + +PR_FILES = 5 # max number of files to query for PRs +NODE_QUERIES = 20 # max number of node queries to make +CUTOFF = 1000 # if additions or deletions > CUTOFF, or sum > 2 * CUTOFF, ignore LOC +FILE_CUTOFF = 1000 # if less than cutoff in file, count LOC + +API_VERSION = 0.02 # determines when to overwrite MongoDB data + +# CUSTOMIZATION +BLACKLIST = ["Jupyter Notebook", "HTML"] # languages to ignore + +# OAUTH +prefix = "PROD" if PROD else "DEV" +# client ID for GitHub OAuth App +OAUTH_CLIENT_ID = os.getenv(f"{prefix}_OAUTH_CLIENT_ID", "") +# client secret for App +OAUTH_CLIENT_SECRET = os.getenv(f"{prefix}_OAUTH_CLIENT_SECRET", "") +# redirect uri for App +OAUTH_REDIRECT_URI = os.getenv(f"{prefix}_OAUTH_REDIRECT_URI", "") + +# MONGODB +MONGODB_PASSWORD = os.getenv("MONGODB_PASSWORD", "") + +# SVG +DEFAULT_COLOR = "#858585" + +# SENTRY +SENTRY_DSN = os.getenv("SENTRY_DSN", "") + +# TESTING +TEST_USER_ID = "avgupta456" +TEST_REPO = "github-trends" +TEST_TOKEN = os.getenv("AUTH_TOKEN", "") # for authentication +TEST_NODE_IDS = [ + "C_kwDOENp939oAKGM1MzdlM2QzMTZjMmEyZGIyYWU4ZWI0MmNmNjQ4YWEwNWQ5OTBiMjM", + "C_kwDOD_-BVNoAKDFhNTIxNWE1MGM4ZDllOGEwYTFhNjhmYWZkYzE5MzA5YTRkMDMwZmM", + "C_kwDOD_-BVNoAKDRiZTQ4MTQ0MzgwYjBlNGEwNjQ4YjY4YWI4ZjFjYmQ3MWU4M2VhMzU", +] +TEST_SHA = "ad83e6340377904fa0295745b5314202b23d2f3f" + +# WRAPPED + +# example users, don't need to star the repo +USER_WHITELIST = [ + "torvalds", + "yyx990803", + "shadcn", + "sindresorhus", +] + +USER_BLACKLIST = ["kangmingtay", "ae7er", "stalukdar7", "piyush7833"] + +print("PROD", PROD) +print("API_VERSION", API_VERSION) +print() diff --git a/frontend/backend/src/data/__init__.py b/frontend/backend/src/data/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/frontend/backend/src/data/github/__init__.py b/frontend/backend/src/data/github/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/frontend/backend/src/data/github/auth/__init__.py b/frontend/backend/src/data/github/auth/__init__.py new file mode 100644 index 00000000..1a576063 --- /dev/null +++ b/frontend/backend/src/data/github/auth/__init__.py @@ -0,0 +1,3 @@ +from src.data.github.auth.main import authenticate + +__all__ = ["authenticate"] diff --git a/frontend/backend/src/data/github/auth/main.py b/frontend/backend/src/data/github/auth/main.py new file mode 100644 index 00000000..175a476e --- /dev/null +++ b/frontend/backend/src/data/github/auth/main.py @@ -0,0 +1,57 @@ +from datetime import datetime +from typing import Dict, Optional, Tuple + +import requests + +from src.constants import OAUTH_CLIENT_ID, OAUTH_CLIENT_SECRET, OAUTH_REDIRECT_URI + +s = requests.session() + + +def get_unknown_user(access_token: str) -> Optional[str]: + """ + Accepts access_token and returns user_id of associated user + :param access_token: GitHub access token + :return: user_id or None if invalid access_token + """ + headers: Dict[str, str] = { + "Accept": "application/vnd.github.v3+json", + "Authorization": f"bearer {access_token}", + } + + r = s.get("https://api.github.com/user", params={}, headers=headers) + return r.json().get("login", None) + + +class OAuthError(Exception): + pass + + +async def authenticate(code: str) -> Tuple[str, str]: + """ + Takes a authentication code, verifies, and returns user_id/access_token + :param code: GitHub authentication code from OAuth process + :return: user_id, access_token of authenticated user + """ + start = datetime.now() + + params = { + "client_id": OAUTH_CLIENT_ID, + "client_secret": OAUTH_CLIENT_SECRET, + "code": code, + "redirect_uri": OAUTH_REDIRECT_URI, + } + + r = s.post("https://github.com/login/oauth/access_token", params=params) + + if r.status_code != 200: + raise OAuthError(f"OAuth Error: {str(r.status_code)}") + + access_token = r.text.split("&")[0].split("=")[1] + user_id = get_unknown_user(access_token) + + if user_id is None: + raise OAuthError("OAuth Error: Invalid user_id/access_token") + + print("OAuth SignUp", datetime.now() - start) + return user_id, access_token diff --git a/frontend/backend/src/data/github/extensions.json b/frontend/backend/src/data/github/extensions.json new file mode 100644 index 00000000..201c4173 --- /dev/null +++ b/frontend/backend/src/data/github/extensions.json @@ -0,0 +1,3326 @@ +{ + ".1": { + "color": "#ecdebe", + "name": "Roff Manpage" + }, + ".1in": { + "color": "#ecdebe", + "name": "Roff Manpage" + }, + ".1m": { + "color": "#ecdebe", + "name": "Roff Manpage" + }, + ".1x": { + "color": "#ecdebe", + "name": "Roff Manpage" + }, + ".2": { + "color": "#ecdebe", + "name": "Roff Manpage" + }, + ".3": { + "color": "#ecdebe", + "name": "Roff Manpage" + }, + ".3in": { + "color": "#ecdebe", + "name": "Roff Manpage" + }, + ".3m": { + "color": "#ecdebe", + "name": "Roff Manpage" + }, + ".3p": { + "color": "#ecdebe", + "name": "Roff Manpage" + }, + ".3pm": { + "color": "#ecdebe", + "name": "Roff Manpage" + }, + ".3qt": { + "color": "#ecdebe", + "name": "Roff Manpage" + }, + ".3x": { + "color": "#ecdebe", + "name": "Roff Manpage" + }, + ".4": { + "color": "#ecdebe", + "name": "Roff Manpage" + }, + ".4dm": { + "color": "#004289", + "name": "4D" + }, + ".4th": { + "color": "#341708", + "name": "Forth" + }, + ".5": { + "color": "#ecdebe", + "name": "Roff Manpage" + }, + ".6": { + "color": "#ecdebe", + "name": "Roff Manpage" + }, + ".6pl": { + "color": "#0000fb", + "name": "Raku" + }, + ".6pm": { + "color": "#0000fb", + "name": "Raku" + }, + ".7": { + "color": "#ecdebe", + "name": "Roff Manpage" + }, + ".8": { + "color": "#ecdebe", + "name": "Roff Manpage" + }, + ".8xk": { + "color": "#A0AA87", + "name": "TI Program" + }, + ".8xk.txt": { + "color": "#A0AA87", + "name": "TI Program" + }, + ".8xp": { + "color": "#A0AA87", + "name": "TI Program" + }, + ".8xp.txt": { + "color": "#A0AA87", + "name": "TI Program" + }, + ".9": { + "color": "#ecdebe", + "name": "Roff Manpage" + }, + ".E": { + "color": "#ccce35", + "name": "E" + }, + "._coffee": { + "color": "#244776", + "name": "CoffeeScript" + }, + "._js": { + "color": "#f1e05a", + "name": "JavaScript" + }, + "._ls": { + "color": "#499886", + "name": "LiveScript" + }, + ".a51": { + "color": "#6E4C13", + "name": "Assembly" + }, + ".abap": { + "color": "#E8274B", + "name": "ABAP" + }, + ".ada": { + "color": "#02f88c", + "name": "Ada" + }, + ".adb": { + "color": "#02f88c", + "name": "Ada" + }, + ".ado": { + "color": "#1a5f91", + "name": "Stata" + }, + ".adp": { + "color": "#e4cc98", + "name": "Tcl" + }, + ".ads": { + "color": "#02f88c", + "name": "Ada" + }, + ".agc": { + "color": "#0B3D91", + "name": "Apollo Guidance Computer" + }, + ".agda": { + "color": "#315665", + "name": "Agda" + }, + ".ahk": { + "color": "#6594b9", + "name": "AutoHotkey" + }, + ".ahkl": { + "color": "#6594b9", + "name": "AutoHotkey" + }, + ".aidl": { + "color": "#34EB6B", + "name": "AIDL" + }, + ".aj": { + "color": "#a957b0", + "name": "AspectJ" + }, + ".al": { + "color": "#0298c3", + "name": "Perl" + }, + ".als": { + "color": "#64C800", + "name": "Alloy" + }, + ".ampl": { + "color": "#E6EFBB", + "name": "AMPL" + }, + ".angelscript": { + "color": "#C7D7DC", + "name": "AngelScript" + }, + ".apib": { + "color": "#2ACCA8", + "name": "API Blueprint" + }, + ".apl": { + "color": "#5A8164", + "name": "APL" + }, + ".app.src": { + "color": "#B83998", + "name": "Erlang" + }, + ".applescript": { + "color": "#101F1F", + "name": "AppleScript" + }, + ".arc": { + "color": "#aa2afe", + "name": "Arc" + }, + ".as": { + "color": "#C7D7DC", + "name": "AngelScript" + }, + ".asax": { + "color": "#9400ff", + "name": "ASP.NET" + }, + ".asc": { + "color": "#B9D9FF", + "name": "AGS Script" + }, + ".ascx": { + "color": "#9400ff", + "name": "ASP.NET" + }, + ".asd": { + "color": "#3fb68b", + "name": "Common Lisp" + }, + ".asddls": { + "color": "#555e25", + "name": "ABAP CDS" + }, + ".ash": { + "color": "#B9D9FF", + "name": "AGS Script" + }, + ".ashx": { + "color": "#9400ff", + "name": "ASP.NET" + }, + ".asm": { + "color": "#005daa", + "name": "Motorola 68K Assembly" + }, + ".asmx": { + "color": "#9400ff", + "name": "ASP.NET" + }, + ".asp": { + "color": "#6a40fd", + "name": "Classic ASP" + }, + ".aspx": { + "color": "#9400ff", + "name": "ASP.NET" + }, + ".astro": { + "color": "#ff5a03", + "name": "Astro" + }, + ".asy": { + "color": "#ff0000", + "name": "Asymptote" + }, + ".au3": { + "color": "#1C3552", + "name": "AutoIt" + }, + ".aug": { + "color": "#9CC134", + "name": "Augeas" + }, + ".auk": { + "color": "#c30e9b", + "name": "Awk" + }, + ".aux": { + "color": "#3D6117", + "name": "TeX" + }, + ".aw": { + "color": "#4F5D95", + "name": "PHP" + }, + ".awk": { + "color": "#c30e9b", + "name": "Awk" + }, + ".axd": { + "color": "#9400ff", + "name": "ASP.NET" + }, + ".axi": { + "color": "#0aa0ff", + "name": "NetLinx" + }, + ".axi.erb": { + "color": "#747faa", + "name": "NetLinx+ERB" + }, + ".axs": { + "color": "#0aa0ff", + "name": "NetLinx" + }, + ".axs.erb": { + "color": "#747faa", + "name": "NetLinx+ERB" + }, + ".b": { + "color": "#2F2530", + "name": "Brainfuck" + }, + ".bal": { + "color": "#FF5000", + "name": "Ballerina" + }, + ".bas": { + "color": "#867db1", + "name": "VBA" + }, + ".bash": { + "color": "#89e051", + "name": "Shell" + }, + ".bat": { + "color": "#C1F12E", + "name": "Batchfile" + }, + ".bats": { + "color": "#89e051", + "name": "Shell" + }, + ".bb": { + "color": "#00FFAE", + "name": "BlitzBasic" + }, + ".bbx": { + "color": "#3D6117", + "name": "TeX" + }, + ".bdy": { + "color": "#dad8d8", + "name": "PLSQL" + }, + ".bf": { + "color": "#2F2530", + "name": "Brainfuck" + }, + ".bi": { + "color": "#867db1", + "name": "FreeBasic" + }, + ".bib": { + "color": "#778899", + "name": "BibTeX" + }, + ".bibtex": { + "color": "#778899", + "name": "BibTeX" + }, + ".bicep": { + "color": "#519aba", + "name": "Bicep" + }, + ".bison": { + "color": "#6A463F", + "name": "Bison" + }, + ".blade": { + "color": "#f7523f", + "name": "Blade" + }, + ".blade.php": { + "color": "#f7523f", + "name": "Blade" + }, + ".bmx": { + "color": "#cd6400", + "name": "BlitzMax" + }, + ".bones": { + "color": "#f1e05a", + "name": "JavaScript" + }, + ".boo": { + "color": "#d4bec1", + "name": "Boo" + }, + ".boot": { + "color": "#db5855", + "name": "Clojure" + }, + ".bpl": { + "color": "#c80fa0", + "name": "Boogie" + }, + ".brs": { + "color": "#662D91", + "name": "Brightscript" + }, + ".bsl": { + "color": "#814CCC", + "name": "1C Enterprise" + }, + ".bsv": { + "color": "#12223c", + "name": "Bluespec" + }, + ".builder": { + "color": "#701516", + "name": "Ruby" + }, + ".bzl": { + "color": "#76d275", + "name": "Starlark" + }, + ".c": { + "color": "#555555", + "name": "C" + }, + ".c++": { + "color": "#f34b7d", + "name": "C++" + }, + ".cake": { + "color": "#244776", + "name": "CoffeeScript" + }, + ".capnp": { + "color": "#c42727", + "name": "Cap'n Proto" + }, + ".cats": { + "color": "#555555", + "name": "C" + }, + ".cbx": { + "color": "#3D6117", + "name": "TeX" + }, + ".cc": { + "color": "#f34b7d", + "name": "C++" + }, + ".cdf": { + "color": "#dd1100", + "name": "Mathematica" + }, + ".ceylon": { + "color": "#dfa535", + "name": "Ceylon" + }, + ".cfc": { + "color": "#ed2cd6", + "name": "ColdFusion CFC" + }, + ".cfm": { + "color": "#ed2cd6", + "name": "ColdFusion" + }, + ".cfml": { + "color": "#ed2cd6", + "name": "ColdFusion" + }, + ".cgi": { + "color": "#89e051", + "name": "Shell" + }, + ".cginc": { + "color": "#aace60", + "name": "HLSL" + }, + ".ch": { + "color": "#403a40", + "name": "xBase" + }, + ".chpl": { + "color": "#8dc63f", + "name": "Chapel" + }, + ".cirru": { + "color": "#ccccff", + "name": "Cirru" + }, + ".cjs": { + "color": "#f1e05a", + "name": "JavaScript" + }, + ".cjsx": { + "color": "#244776", + "name": "CoffeeScript" + }, + ".ck": { + "color": "#3f8000", + "name": "ChucK" + }, + ".cl": { + "color": "#ed2e2d", + "name": "OpenCL" + }, + ".cl2": { + "color": "#db5855", + "name": "Clojure" + }, + ".click": { + "color": "#E4E6F3", + "name": "Click" + }, + ".clj": { + "color": "#db5855", + "name": "Clojure" + }, + ".cljc": { + "color": "#db5855", + "name": "Clojure" + }, + ".cljs": { + "color": "#db5855", + "name": "Clojure" + }, + ".cljs.hl": { + "color": "#db5855", + "name": "Clojure" + }, + ".cljscm": { + "color": "#db5855", + "name": "Clojure" + }, + ".cljx": { + "color": "#db5855", + "name": "Clojure" + }, + ".clp": { + "color": "#00A300", + "name": "CLIPS" + }, + ".cls": { + "color": "#867db1", + "name": "VBA" + }, + ".clw": { + "color": "#db901e", + "name": "Clarion" + }, + ".cmake": { + "color": "#DA3434", + "name": "CMake" + }, + ".cmake.in": { + "color": "#DA3434", + "name": "CMake" + }, + ".cmd": { + "color": "#C1F12E", + "name": "Batchfile" + }, + ".cnc": { + "color": "#D08CF2", + "name": "G-code" + }, + ".cocci": { + "color": "#c94949", + "name": "SmPL" + }, + ".coffee": { + "color": "#244776", + "name": "CoffeeScript" + }, + ".coffee.md": { + "color": "#244776", + "name": "Literate CoffeeScript" + }, + ".command": { + "color": "#89e051", + "name": "Shell" + }, + ".coq": { + "color": "#d0b68c", + "name": "Coq" + }, + ".cp": { + "color": "#B0CE4E", + "name": "Component Pascal" + }, + ".cpp": { + "color": "#f34b7d", + "name": "C++" + }, + ".cps": { + "color": "#B0CE4E", + "name": "Component Pascal" + }, + ".cr": { + "color": "#000100", + "name": "Crystal" + }, + ".cs": { + "color": "#178600", + "name": "C#" + }, + ".csd": { + "color": "#1a1a1a", + "name": "Csound Document" + }, + ".cshtml": { + "color": "#512be4", + "name": "HTML+Razor" + }, + ".css": { + "color": "#563d7c", + "name": "CSS" + }, + ".csx": { + "color": "#178600", + "name": "C#" + }, + ".ctp": { + "color": "#4F5D95", + "name": "PHP" + }, + ".cu": { + "color": "#3A4E3A", + "name": "Cuda" + }, + ".cue": { + "color": "#5886E1", + "name": "CUE" + }, + ".cuh": { + "color": "#3A4E3A", + "name": "Cuda" + }, + ".cwl": { + "color": "#B5314C", + "name": "Common Workflow Language" + }, + ".cxx": { + "color": "#f34b7d", + "name": "C++" + }, + ".d": { + "color": "#427819", + "name": "Makefile" + }, + ".dart": { + "color": "#00B4AB", + "name": "Dart" + }, + ".dats": { + "color": "#1ac620", + "name": "ATS" + }, + ".db2": { + "color": "#e38c00", + "name": "SQLPL" + }, + ".dcl": { + "color": "#3F85AF", + "name": "Clean" + }, + ".ddl": { + "color": "#dad8d8", + "name": "PLSQL" + }, + ".decls": { + "color": "#00FFAE", + "name": "BlitzBasic" + }, + ".dfm": { + "color": "#E3F171", + "name": "Pascal" + }, + ".dfy": { + "color": "#FFEC25", + "name": "Dafny" + }, + ".dhall": { + "color": "#dfafff", + "name": "Dhall" + }, + ".di": { + "color": "#ba595e", + "name": "D" + }, + ".djs": { + "color": "#cca760", + "name": "Dogescript" + }, + ".dlm": { + "color": "#a3522f", + "name": "IDL" + }, + ".dm": { + "color": "#447265", + "name": "DM" + }, + ".do": { + "color": "#1a5f91", + "name": "Stata" + }, + ".dockerfile": { + "color": "#384d54", + "name": "Dockerfile" + }, + ".doh": { + "color": "#1a5f91", + "name": "Stata" + }, + ".dpr": { + "color": "#E3F171", + "name": "Pascal" + }, + ".druby": { + "color": "#c7a938", + "name": "Mirah" + }, + ".dsp": { + "color": "#c37240", + "name": "Faust" + }, + ".dtx": { + "color": "#3D6117", + "name": "TeX" + }, + ".duby": { + "color": "#c7a938", + "name": "Mirah" + }, + ".dwl": { + "color": "#003a52", + "name": "DataWeave" + }, + ".dyalog": { + "color": "#5A8164", + "name": "APL" + }, + ".dyl": { + "color": "#6c616e", + "name": "Dylan" + }, + ".dylan": { + "color": "#6c616e", + "name": "Dylan" + }, + ".e": { + "color": "#4d6977", + "name": "Eiffel" + }, + ".ebuild": { + "color": "#9400ff", + "name": "Gentoo Ebuild" + }, + ".ec": { + "color": "#913960", + "name": "eC" + }, + ".ecl": { + "color": "#001d9d", + "name": "ECLiPSe" + }, + ".eclass": { + "color": "#9400ff", + "name": "Gentoo Eclass" + }, + ".eclxml": { + "color": "#8a1267", + "name": "ECL" + }, + ".ecr": { + "color": "#2e1052", + "name": "HTML+ECR" + }, + ".ect": { + "color": "#a91e50", + "name": "EJS" + }, + ".eex": { + "color": "#6e4a7e", + "name": "HTML+EEX" + }, + ".eh": { + "color": "#913960", + "name": "eC" + }, + ".ejs": { + "color": "#a91e50", + "name": "EJS" + }, + ".el": { + "color": "#c065db", + "name": "Emacs Lisp" + }, + ".eliom": { + "color": "#3be133", + "name": "OCaml" + }, + ".eliomi": { + "color": "#3be133", + "name": "OCaml" + }, + ".elm": { + "color": "#60B5CC", + "name": "Elm" + }, + ".em": { + "color": "#FFF4F3", + "name": "EmberScript" + }, + ".emacs": { + "color": "#c065db", + "name": "Emacs Lisp" + }, + ".emacs.desktop": { + "color": "#c065db", + "name": "Emacs Lisp" + }, + ".emberscript": { + "color": "#FFF4F3", + "name": "EmberScript" + }, + ".env": { + "color": "#89e051", + "name": "Shell" + }, + ".eps": { + "color": "#da291c", + "name": "PostScript" + }, + ".epsi": { + "color": "#da291c", + "name": "PostScript" + }, + ".eq": { + "color": "#a78649", + "name": "EQ" + }, + ".erb": { + "color": "#701516", + "name": "HTML+ERB" + }, + ".erb.deface": { + "color": "#701516", + "name": "HTML+ERB" + }, + ".erl": { + "color": "#B83998", + "name": "Erlang" + }, + ".es": { + "color": "#f1e05a", + "name": "JavaScript" + }, + ".es6": { + "color": "#f1e05a", + "name": "JavaScript" + }, + ".escript": { + "color": "#B83998", + "name": "Erlang" + }, + ".ex": { + "color": "#6e4a7e", + "name": "Elixir" + }, + ".exs": { + "color": "#6e4a7e", + "name": "Elixir" + }, + ".eye": { + "color": "#701516", + "name": "Ruby" + }, + ".f": { + "color": "#4d41b1", + "name": "Fortran" + }, + ".f03": { + "color": "#4d41b1", + "name": "Fortran Free Form" + }, + ".f08": { + "color": "#4d41b1", + "name": "Fortran Free Form" + }, + ".f77": { + "color": "#4d41b1", + "name": "Fortran" + }, + ".f90": { + "color": "#4d41b1", + "name": "Fortran Free Form" + }, + ".f95": { + "color": "#4d41b1", + "name": "Fortran Free Form" + }, + ".factor": { + "color": "#636746", + "name": "Factor" + }, + ".fan": { + "color": "#14253c", + "name": "Fantom" + }, + ".fancypack": { + "color": "#7b9db4", + "name": "Fancy" + }, + ".fcgi": { + "color": "#89e051", + "name": "Shell" + }, + ".feature": { + "color": "#5B2063", + "name": "Gherkin" + }, + ".fish": { + "color": "#4aae47", + "name": "fish" + }, + ".flex": { + "color": "#DBCA00", + "name": "JFlex" + }, + ".flux": { + "color": "#88ccff", + "name": "FLUX" + }, + ".fnc": { + "color": "#dad8d8", + "name": "PLSQL" + }, + ".fnl": { + "color": "#fff3d7", + "name": "Fennel" + }, + ".for": { + "color": "#4d41b1", + "name": "Fortran" + }, + ".forth": { + "color": "#341708", + "name": "Forth" + }, + ".fp": { + "color": "#5686a5", + "name": "GLSL" + }, + ".fpp": { + "color": "#4d41b1", + "name": "Fortran" + }, + ".fr": { + "color": "#00cafe", + "name": "Frege" + }, + ".frag": { + "color": "#f1e05a", + "name": "JavaScript" + }, + ".frg": { + "color": "#5686a5", + "name": "GLSL" + }, + ".frm": { + "color": "#867db1", + "name": "VBA" + }, + ".frt": { + "color": "#341708", + "name": "Forth" + }, + ".frx": { + "color": "#867db1", + "name": "VBA" + }, + ".fs": { + "color": "#5686a5", + "name": "GLSL" + }, + ".fsh": { + "color": "#5686a5", + "name": "GLSL" + }, + ".fshader": { + "color": "#5686a5", + "name": "GLSL" + }, + ".fsi": { + "color": "#b845fc", + "name": "F#" + }, + ".fst": { + "color": "#572e30", + "name": "F*" + }, + ".fsx": { + "color": "#b845fc", + "name": "F#" + }, + ".fth": { + "color": "#341708", + "name": "Forth" + }, + ".ftl": { + "color": "#0050b2", + "name": "FreeMarker" + }, + ".fun": { + "color": "#dc566d", + "name": "Standard ML" + }, + ".fut": { + "color": "#5f021f", + "name": "Futhark" + }, + ".fx": { + "color": "#aace60", + "name": "HLSL" + }, + ".fxh": { + "color": "#aace60", + "name": "HLSL" + }, + ".fy": { + "color": "#7b9db4", + "name": "Fancy" + }, + ".g": { + "color": "#0000cc", + "name": "GAP" + }, + ".g4": { + "color": "#9DC3FF", + "name": "ANTLR" + }, + ".gaml": { + "color": "#FFC766", + "name": "GAML" + }, + ".gap": { + "color": "#0000cc", + "name": "GAP" + }, + ".gawk": { + "color": "#c30e9b", + "name": "Awk" + }, + ".gco": { + "color": "#D08CF2", + "name": "G-code" + }, + ".gcode": { + "color": "#D08CF2", + "name": "G-code" + }, + ".gd": { + "color": "#355570", + "name": "GDScript" + }, + ".gemspec": { + "color": "#701516", + "name": "Ruby" + }, + ".geo": { + "color": "#5686a5", + "name": "GLSL" + }, + ".geom": { + "color": "#5686a5", + "name": "GLSL" + }, + ".gf": { + "color": "#ff0000", + "name": "Grammatical Framework" + }, + ".gi": { + "color": "#0000cc", + "name": "GAP" + }, + ".glf": { + "color": "#c1ac7f", + "name": "Glyph" + }, + ".glsl": { + "color": "#5686a5", + "name": "GLSL" + }, + ".glslf": { + "color": "#5686a5", + "name": "GLSL" + }, + ".glslv": { + "color": "#5686a5", + "name": "GLSL" + }, + ".gml": { + "color": "#71b417", + "name": "Game Maker Language" + }, + ".gms": { + "color": "#f49a22", + "name": "GAMS" + }, + ".gnu": { + "color": "#f0a9f0", + "name": "Gnuplot" + }, + ".gnuplot": { + "color": "#f0a9f0", + "name": "Gnuplot" + }, + ".go": { + "color": "#00ADD8", + "name": "Go" + }, + ".god": { + "color": "#701516", + "name": "Ruby" + }, + ".golo": { + "color": "#88562A", + "name": "Golo" + }, + ".gp": { + "color": "#f0a9f0", + "name": "Gnuplot" + }, + ".grace": { + "color": "#615f8b", + "name": "Grace" + }, + ".groovy": { + "color": "#4298b8", + "name": "Groovy" + }, + ".grt": { + "color": "#4298b8", + "name": "Groovy" + }, + ".gs": { + "color": "#f1e05a", + "name": "JavaScript" + }, + ".gshader": { + "color": "#5686a5", + "name": "GLSL" + }, + ".gsp": { + "color": "#4298b8", + "name": "Groovy Server Pages" + }, + ".gst": { + "color": "#82937f", + "name": "Gosu" + }, + ".gsx": { + "color": "#82937f", + "name": "Gosu" + }, + ".gtpl": { + "color": "#4298b8", + "name": "Groovy" + }, + ".gvy": { + "color": "#4298b8", + "name": "Groovy" + }, + ".gyp": { + "color": "#3572A5", + "name": "Python" + }, + ".gypi": { + "color": "#3572A5", + "name": "Python" + }, + ".h": { + "color": "#438eff", + "name": "Objective-C" + }, + ".h++": { + "color": "#f34b7d", + "name": "C++" + }, + ".hack": { + "color": "#878787", + "name": "Hack" + }, + ".haml": { + "color": "#ece2a9", + "name": "Haml" + }, + ".haml.deface": { + "color": "#ece2a9", + "name": "Haml" + }, + ".handlebars": { + "color": "#f7931e", + "name": "Handlebars" + }, + ".hats": { + "color": "#1ac620", + "name": "ATS" + }, + ".hb": { + "color": "#0e60e3", + "name": "Harbour" + }, + ".hbs": { + "color": "#f7931e", + "name": "Handlebars" + }, + ".hc": { + "color": "#ffefaf", + "name": "HolyC" + }, + ".hh": { + "color": "#878787", + "name": "Hack" + }, + ".hhi": { + "color": "#878787", + "name": "Hack" + }, + ".hic": { + "color": "#db5855", + "name": "Clojure" + }, + ".hlsl": { + "color": "#aace60", + "name": "HLSL" + }, + ".hlsli": { + "color": "#aace60", + "name": "HLSL" + }, + ".hpp": { + "color": "#f34b7d", + "name": "C++" + }, + ".hqf": { + "color": "#3F3F3F", + "name": "SQF" + }, + ".hql": { + "color": "#dce200", + "name": "HiveQL" + }, + ".hrl": { + "color": "#B83998", + "name": "Erlang" + }, + ".hs": { + "color": "#5e5086", + "name": "Haskell" + }, + ".hs-boot": { + "color": "#5e5086", + "name": "Haskell" + }, + ".hsc": { + "color": "#5e5086", + "name": "Haskell" + }, + ".hta": { + "color": "#e34c26", + "name": "HTML" + }, + ".htm": { + "color": "#e34c26", + "name": "HTML" + }, + ".html": { + "color": "#e34c26", + "name": "HTML" + }, + ".html.hl": { + "color": "#e34c26", + "name": "HTML" + }, + ".html.leex": { + "color": "#6e4a7e", + "name": "HTML+EEX" + }, + ".hx": { + "color": "#df7900", + "name": "Haxe" + }, + ".hxsl": { + "color": "#df7900", + "name": "Haxe" + }, + ".hxx": { + "color": "#f34b7d", + "name": "C++" + }, + ".hy": { + "color": "#7790B2", + "name": "Hy" + }, + ".i": { + "color": "#005daa", + "name": "Motorola 68K Assembly" + }, + ".i3": { + "color": "#223388", + "name": "Modula-3" + }, + ".ice": { + "color": "#003fa2", + "name": "Slice" + }, + ".iced": { + "color": "#244776", + "name": "CoffeeScript" + }, + ".icl": { + "color": "#3F85AF", + "name": "Clean" + }, + ".idc": { + "color": "#555555", + "name": "C" + }, + ".idr": { + "color": "#b30000", + "name": "Idris" + }, + ".ig": { + "color": "#223388", + "name": "Modula-3" + }, + ".ihlp": { + "color": "#1a5f91", + "name": "Stata" + }, + ".ijm": { + "color": "#99AAFF", + "name": "ImageJ Macro" + }, + ".ijs": { + "color": "#9EEDFF", + "name": "J" + }, + ".ik": { + "color": "#078193", + "name": "Ioke" + }, + ".ily": { + "color": "#9ccc7c", + "name": "LilyPond" + }, + ".inc": { + "color": "#f69e1d", + "name": "SourcePawn" + }, + ".inl": { + "color": "#f34b7d", + "name": "C++" + }, + ".ino": { + "color": "#f34b7d", + "name": "C++" + }, + ".ins": { + "color": "#3D6117", + "name": "TeX" + }, + ".intr": { + "color": "#6c616e", + "name": "Dylan" + }, + ".io": { + "color": "#a9188d", + "name": "Io" + }, + ".iol": { + "color": "#843179", + "name": "Jolie" + }, + ".ipf": { + "color": "#0000cc", + "name": "IGOR Pro" + }, + ".ipp": { + "color": "#f34b7d", + "name": "C++" + }, + ".ipynb": { + "color": "#DA5B0B", + "name": "Jupyter Notebook" + }, + ".isl": { + "color": "#264b99", + "name": "Inno Setup" + }, + ".iss": { + "color": "#264b99", + "name": "Inno Setup" + }, + ".j": { + "color": "#ff0c5a", + "name": "Objective-J" + }, + ".j2": { + "color": "#a52a22", + "name": "Jinja" + }, + ".jade": { + "color": "#a86454", + "name": "Pug" + }, + ".jake": { + "color": "#f1e05a", + "name": "JavaScript" + }, + ".jav": { + "color": "#b07219", + "name": "Java" + }, + ".java": { + "color": "#b07219", + "name": "Java" + }, + ".javascript": { + "color": "#f1e05a", + "name": "JavaScript" + }, + ".jbuilder": { + "color": "#701516", + "name": "Ruby" + }, + ".jflex": { + "color": "#DBCA00", + "name": "JFlex" + }, + ".jinja": { + "color": "#a52a22", + "name": "Jinja" + }, + ".jinja2": { + "color": "#a52a22", + "name": "Jinja" + }, + ".jison": { + "color": "#56b3cb", + "name": "Jison" + }, + ".jisonlex": { + "color": "#56b3cb", + "name": "Jison Lex" + }, + ".jl": { + "color": "#a270ba", + "name": "Julia" + }, + ".jq": { + "color": "#c7254e", + "name": "jq" + }, + ".js": { + "color": "#f1e05a", + "name": "JavaScript" + }, + ".js.erb": { + "color": "#f1e05a", + "name": "JavaScript+ERB" + }, + ".jsb": { + "color": "#f1e05a", + "name": "JavaScript" + }, + ".jscad": { + "color": "#f1e05a", + "name": "JavaScript" + }, + ".jsfl": { + "color": "#f1e05a", + "name": "JavaScript" + }, + ".jsm": { + "color": "#f1e05a", + "name": "JavaScript" + }, + ".jsonnet": { + "color": "#0064bd", + "name": "Jsonnet" + }, + ".jsp": { + "color": "#2A6277", + "name": "Java Server Pages" + }, + ".jss": { + "color": "#f1e05a", + "name": "JavaScript" + }, + ".jst": { + "color": "#a91e50", + "name": "EJS" + }, + ".jsx": { + "color": "#f1e05a", + "name": "JavaScript" + }, + ".kak": { + "color": "#6f8042", + "name": "KakouneScript" + }, + ".kid": { + "color": "#951531", + "name": "Genshi" + }, + ".kojo": { + "color": "#c22d40", + "name": "Scala" + }, + ".krl": { + "color": "#28430A", + "name": "KRL" + }, + ".ksh": { + "color": "#89e051", + "name": "Shell" + }, + ".ksy": { + "color": "#773b37", + "name": "Kaitai Struct" + }, + ".kt": { + "color": "#A97BFF", + "name": "Kotlin" + }, + ".ktm": { + "color": "#A97BFF", + "name": "Kotlin" + }, + ".kts": { + "color": "#A97BFF", + "name": "Kotlin" + }, + ".l": { + "color": "#ecdebe", + "name": "Roff" + }, + ".lagda": { + "color": "#315665", + "name": "Literate Agda" + }, + ".las": { + "color": "#999999", + "name": "Lasso" + }, + ".lasso": { + "color": "#999999", + "name": "Lasso" + }, + ".lasso8": { + "color": "#999999", + "name": "Lasso" + }, + ".lasso9": { + "color": "#999999", + "name": "Lasso" + }, + ".latte": { + "color": "#f2a542", + "name": "Latte" + }, + ".lbx": { + "color": "#3D6117", + "name": "TeX" + }, + ".less": { + "color": "#1d365d", + "name": "Less" + }, + ".lex": { + "color": "#DBCA00", + "name": "Lex" + }, + ".lfe": { + "color": "#4C3023", + "name": "LFE" + }, + ".lgt": { + "color": "#295b9a", + "name": "Logtalk" + }, + ".lhs": { + "color": "#5e5086", + "name": "Literate Haskell" + }, + ".libsonnet": { + "color": "#0064bd", + "name": "Jsonnet" + }, + ".lid": { + "color": "#6c616e", + "name": "Dylan" + }, + ".lidr": { + "color": "#b30000", + "name": "Idris" + }, + ".linq": { + "color": "#178600", + "name": "C#" + }, + ".liquid": { + "color": "#67b8de", + "name": "Liquid" + }, + ".lisp": { + "color": "#87AED7", + "name": "NewLisp" + }, + ".litcoffee": { + "color": "#244776", + "name": "Literate CoffeeScript" + }, + ".ll": { + "color": "#185619", + "name": "LLVM" + }, + ".lmi": { + "color": "#3572A5", + "name": "Python" + }, + ".logtalk": { + "color": "#295b9a", + "name": "Logtalk" + }, + ".lol": { + "color": "#cc9900", + "name": "LOLCODE" + }, + ".lookml": { + "color": "#652B81", + "name": "LookML" + }, + ".lpr": { + "color": "#E3F171", + "name": "Pascal" + }, + ".ls": { + "color": "#499886", + "name": "LiveScript" + }, + ".lsl": { + "color": "#3d9970", + "name": "LSL" + }, + ".lslp": { + "color": "#3d9970", + "name": "LSL" + }, + ".lsp": { + "color": "#87AED7", + "name": "NewLisp" + }, + ".ltx": { + "color": "#3D6117", + "name": "TeX" + }, + ".lua": { + "color": "#000080", + "name": "Lua" + }, + ".lvlib": { + "color": "#fede06", + "name": "LabVIEW" + }, + ".lvproj": { + "color": "#fede06", + "name": "LabVIEW" + }, + ".ly": { + "color": "#9ccc7c", + "name": "LilyPond" + }, + ".m": { + "color": "#438eff", + "name": "Objective-C" + }, + ".m2": { + "color": "#d8ffff", + "name": "Macaulay2" + }, + ".m3": { + "color": "#223388", + "name": "Modula-3" + }, + ".ma": { + "color": "#dd1100", + "name": "Mathematica" + }, + ".mak": { + "color": "#427819", + "name": "Makefile" + }, + ".make": { + "color": "#427819", + "name": "Makefile" + }, + ".makefile": { + "color": "#427819", + "name": "Makefile" + }, + ".mako": { + "color": "#7e858d", + "name": "Mako" + }, + ".man": { + "color": "#ecdebe", + "name": "Roff Manpage" + }, + ".mao": { + "color": "#7e858d", + "name": "Mako" + }, + ".marko": { + "color": "#42bff2", + "name": "Marko" + }, + ".mask": { + "color": "#f97732", + "name": "Mask" + }, + ".mata": { + "color": "#1a5f91", + "name": "Stata" + }, + ".matah": { + "color": "#1a5f91", + "name": "Stata" + }, + ".mathematica": { + "color": "#dd1100", + "name": "Mathematica" + }, + ".matlab": { + "color": "#e16737", + "name": "MATLAB" + }, + ".mawk": { + "color": "#c30e9b", + "name": "Awk" + }, + ".maxhelp": { + "color": "#c4a79c", + "name": "Max" + }, + ".maxpat": { + "color": "#c4a79c", + "name": "Max" + }, + ".maxproj": { + "color": "#c4a79c", + "name": "Max" + }, + ".mcfunction": { + "color": "#E22837", + "name": "mcfunction" + }, + ".mcr": { + "color": "#00a6a6", + "name": "MAXScript" + }, + ".mdoc": { + "color": "#ecdebe", + "name": "Roff Manpage" + }, + ".me": { + "color": "#ecdebe", + "name": "Roff" + }, + ".metal": { + "color": "#8f14e9", + "name": "Metal" + }, + ".mg": { + "color": "#223388", + "name": "Modula-3" + }, + ".mirah": { + "color": "#c7a938", + "name": "Mirah" + }, + ".mjs": { + "color": "#f1e05a", + "name": "JavaScript" + }, + ".mk": { + "color": "#427819", + "name": "Makefile" + }, + ".mkfile": { + "color": "#427819", + "name": "Makefile" + }, + ".mkii": { + "color": "#3D6117", + "name": "TeX" + }, + ".mkiv": { + "color": "#3D6117", + "name": "TeX" + }, + ".mkvi": { + "color": "#3D6117", + "name": "TeX" + }, + ".ml": { + "color": "#dc566d", + "name": "Standard ML" + }, + ".ml4": { + "color": "#3be133", + "name": "OCaml" + }, + ".mli": { + "color": "#3be133", + "name": "OCaml" + }, + ".mlir": { + "color": "#5EC8DB", + "name": "MLIR" + }, + ".mll": { + "color": "#3be133", + "name": "OCaml" + }, + ".mly": { + "color": "#3be133", + "name": "OCaml" + }, + ".mm": { + "color": "#6866fb", + "name": "Objective-C++" + }, + ".mo": { + "color": "#de1d31", + "name": "Modelica" + }, + ".mod": { + "color": "#10253f", + "name": "Modula-2" + }, + ".model.lkml": { + "color": "#652B81", + "name": "LookML" + }, + ".moo": { + "color": "#ff2b2b", + "name": "Mercury" + }, + ".moon": { + "color": "#ff4585", + "name": "MoonScript" + }, + ".mq4": { + "color": "#62A8D6", + "name": "MQL4" + }, + ".mq5": { + "color": "#4A76B8", + "name": "MQL5" + }, + ".mqh": { + "color": "#4A76B8", + "name": "MQL5" + }, + ".mrc": { + "color": "#3d57c3", + "name": "mIRC Script" + }, + ".ms": { + "color": "#ecdebe", + "name": "Roff" + }, + ".mspec": { + "color": "#701516", + "name": "Ruby" + }, + ".mt": { + "color": "#dd1100", + "name": "Mathematica" + }, + ".mtml": { + "color": "#b7e1f4", + "name": "MTML" + }, + ".mu": { + "color": "#244963", + "name": "mupad" + }, + ".mud": { + "color": "#dc75e5", + "name": "ZIL" + }, + ".mustache": { + "color": "#724b3b", + "name": "Mustache" + }, + ".mxt": { + "color": "#c4a79c", + "name": "Max" + }, + ".n": { + "color": "#ecdebe", + "name": "Roff" + }, + ".nasm": { + "color": "#6E4C13", + "name": "Assembly" + }, + ".nawk": { + "color": "#c30e9b", + "name": "Awk" + }, + ".nb": { + "color": "#dd1100", + "name": "Mathematica" + }, + ".nbp": { + "color": "#dd1100", + "name": "Mathematica" + }, + ".nc": { + "color": "#94B0C7", + "name": "nesC" + }, + ".ncl": { + "color": "#28431f", + "name": "NCL" + }, + ".ne": { + "color": "#990000", + "name": "Nearley" + }, + ".nearley": { + "color": "#990000", + "name": "Nearley" + }, + ".nf": { + "color": "#3ac486", + "name": "Nextflow" + }, + ".nim": { + "color": "#ffc200", + "name": "Nim" + }, + ".nim.cfg": { + "color": "#ffc200", + "name": "Nim" + }, + ".nimble": { + "color": "#ffc200", + "name": "Nim" + }, + ".nimrod": { + "color": "#ffc200", + "name": "Nim" + }, + ".nims": { + "color": "#ffc200", + "name": "Nim" + }, + ".nit": { + "color": "#009917", + "name": "Nit" + }, + ".nix": { + "color": "#7e7eff", + "name": "Nix" + }, + ".njk": { + "color": "#3d8137", + "name": "Nunjucks" + }, + ".njs": { + "color": "#f1e05a", + "name": "JavaScript" + }, + ".nl": { + "color": "#87AED7", + "name": "NewLisp" + }, + ".nlogo": { + "color": "#ff6375", + "name": "NetLogo" + }, + ".nqp": { + "color": "#0000fb", + "name": "Raku" + }, + ".nr": { + "color": "#ecdebe", + "name": "Roff" + }, + ".nse": { + "color": "#000080", + "name": "Lua" + }, + ".nss": { + "color": "#111522", + "name": "NWScript" + }, + ".nu": { + "color": "#c9df40", + "name": "Nu" + }, + ".numpy": { + "color": "#9C8AF9", + "name": "NumPy" + }, + ".numpyw": { + "color": "#9C8AF9", + "name": "NumPy" + }, + ".numsc": { + "color": "#9C8AF9", + "name": "NumPy" + }, + ".nut": { + "color": "#800000", + "name": "Squirrel" + }, + ".ny": { + "color": "#3fb68b", + "name": "Common Lisp" + }, + ".odin": { + "color": "#60AFFE", + "name": "Odin" + }, + ".ol": { + "color": "#843179", + "name": "Jolie" + }, + ".omgrofl": { + "color": "#cabbff", + "name": "Omgrofl" + }, + ".ooc": { + "color": "#b0b77e", + "name": "ooc" + }, + ".opal": { + "color": "#f7ede0", + "name": "Opal" + }, + ".opencl": { + "color": "#ed2e2d", + "name": "OpenCL" + }, + ".orc": { + "color": "#1a1a1a", + "name": "Csound" + }, + ".os": { + "color": "#814CCC", + "name": "1C Enterprise" + }, + ".oxygene": { + "color": "#cdd0e3", + "name": "Oxygene" + }, + ".oz": { + "color": "#fab738", + "name": "Oz" + }, + ".p": { + "color": "#5ce600", + "name": "OpenEdge ABL" + }, + ".p4": { + "color": "#7055b5", + "name": "P4" + }, + ".p6": { + "color": "#0000fb", + "name": "Raku" + }, + ".p6l": { + "color": "#0000fb", + "name": "Raku" + }, + ".p6m": { + "color": "#0000fb", + "name": "Raku" + }, + ".p8": { + "color": "#000080", + "name": "Lua" + }, + ".pac": { + "color": "#f1e05a", + "name": "JavaScript" + }, + ".pan": { + "color": "#cc0000", + "name": "Pan" + }, + ".parrot": { + "color": "#f3ca0a", + "name": "Parrot" + }, + ".pas": { + "color": "#E3F171", + "name": "Pascal" + }, + ".pascal": { + "color": "#E3F171", + "name": "Pascal" + }, + ".pat": { + "color": "#c4a79c", + "name": "Max" + }, + ".pb": { + "color": "#5a6986", + "name": "PureBasic" + }, + ".pbi": { + "color": "#5a6986", + "name": "PureBasic" + }, + ".pbt": { + "color": "#8f0f8d", + "name": "PowerBuilder" + }, + ".pck": { + "color": "#dad8d8", + "name": "PLSQL" + }, + ".pcss": { + "color": "#dc3a0c", + "name": "PostCSS" + }, + ".pd_lua": { + "color": "#000080", + "name": "Lua" + }, + ".pde": { + "color": "#0096D8", + "name": "Processing" + }, + ".pegjs": { + "color": "#234d6b", + "name": "PEG.js" + }, + ".pep": { + "color": "#C76F5B", + "name": "Pep8" + }, + ".perl": { + "color": "#0298c3", + "name": "Perl" + }, + ".pfa": { + "color": "#da291c", + "name": "PostScript" + }, + ".pgsql": { + "color": "#336790", + "name": "PLpgSQL" + }, + ".ph": { + "color": "#0298c3", + "name": "Perl" + }, + ".php": { + "color": "#4F5D95", + "name": "PHP" + }, + ".php3": { + "color": "#4F5D95", + "name": "PHP" + }, + ".php4": { + "color": "#4F5D95", + "name": "PHP" + }, + ".php5": { + "color": "#4F5D95", + "name": "PHP" + }, + ".phps": { + "color": "#4F5D95", + "name": "PHP" + }, + ".phpt": { + "color": "#4F5D95", + "name": "PHP" + }, + ".phtml": { + "color": "#4f5d95", + "name": "HTML+PHP" + }, + ".pig": { + "color": "#fcd7de", + "name": "PigLatin" + }, + ".pike": { + "color": "#005390", + "name": "Pike" + }, + ".pkb": { + "color": "#dad8d8", + "name": "PLSQL" + }, + ".pks": { + "color": "#dad8d8", + "name": "PLSQL" + }, + ".pl": { + "color": "#0000fb", + "name": "Raku" + }, + ".pl6": { + "color": "#0000fb", + "name": "Raku" + }, + ".plb": { + "color": "#dad8d8", + "name": "PLSQL" + }, + ".plot": { + "color": "#f0a9f0", + "name": "Gnuplot" + }, + ".pls": { + "color": "#dad8d8", + "name": "PLSQL" + }, + ".plsql": { + "color": "#dad8d8", + "name": "PLSQL" + }, + ".plt": { + "color": "#f0a9f0", + "name": "Gnuplot" + }, + ".pluginspec": { + "color": "#701516", + "name": "Ruby" + }, + ".plx": { + "color": "#0298c3", + "name": "Perl" + }, + ".pm": { + "color": "#0000fb", + "name": "Raku" + }, + ".pm6": { + "color": "#0000fb", + "name": "Raku" + }, + ".pmod": { + "color": "#005390", + "name": "Pike" + }, + ".podsl": { + "color": "#3fb68b", + "name": "Common Lisp" + }, + ".podspec": { + "color": "#701516", + "name": "Ruby" + }, + ".pogo": { + "color": "#d80074", + "name": "PogoScript" + }, + ".postcss": { + "color": "#dc3a0c", + "name": "PostCSS" + }, + ".pov": { + "color": "#6bac65", + "name": "POV-Ray SDL" + }, + ".pp": { + "color": "#302B6D", + "name": "Puppet" + }, + ".pprx": { + "color": "#d90e09", + "name": "REXX" + }, + ".prawn": { + "color": "#701516", + "name": "Ruby" + }, + ".prc": { + "color": "#dad8d8", + "name": "PLSQL" + }, + ".prg": { + "color": "#403a40", + "name": "xBase" + }, + ".pro": { + "color": "#74283c", + "name": "Prolog" + }, + ".prolog": { + "color": "#74283c", + "name": "Prolog" + }, + ".prw": { + "color": "#403a40", + "name": "xBase" + }, + ".ps": { + "color": "#da291c", + "name": "PostScript" + }, + ".ps1": { + "color": "#012456", + "name": "PowerShell" + }, + ".psc": { + "color": "#6600cc", + "name": "Papyrus" + }, + ".psd1": { + "color": "#012456", + "name": "PowerShell" + }, + ".psgi": { + "color": "#0298c3", + "name": "Perl" + }, + ".psm1": { + "color": "#012456", + "name": "PowerShell" + }, + ".pug": { + "color": "#a86454", + "name": "Pug" + }, + ".purs": { + "color": "#1D222D", + "name": "PureScript" + }, + ".pwn": { + "color": "#dbb284", + "name": "Pawn" + }, + ".pxd": { + "color": "#fedf5b", + "name": "Cython" + }, + ".pxi": { + "color": "#fedf5b", + "name": "Cython" + }, + ".py": { + "color": "#3572A5", + "name": "Python" + }, + ".py3": { + "color": "#3572A5", + "name": "Python" + }, + ".pyde": { + "color": "#3572A5", + "name": "Python" + }, + ".pyi": { + "color": "#3572A5", + "name": "Python" + }, + ".pyp": { + "color": "#3572A5", + "name": "Python" + }, + ".pyt": { + "color": "#3572A5", + "name": "Python" + }, + ".pyw": { + "color": "#3572A5", + "name": "Python" + }, + ".pyx": { + "color": "#fedf5b", + "name": "Cython" + }, + ".q": { + "color": "#0040cd", + "name": "q" + }, + ".qasm": { + "color": "#AA70FF", + "name": "OpenQASM" + }, + ".qbs": { + "color": "#44a51c", + "name": "QML" + }, + ".ql": { + "color": "#140f46", + "name": "CodeQL" + }, + ".qll": { + "color": "#140f46", + "name": "CodeQL" + }, + ".qml": { + "color": "#44a51c", + "name": "QML" + }, + ".qs": { + "color": "#00b841", + "name": "Qt Script" + }, + ".r": { + "color": "#358a5b", + "name": "Rebol" + }, + ".r2": { + "color": "#358a5b", + "name": "Rebol" + }, + ".r3": { + "color": "#358a5b", + "name": "Rebol" + }, + ".rabl": { + "color": "#701516", + "name": "Ruby" + }, + ".rake": { + "color": "#701516", + "name": "Ruby" + }, + ".raku": { + "color": "#0000fb", + "name": "Raku" + }, + ".rakumod": { + "color": "#0000fb", + "name": "Raku" + }, + ".raml": { + "color": "#77d9fb", + "name": "RAML" + }, + ".razor": { + "color": "#512be4", + "name": "HTML+Razor" + }, + ".rb": { + "color": "#701516", + "name": "Ruby" + }, + ".rbi": { + "color": "#701516", + "name": "Ruby" + }, + ".rbuild": { + "color": "#701516", + "name": "Ruby" + }, + ".rbw": { + "color": "#701516", + "name": "Ruby" + }, + ".rbx": { + "color": "#701516", + "name": "Ruby" + }, + ".rbxs": { + "color": "#000080", + "name": "Lua" + }, + ".rd": { + "color": "#198CE7", + "name": "R" + }, + ".re": { + "color": "#ff5847", + "name": "Reason" + }, + ".reb": { + "color": "#358a5b", + "name": "Rebol" + }, + ".rebol": { + "color": "#358a5b", + "name": "Rebol" + }, + ".red": { + "color": "#f50000", + "name": "Red" + }, + ".reds": { + "color": "#f50000", + "name": "Red" + }, + ".rego": { + "color": "#7d9199", + "name": "Open Policy Agent" + }, + ".rei": { + "color": "#ff5847", + "name": "Reason" + }, + ".res": { + "color": "#ed5051", + "name": "ReScript" + }, + ".rex": { + "color": "#d90e09", + "name": "REXX" + }, + ".rexx": { + "color": "#d90e09", + "name": "REXX" + }, + ".rg": { + "color": "#cc0088", + "name": "Rouge" + }, + ".rhtml": { + "color": "#701516", + "name": "HTML+ERB" + }, + ".ring": { + "color": "#2D54CB", + "name": "Ring" + }, + ".riot": { + "color": "#A71E49", + "name": "Riot" + }, + ".rkt": { + "color": "#3c5caa", + "name": "Racket" + }, + ".rktd": { + "color": "#3c5caa", + "name": "Racket" + }, + ".rktl": { + "color": "#3c5caa", + "name": "Racket" + }, + ".rl": { + "color": "#9d5200", + "name": "Ragel" + }, + ".rnh": { + "color": "#665a4e", + "name": "RUNOFF" + }, + ".rno": { + "color": "#ecdebe", + "name": "Roff" + }, + ".robot": { + "color": "#00c0b5", + "name": "RobotFramework" + }, + ".rockspec": { + "color": "#000080", + "name": "Lua" + }, + ".roff": { + "color": "#ecdebe", + "name": "Roff" + }, + ".rpy": { + "color": "#ff7f7f", + "name": "Ren'Py" + }, + ".rs": { + "color": "#dea584", + "name": "Rust" + }, + ".rs.in": { + "color": "#dea584", + "name": "Rust" + }, + ".rsc": { + "color": "#fffaa0", + "name": "Rascal" + }, + ".rsx": { + "color": "#198CE7", + "name": "R" + }, + ".ru": { + "color": "#701516", + "name": "Ruby" + }, + ".ruby": { + "color": "#701516", + "name": "Ruby" + }, + ".s": { + "color": "#005daa", + "name": "Motorola 68K Assembly" + }, + ".sas": { + "color": "#B34936", + "name": "SAS" + }, + ".sass": { + "color": "#a53b70", + "name": "Sass" + }, + ".sats": { + "color": "#1ac620", + "name": "ATS" + }, + ".sbt": { + "color": "#c22d40", + "name": "Scala" + }, + ".sc": { + "color": "#46390b", + "name": "SuperCollider" + }, + ".scad": { + "color": "#e5cd45", + "name": "OpenSCAD" + }, + ".scala": { + "color": "#c22d40", + "name": "Scala" + }, + ".scaml": { + "color": "#bd181a", + "name": "Scaml" + }, + ".scd": { + "color": "#46390b", + "name": "SuperCollider" + }, + ".sce": { + "color": "#ca0f21", + "name": "Scilab" + }, + ".sch": { + "color": "#1e4aec", + "name": "Scheme" + }, + ".sci": { + "color": "#ca0f21", + "name": "Scilab" + }, + ".scm": { + "color": "#1e4aec", + "name": "Scheme" + }, + ".sco": { + "color": "#1a1a1a", + "name": "Csound Score" + }, + ".scpt": { + "color": "#101F1F", + "name": "AppleScript" + }, + ".scrbl": { + "color": "#3c5caa", + "name": "Racket" + }, + ".scss": { + "color": "#c6538c", + "name": "SCSS" + }, + ".sed": { + "color": "#64b970", + "name": "sed" + }, + ".self": { + "color": "#0579aa", + "name": "Self" + }, + ".sexp": { + "color": "#3fb68b", + "name": "Common Lisp" + }, + ".sh": { + "color": "#89e051", + "name": "Shell" + }, + ".sh.in": { + "color": "#89e051", + "name": "Shell" + }, + ".shader": { + "color": "#222c37", + "name": "ShaderLab" + }, + ".shen": { + "color": "#120F14", + "name": "Shen" + }, + ".sig": { + "color": "#dc566d", + "name": "Standard ML" + }, + ".sj": { + "color": "#ff0c5a", + "name": "Objective-J" + }, + ".sjs": { + "color": "#f1e05a", + "name": "JavaScript" + }, + ".sl": { + "color": "#007eff", + "name": "Slash" + }, + ".sld": { + "color": "#1e4aec", + "name": "Scheme" + }, + ".slim": { + "color": "#2b2b2b", + "name": "Slim" + }, + ".sls": { + "color": "#1e4aec", + "name": "Scheme" + }, + ".sma": { + "color": "#dbb284", + "name": "Pawn" + }, + ".smk": { + "color": "#3572A5", + "name": "Python" + }, + ".sml": { + "color": "#dc566d", + "name": "Standard ML" + }, + ".snip": { + "color": "#199f4b", + "name": "Vim Snippet" + }, + ".snippet": { + "color": "#199f4b", + "name": "Vim Snippet" + }, + ".snippets": { + "color": "#199f4b", + "name": "Vim Snippet" + }, + ".sol": { + "color": "#AA6746", + "name": "Solidity" + }, + ".soy": { + "color": "#0d948f", + "name": "Closure Templates" + }, + ".sp": { + "color": "#f69e1d", + "name": "SourcePawn" + }, + ".spc": { + "color": "#dad8d8", + "name": "PLSQL" + }, + ".spec": { + "color": "#701516", + "name": "Ruby" + }, + ".spin": { + "color": "#7fa2a7", + "name": "Propeller Spin" + }, + ".sps": { + "color": "#1e4aec", + "name": "Scheme" + }, + ".sqf": { + "color": "#3F3F3F", + "name": "SQF" + }, + ".sql": { + "color": "#e38c00", + "name": "TSQL" + }, + ".sra": { + "color": "#8f0f8d", + "name": "PowerBuilder" + }, + ".srt": { + "color": "#348a34", + "name": "SRecode Template" + }, + ".sru": { + "color": "#8f0f8d", + "name": "PowerBuilder" + }, + ".srw": { + "color": "#8f0f8d", + "name": "PowerBuilder" + }, + ".ss": { + "color": "#1e4aec", + "name": "Scheme" + }, + ".ssjs": { + "color": "#f1e05a", + "name": "JavaScript" + }, + ".sss": { + "color": "#2fcc9f", + "name": "SugarSS" + }, + ".st": { + "color": "#3fb34f", + "name": "StringTemplate" + }, + ".stan": { + "color": "#b2011d", + "name": "Stan" + }, + ".sthlp": { + "color": "#1a5f91", + "name": "Stata" + }, + ".story": { + "color": "#5B2063", + "name": "Gherkin" + }, + ".sty": { + "color": "#3D6117", + "name": "TeX" + }, + ".styl": { + "color": "#ff6347", + "name": "Stylus" + }, + ".sv": { + "color": "#DAE1C2", + "name": "SystemVerilog" + }, + ".svelte": { + "color": "#ff3e00", + "name": "Svelte" + }, + ".svh": { + "color": "#DAE1C2", + "name": "SystemVerilog" + }, + ".swift": { + "color": "#F05138", + "name": "Swift" + }, + ".t": { + "color": "#cf142b", + "name": "Turing" + }, + ".tac": { + "color": "#3572A5", + "name": "Python" + }, + ".tcc": { + "color": "#f34b7d", + "name": "C++" + }, + ".tcl": { + "color": "#e4cc98", + "name": "Tcl" + }, + ".tcl.in": { + "color": "#e4cc98", + "name": "Tcl" + }, + ".tesc": { + "color": "#5686a5", + "name": "GLSL" + }, + ".tese": { + "color": "#5686a5", + "name": "GLSL" + }, + ".tex": { + "color": "#3D6117", + "name": "TeX" + }, + ".thor": { + "color": "#701516", + "name": "Ruby" + }, + ".thrift": { + "color": "#D12127", + "name": "Thrift" + }, + ".thy": { + "color": "#FEFE00", + "name": "Isabelle" + }, + ".tla": { + "color": "#4b0079", + "name": "TLA" + }, + ".tm": { + "color": "#e4cc98", + "name": "Tcl" + }, + ".tmac": { + "color": "#ecdebe", + "name": "Roff" + }, + ".tmux": { + "color": "#89e051", + "name": "Shell" + }, + ".toc": { + "color": "#3D6117", + "name": "TeX" + }, + ".tool": { + "color": "#89e051", + "name": "Shell" + }, + ".tpb": { + "color": "#dad8d8", + "name": "PLSQL" + }, + ".tpl": { + "color": "#f0c040", + "name": "Smarty" + }, + ".tpp": { + "color": "#f34b7d", + "name": "C++" + }, + ".tps": { + "color": "#dad8d8", + "name": "PLSQL" + }, + ".trg": { + "color": "#dad8d8", + "name": "PLSQL" + }, + ".ts": { + "color": "#2b7489", + "name": "TypeScript" + }, + ".tst": { + "color": "#ca0f21", + "name": "Scilab" + }, + ".tsx": { + "color": "#2b7489", + "name": "TypeScript" + }, + ".tu": { + "color": "#cf142b", + "name": "Turing" + }, + ".twig": { + "color": "#c1d026", + "name": "Twig" + }, + ".txl": { + "color": "#0178b8", + "name": "TXL" + }, + ".uc": { + "color": "#a54c4d", + "name": "UnrealScript" + }, + ".udo": { + "color": "#1a1a1a", + "name": "Csound" + }, + ".uno": { + "color": "#9933cc", + "name": "Uno" + }, + ".upc": { + "color": "#4e3617", + "name": "Unified Parallel C" + }, + ".ur": { + "color": "#ccccee", + "name": "UrWeb" + }, + ".urs": { + "color": "#ccccee", + "name": "UrWeb" + }, + ".v": { + "color": "#b2b7f8", + "name": "Verilog" + }, + ".vala": { + "color": "#fbe5cd", + "name": "Vala" + }, + ".vapi": { + "color": "#fbe5cd", + "name": "Vala" + }, + ".vark": { + "color": "#82937f", + "name": "Gosu" + }, + ".vb": { + "color": "#945db7", + "name": "Visual Basic .NET" + }, + ".vba": { + "color": "#199f4b", + "name": "Vim Script" + }, + ".vbhtml": { + "color": "#945db7", + "name": "Visual Basic .NET" + }, + ".vbs": { + "color": "#15dcdc", + "name": "VBScript" + }, + ".vcl": { + "color": "#148AA8", + "name": "VCL" + }, + ".veo": { + "color": "#b2b7f8", + "name": "Verilog" + }, + ".vert": { + "color": "#5686a5", + "name": "GLSL" + }, + ".vh": { + "color": "#DAE1C2", + "name": "SystemVerilog" + }, + ".vhd": { + "color": "#adb2cb", + "name": "VHDL" + }, + ".vhdl": { + "color": "#adb2cb", + "name": "VHDL" + }, + ".vhf": { + "color": "#adb2cb", + "name": "VHDL" + }, + ".vhi": { + "color": "#adb2cb", + "name": "VHDL" + }, + ".vho": { + "color": "#adb2cb", + "name": "VHDL" + }, + ".vhs": { + "color": "#adb2cb", + "name": "VHDL" + }, + ".vht": { + "color": "#adb2cb", + "name": "VHDL" + }, + ".vhw": { + "color": "#adb2cb", + "name": "VHDL" + }, + ".view.lkml": { + "color": "#652B81", + "name": "LookML" + }, + ".vim": { + "color": "#199f4b", + "name": "Vim Script" + }, + ".vmb": { + "color": "#199f4b", + "name": "Vim Script" + }, + ".volt": { + "color": "#1F1F1F", + "name": "Volt" + }, + ".vrx": { + "color": "#5686a5", + "name": "GLSL" + }, + ".vsh": { + "color": "#5686a5", + "name": "GLSL" + }, + ".vshader": { + "color": "#5686a5", + "name": "GLSL" + }, + ".vue": { + "color": "#41b883", + "name": "Vue" + }, + ".vw": { + "color": "#dad8d8", + "name": "PLSQL" + }, + ".w": { + "color": "#5ce600", + "name": "OpenEdge ABL" + }, + ".wast": { + "color": "#04133b", + "name": "WebAssembly" + }, + ".wat": { + "color": "#04133b", + "name": "WebAssembly" + }, + ".watchr": { + "color": "#701516", + "name": "Ruby" + }, + ".wdl": { + "color": "#42f1f4", + "name": "wdl" + }, + ".wisp": { + "color": "#7582D1", + "name": "wisp" + }, + ".wl": { + "color": "#dd1100", + "name": "Mathematica" + }, + ".wlk": { + "color": "#a23738", + "name": "Wollok" + }, + ".wlt": { + "color": "#dd1100", + "name": "Mathematica" + }, + ".wlua": { + "color": "#000080", + "name": "Lua" + }, + ".wsgi": { + "color": "#3572A5", + "name": "Python" + }, + ".x10": { + "color": "#4B6BEF", + "name": "X10" + }, + ".x68": { + "color": "#005daa", + "name": "Motorola 68K Assembly" + }, + ".xc": { + "color": "#99DA07", + "name": "XC" + }, + ".xht": { + "color": "#e34c26", + "name": "HTML" + }, + ".xhtml": { + "color": "#e34c26", + "name": "HTML" + }, + ".xojo_code": { + "color": "#81bd41", + "name": "Xojo" + }, + ".xojo_menu": { + "color": "#81bd41", + "name": "Xojo" + }, + ".xojo_report": { + "color": "#81bd41", + "name": "Xojo" + }, + ".xojo_script": { + "color": "#81bd41", + "name": "Xojo" + }, + ".xojo_toolbar": { + "color": "#81bd41", + "name": "Xojo" + }, + ".xojo_window": { + "color": "#81bd41", + "name": "Xojo" + }, + ".xpy": { + "color": "#3572A5", + "name": "Python" + }, + ".xq": { + "color": "#5232e7", + "name": "XQuery" + }, + ".xql": { + "color": "#5232e7", + "name": "XQuery" + }, + ".xqm": { + "color": "#5232e7", + "name": "XQuery" + }, + ".xquery": { + "color": "#5232e7", + "name": "XQuery" + }, + ".xqy": { + "color": "#5232e7", + "name": "XQuery" + }, + ".xrl": { + "color": "#B83998", + "name": "Erlang" + }, + ".xsh": { + "color": "#285EEF", + "name": "Xonsh" + }, + ".xsjs": { + "color": "#f1e05a", + "name": "JavaScript" + }, + ".xsjslib": { + "color": "#f1e05a", + "name": "JavaScript" + }, + ".xsl": { + "color": "#EB8CEB", + "name": "XSLT" + }, + ".xslt": { + "color": "#EB8CEB", + "name": "XSLT" + }, + ".xtend": { + "color": "#24255d", + "name": "Xtend" + }, + ".xzap": { + "color": "#0d665e", + "name": "ZAP" + }, + ".y": { + "color": "#4B6C4B", + "name": "Yacc" + }, + ".yacc": { + "color": "#4B6C4B", + "name": "Yacc" + }, + ".yap": { + "color": "#74283c", + "name": "Prolog" + }, + ".yar": { + "color": "#220000", + "name": "YARA" + }, + ".yara": { + "color": "#220000", + "name": "YARA" + }, + ".yasnippet": { + "color": "#32AB90", + "name": "YASnippet" + }, + ".yrl": { + "color": "#B83998", + "name": "Erlang" + }, + ".yy": { + "color": "#4B6C4B", + "name": "Yacc" + }, + ".zap": { + "color": "#0d665e", + "name": "ZAP" + }, + ".zep": { + "color": "#118f9e", + "name": "Zephir" + }, + ".zig": { + "color": "#ec915c", + "name": "Zig" + }, + ".zil": { + "color": "#dc75e5", + "name": "ZIL" + }, + ".zimpl": { + "color": "#d67711", + "name": "Zimpl" + }, + ".zmpl": { + "color": "#d67711", + "name": "Zimpl" + }, + ".zpl": { + "color": "#d67711", + "name": "Zimpl" + }, + ".zs": { + "color": "#00BCD1", + "name": "ZenScript" + }, + ".zsh": { + "color": "#89e051", + "name": "Shell" + } +} \ No newline at end of file diff --git a/frontend/backend/src/data/github/graphql/__init__.py b/frontend/backend/src/data/github/graphql/__init__.py new file mode 100644 index 00000000..002a18ec --- /dev/null +++ b/frontend/backend/src/data/github/graphql/__init__.py @@ -0,0 +1,44 @@ +from src.data.github.graphql.commit import get_commits +from src.data.github.graphql.models import RawCommit, RawRepo +from src.data.github.graphql.repo import get_repo +from src.data.github.graphql.template import ( + GraphQLErrorMissingNode, + GraphQLErrorRateLimit, + GraphQLErrorTimeout, + get_query_limit, +) +from src.data.github.graphql.user.contribs.contribs import ( + get_user_contribution_calendar, + get_user_contribution_events, +) +from src.data.github.graphql.user.contribs.models import ( + RawCalendar, + RawEvents, + RawEventsCommit, + RawEventsEvent, +) +from src.data.github.graphql.user.follows.follows import ( + get_user_followers, + get_user_following, +) +from src.data.github.graphql.user.follows.models import RawFollows + +__all__ = [ + "get_commits", + "RawCommit", + "RawRepo", + "get_repo", + "GraphQLErrorMissingNode", + "GraphQLErrorRateLimit", + "GraphQLErrorTimeout", + "get_query_limit", + "get_user_contribution_calendar", + "get_user_contribution_events", + "RawCalendar", + "RawEvents", + "RawEventsCommit", + "RawEventsEvent", + "get_user_followers", + "get_user_following", + "RawFollows", +] diff --git a/frontend/backend/src/data/github/graphql/commit.py b/frontend/backend/src/data/github/graphql/commit.py new file mode 100644 index 00000000..164bc1b7 --- /dev/null +++ b/frontend/backend/src/data/github/graphql/commit.py @@ -0,0 +1,95 @@ +from typing import List, Optional + +from src.constants import PR_FILES +from src.data.github.graphql.models import RawCommit +from src.data.github.graphql.template import ( + GraphQLError, + GraphQLErrorMissingNode, + GraphQLErrorRateLimit, + GraphQLErrorTimeout, + get_template, +) + + +def get_commits( + node_ids: List[str], access_token: Optional[str] = None, catch_errors: bool = False +) -> List[Optional[RawCommit]]: + """ + Gets all repository data from graphql + :param access_token: GitHub access token + :param node_ids: List of node ids + :return: List of commits + """ + + if PR_FILES == 0: # type: ignore + query = { + "variables": {"ids": node_ids}, + "query": """ + query getCommits($ids: [ID!]!) { + nodes(ids: $ids) { + ... on Commit { + additions + deletions + changedFiles + url + } + } + } + """, + } + else: + query = { + "variables": {"ids": node_ids, "first": PR_FILES}, + "query": """ + query getCommits($ids: [ID!]!, $first: Int!) { + nodes(ids: $ids) { + ... on Commit { + additions + deletions + changedFiles + url + associatedPullRequests(first: 1) { + nodes { + changedFiles + additions + deletions + files(first: $first) { + nodes { + path + additions + deletions + } + } + } + } + } + } + } + """, + } + + try: + raw_commits = get_template(query, access_token)["data"]["nodes"] + except GraphQLErrorMissingNode as e: + return ( + get_commits(node_ids[: e.node], access_token) + + [None] + + get_commits(node_ids[e.node + 1 :], access_token) + ) + except (GraphQLErrorRateLimit, GraphQLErrorTimeout, GraphQLError) as e: + if catch_errors: + return [None for _ in node_ids] + raise e + + out: List[Optional[RawCommit]] = [] + for raw_commit in raw_commits: + try: + if "associatedPullRequests" not in raw_commit: + raw_commit["associatedPullRequests"] = {"nodes": []} + out.append(RawCommit.model_validate(raw_commit)) + except Exception as e: + if catch_errors: + out.append(None) + else: + raise e + return out diff --git a/frontend/backend/src/data/github/graphql/models.py b/frontend/backend/src/data/github/graphql/models.py new file mode 100644 index 00000000..f9122924 --- /dev/null +++ b/frontend/backend/src/data/github/graphql/models.py @@ -0,0 +1,55 @@ +from typing import List, Optional + +from pydantic import BaseModel, Field + + +class RawCommitPRFileNode(BaseModel): + path: str + additions: int + deletions: int + + +class RawCommitPRFile(BaseModel): + nodes: List[RawCommitPRFileNode] + + +class RawCommitPRNode(BaseModel): + changed_files: int = Field(alias="changedFiles") + additions: int + deletions: int + files: RawCommitPRFile + + +class RawCommitPR(BaseModel): + nodes: List[RawCommitPRNode] + + +class RawCommit(BaseModel): + additions: int + deletions: int + changed_files: int = Field(alias="changedFiles") + url: str + prs: RawCommitPR = Field(alias="associatedPullRequests") + + +class RawRepoLanguageNode(BaseModel): + name: str + color: Optional[str] + + +class RawRepoLanguageEdge(BaseModel): + node: RawRepoLanguageNode + size: int + + +class RawRepoLanguage(BaseModel): + total_count: int = Field(alias="totalCount") + total_size: int = Field(alias="totalSize") + edges: List[RawRepoLanguageEdge] + + +class RawRepo(BaseModel): + is_private: bool = Field(alias="isPrivate") + fork_count: int = Field(alias="forkCount") + stargazer_count: int = Field(alias="stargazerCount") + languages: RawRepoLanguage diff --git a/frontend/backend/src/data/github/graphql/repo.py b/frontend/backend/src/data/github/graphql/repo.py new file mode 100644 index 00000000..97868d8d --- /dev/null +++ b/frontend/backend/src/data/github/graphql/repo.py @@ -0,0 +1,50 @@ +from typing import Optional + +from src.data.github.graphql.models import RawRepo +from src.data.github.graphql.template import get_template + + +def get_repo( + owner: str, + repo: str, + access_token: Optional[str] = None, + catch_errors: bool = False, +) -> Optional[RawRepo]: + """ + Gets all repository data from graphql + :param access_token: GitHub access token + :param owner: GitHub owner + :param repo: GitHub repository + :return: RawRepo object or None if repo not present + """ + query = { + "variables": {"owner": owner, "repo": repo}, + "query": """ + query getRepo($owner: String!, $repo: String!) { + repository(owner: $owner, name: $repo) { + isPrivate, + forkCount, + stargazerCount, + languages(first: 10){ + totalCount, + totalSize, + edges{ + node { + name, + color, + }, + size, + }, + }, + } + } + """, + } + + try: + raw_repo = get_template(query, access_token)["data"]["repository"] + return RawRepo.model_validate(raw_repo) + except Exception as e: + if catch_errors: + return None + raise e diff --git a/frontend/backend/src/data/github/graphql/template.py b/frontend/backend/src/data/github/graphql/template.py new file mode 100644 index 00000000..45b924a7 --- /dev/null +++ b/frontend/backend/src/data/github/graphql/template.py @@ -0,0 +1,102 @@ +import logging +from datetime import datetime +from typing import Any, Dict, Optional, Tuple + +import requests +from requests.exceptions import ReadTimeout + +from src.constants import TIMEOUT +from src.data.github.utils import get_access_token + +s = requests.session() + + +class GraphQLError(Exception): + pass + + +class GraphQLErrorMissingNode(Exception): + def __init__(self, node: int, *args: Tuple[Any], **kwargs: Dict[str, Any]): + super(Exception, self).__init__(*args, **kwargs) + self.node = node + + +class GraphQLErrorRateLimit(Exception): + pass + + +class GraphQLErrorTimeout(Exception): + pass + + +def get_template( + query: Dict[str, Any], access_token: Optional[str] = None, retries: int = 0 +) -> Dict[str, Any]: + """ + Template for interacting with the GitHub GraphQL API + :param query: The query to be sent to the GitHub GraphQL API + :param access_token: The access token to be used for the query + :param retries: The number of retries to be made for Auth Exceptions + :return: The response from the GitHub GraphQL API + """ + start = datetime.now() + new_access_token = get_access_token(access_token) + headers: Dict[str, str] = {"Authorization": f"bearer {new_access_token}"} + + try: + r = s.post( + "https://api.github.com/graphql", + json=query, + headers=headers, + timeout=TIMEOUT, + ) + except ReadTimeout: + raise GraphQLErrorTimeout("GraphQL Error: Request Timeout") + + print("GraphQL", new_access_token, datetime.now() - start) + if r.status_code == 200: + data = r.json() + if "errors" in data: + if ( + "type" in data["errors"][0] + and data["errors"][0]["type"] in ["SERVICE_UNAVAILABLE", "NOT_FOUND"] + and "path" in data["errors"][0] + and isinstance(data["errors"][0]["path"], list) + and data["errors"][0]["path"][0] == "nodes" + ): + raise GraphQLErrorMissingNode(node=int(data["errors"][0]["path"][1])) + + if retries < 2: + print("GraphQL Error, Retrying:", new_access_token) + return get_template(query, access_token, retries + 1) + + raise GraphQLError("GraphQL Error: " + str(data["errors"])) + + return data + + if r.status_code in [401, 403]: + if retries < 2: + print("GraphQL Error, Retrying:", new_access_token) + return get_template(query, access_token, retries + 1) + raise GraphQLErrorRateLimit("GraphQL Error: Unauthorized") + + if r.status_code == 502: + raise GraphQLErrorTimeout("GraphQL Error: Request Timeout") + + raise GraphQLError(f"GraphQL Error: {str(r.status_code)}") + + +def get_query_limit(access_token: str) -> int: + """ + Get the current rate limit for the GitHub GraphQL API + :param access_token: The access token to be used for the query + :return: The current rate limit for the GitHub GraphQL API + """ + try: + data = get_template( + {"query": "query { rateLimit { remaining } }"}, access_token + ) + return data["data"]["rateLimit"]["remaining"] + except Exception as e: + logging.exception(e) + return -1 diff --git a/frontend/backend/src/data/github/graphql/user/__init__.py b/frontend/backend/src/data/github/graphql/user/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/frontend/backend/src/data/github/graphql/user/contribs/__init__.py b/frontend/backend/src/data/github/graphql/user/contribs/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/frontend/backend/src/data/github/graphql/user/contribs/contribs.py b/frontend/backend/src/data/github/graphql/user/contribs/contribs.py new file mode 100644 index 00000000..d2e33c8f --- /dev/null +++ b/frontend/backend/src/data/github/graphql/user/contribs/contribs.py @@ -0,0 +1,157 @@ +# import json +from datetime import datetime +from typing import Optional + +from src.data.github.graphql.template import get_template +from src.data.github.graphql.user.contribs.models import RawCalendar, RawEvents + + +def get_user_contribution_calendar( + user_id: str, + start_date: datetime, + end_date: datetime, + access_token: Optional[str] = None, +) -> RawCalendar: + """Gets contribution calendar for a given time period (max one year)""" + if (end_date - start_date).days > 365: + raise ValueError("date range can be at most 1 year") + query = { + "variables": { + "login": user_id, + "startDate": start_date.strftime("%Y-%m-%dT%H:%M:%SZ"), + "endDate": end_date.strftime("%Y-%m-%dT%H:%M:%SZ"), + }, + "query": """ + query getUser($login: String!, $startDate: DateTime!, $endDate: DateTime!){ + user(login: $login){ + contributionsCollection(from: $startDate, to: $endDate){ + contributionCalendar{ + weeks{ + contributionDays{ + date + weekday + contributionCount + } + } + } + } + } + } + """, + } + + raw_data = get_template(query, access_token) + output = raw_data["data"]["user"]["contributionsCollection"]["contributionCalendar"] + return RawCalendar.model_validate(output) + + +def get_user_contribution_events( + user_id: str, + start_date: datetime, + end_date: datetime, + max_repos: int = 100, + first: int = 100, + after: str = "", + access_token: Optional[str] = None, +) -> RawEvents: + """Fetches user contributions (commits, issues, prs, reviews)""" + query = { + "variables": { + "login": user_id, + "startDate": start_date.strftime("%Y-%m-%dT%H:%M:%SZ"), + "endDate": end_date.strftime("%Y-%m-%dT%H:%M:%SZ"), + "maxRepos": max_repos, + "first": first, + "after": after, + }, + "query": """ + query getUser($login: String!, $startDate: DateTime!, $endDate: DateTime!, $maxRepos: Int!, $first: Int!, $after: String!) { + user(login: $login){ + contributionsCollection(from: $startDate, to: $endDate){ + commitContributionsByRepository(maxRepositories: $maxRepos){ + repository{ + nameWithOwner, + }, + totalCount:contributions(first: 1){ + totalCount + } + contributions(first: $first, after: $after){ + nodes{ + commitCount, + occurredAt, + } + pageInfo{ + hasNextPage, + endCursor + } + } + } + issueContributionsByRepository(maxRepositories: $maxRepos){ + repository{ + nameWithOwner + }, + totalCount:contributions(first: 1){ + totalCount + } + contributions(first: $first, after: $after){ + nodes{ + occurredAt, + } + pageInfo{ + hasNextPage, + endCursor + } + } + } + pullRequestContributionsByRepository(maxRepositories: $maxRepos){ + repository{ + nameWithOwner + }, + totalCount:contributions(first: 1){ + totalCount + } + contributions(first: $first, after: $after){ + nodes{ + occurredAt, + } + pageInfo{ + hasNextPage, + endCursor + } + } + } + pullRequestReviewContributionsByRepository(maxRepositories: $maxRepos){ + repository{ + nameWithOwner + }, + totalCount:contributions(first: 1){ + totalCount + } + contributions(first: $first, after: $after){ + nodes{ + occurredAt, + } + pageInfo{ + hasNextPage, + endCursor + } + } + }, + repositoryContributions(first: $maxRepos){ + totalCount + nodes{ + repository{ + nameWithOwner, + } + occurredAt, + } + }, + }, + } + } + """, + } + + raw_data = get_template(query, access_token) + output = raw_data["data"]["user"]["contributionsCollection"] + return RawEvents.model_validate(output) diff --git a/frontend/backend/src/data/github/graphql/user/contribs/models.py b/frontend/backend/src/data/github/graphql/user/contribs/models.py new file mode 100644 index 00000000..e541ff61 --- /dev/null +++ b/frontend/backend/src/data/github/graphql/user/contribs/models.py @@ -0,0 +1,91 @@ +from datetime import date, datetime +from typing import List, Optional + +from pydantic import BaseModel, Field + + +class RawCalendarDay(BaseModel): + date: date + weekday: int + count: int = Field(alias="contributionCount") + + +class RawCalendarWeek(BaseModel): + contribution_days: List[RawCalendarDay] = Field(alias="contributionDays") + + +class RawCalendar(BaseModel): + weeks: List[RawCalendarWeek] + + +class RawEventsRepoName(BaseModel): + name: str = Field(alias="nameWithOwner") + + +class RawEventsCount(BaseModel): + count: int = Field(alias="totalCount") + + +class RawEventsCommit(BaseModel): + count: int = Field(alias="commitCount") + occurred_at: datetime = Field(alias="occurredAt") + + +class RawEventsEvent(BaseModel): + occurred_at: datetime = Field(alias="occurredAt") + + +class RawEventsPageInfo(BaseModel): + has_next_page: bool = Field(alias="hasNextPage") + end_cursor: Optional[str] = Field(alias="endCursor") + + class Config: + allow_none = True + + +class RawEventsCommits(BaseModel): + nodes: List[RawEventsCommit] + page_info: RawEventsPageInfo = Field(alias="pageInfo") + + +class RawEventsContribs(BaseModel): + nodes: List[RawEventsEvent] + page_info: RawEventsPageInfo = Field(alias="pageInfo") + + +class RawEventsRepoCommits(BaseModel): + repo: RawEventsRepoName = Field(alias="repository") + count: RawEventsCount = Field(alias="totalCount") + contribs: RawEventsCommits = Field(alias="contributions") + + +class RawEventsRepo(BaseModel): + repo: RawEventsRepoName = Field(alias="repository") + count: RawEventsCount = Field(alias="totalCount") + contribs: RawEventsContribs = Field(alias="contributions") + + +class RawEventsRepoEvent(BaseModel): + repo: RawEventsRepoName = Field(alias="repository") + occurred_at: datetime = Field(alias="occurredAt") + + +class RawEventsRepoContribs(BaseModel): + count: int = Field(alias="totalCount") + nodes: List[RawEventsRepoEvent] + + +class RawEvents(BaseModel): + commit_contribs_by_repo: List[RawEventsRepoCommits] = Field( + alias="commitContributionsByRepository" + ) + issue_contribs_by_repo: List[RawEventsRepo] = Field( + alias="issueContributionsByRepository" + ) + pr_contribs_by_repo: List[RawEventsRepo] = Field( + alias="pullRequestContributionsByRepository" + ) + review_contribs_by_repo: List[RawEventsRepo] = Field( + alias="pullRequestReviewContributionsByRepository" + ) + repo_contribs: RawEventsRepoContribs = Field(alias="repositoryContributions") diff --git a/frontend/backend/src/data/github/graphql/user/follows/__init__.py b/frontend/backend/src/data/github/graphql/user/follows/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/frontend/backend/src/data/github/graphql/user/follows/follows.py b/frontend/backend/src/data/github/graphql/user/follows/follows.py new file mode 100644 index 00000000..9546b368 --- /dev/null +++ b/frontend/backend/src/data/github/graphql/user/follows/follows.py @@ -0,0 +1,121 @@ +# import json +from typing import Dict, Optional, Union + +from src.data.github.graphql.template import get_template +from src.data.github.graphql.user.follows.models import RawFollows + + +def get_user_followers( + user_id: str, first: int = 100, after: str = "", access_token: Optional[str] = None +) -> RawFollows: + """gets user's followers and users following'""" + + variables: Dict[str, Union[str, int]] = ( + {"login": user_id, "first": first, "after": after} + if after != "" + else {"login": user_id, "first": first} + ) + + query_str: str = ( + """ + query getUser($login: String!, $first: Int!, $after: String!) { + user(login: $login){ + followers(first: $first, after: $after){ + nodes{ + name, + login, + url + } + pageInfo{ + hasNextPage, + endCursor + } + } + } + } + """ + if after != "" + else """ + query getUser($login: String!, $first: Int!) { + user(login: $login){ + followers(first: $first){ + nodes{ + name, + login, + url + } + pageInfo{ + hasNextPage, + endCursor + } + } + } + } + """ + ) + + query = { + "variables": variables, + "query": query_str, + } + + output_dict = get_template(query, access_token)["data"]["user"]["followers"] + return RawFollows.model_validate(output_dict) + + +def get_user_following( + user_id: str, first: int = 10, after: str = "", access_token: Optional[str] = None +) -> RawFollows: + """gets user's followers and users following'""" + + variables: Dict[str, Union[str, int]] = ( + {"login": user_id, "first": first, "after": after} + if after != "" + else {"login": user_id, "first": first} + ) + + query_str: str = ( + """ + query getUser($login: String!, $first: Int!, $after: String!) { + user(login: $login){ + following(first: $first, after: $after){ + nodes{ + name, + login, + url + } + pageInfo{ + hasNextPage, + endCursor + } + } + } + } + """ + if after != "" + else """ + query getUser($login: String!, $first: Int!) { + user(login: $login){ + following(first: $first){ + nodes{ + name, + login, + url + } + pageInfo{ + hasNextPage, + endCursor + } + } + } + } + """ + ) + + query = { + "variables": variables, + "query": query_str, + } + + output_dict = get_template(query, access_token)["data"]["user"]["following"] + return RawFollows.model_validate(output_dict) diff --git a/frontend/backend/src/data/github/graphql/user/follows/models.py b/frontend/backend/src/data/github/graphql/user/follows/models.py new file mode 100644 index 00000000..82fcaac0 --- /dev/null +++ b/frontend/backend/src/data/github/graphql/user/follows/models.py @@ -0,0 +1,18 @@ +from typing import List, Optional + +from pydantic import BaseModel, Field + +from src.models import User + + +class PageInfo(BaseModel): + has_next_page: bool = Field(alias="hasNextPage") + end_cursor: Optional[str] = Field(alias="endCursor") + + class Config: + allow_none = True + + +class RawFollows(BaseModel): + nodes: List[User] + page_info: PageInfo = Field(alias="pageInfo") diff --git a/frontend/backend/src/data/github/language_map.py b/frontend/backend/src/data/github/language_map.py new file mode 100644 index 00000000..920b9936 --- /dev/null +++ b/frontend/backend/src/data/github/language_map.py @@ -0,0 +1,30 @@ +import json +import urllib.request +from typing import Any, Dict + +BLACKLIST = [".md"] + +with urllib.request.urlopen( + "https://raw.githubusercontent.com/blakeembrey/language-map/main/languages.json" +) as url: + data: Dict[str, Dict[str, Any]] = json.loads(url.read().decode()) + languages = { + k: v + for k, v in data.items() + if v["type"] in ["programming", "markup"] and "color" in v and "extensions" in v + } + extensions: Dict[str, Dict[str, str]] = {} + for lang_name, lang in languages.items(): + for extension in lang["extensions"]: + if extension not in BLACKLIST: + extensions[extension] = {"color": lang["color"], "name": lang_name} + extensions = dict(sorted(extensions.items(), key=lambda x: x[0])) + extensions[".tsx"]["name"] = "TypeScript" + extensions[".tsx"]["color"] = "#2B7489" + extensions[".cs"]["name"] = "C#" + extensions[".cs"]["color"] = "#178600" + extensions[".ml"]["name"] = "OCaml" + extensions[".ml"]["color"] = "#3BE133" + + with open("src/data/github/extensions.json", "w") as f: + json.dump(extensions, f, indent=4) diff --git a/frontend/backend/src/data/github/rest/__init__.py b/frontend/backend/src/data/github/rest/__init__.py new file mode 100644 index 00000000..008de45e --- /dev/null +++ b/frontend/backend/src/data/github/rest/__init__.py @@ -0,0 +1,17 @@ +from src.data.github.rest.commit import get_commit_files +from src.data.github.rest.models import RawCommit, RawCommitFile +from src.data.github.rest.repo import get_repo_commits, get_repo_stargazers +from src.data.github.rest.template import RESTError, RESTErrorNotFound +from src.data.github.rest.user import get_user, get_user_starred_repos + +__all__ = [ + "get_commit_files", + "RawCommit", + "RawCommitFile", + "get_repo_commits", + "get_repo_stargazers", + "RESTError", + "RESTErrorNotFound", + "get_user", + "get_user_starred_repos", +] diff --git a/frontend/backend/src/data/github/rest/commit.py b/frontend/backend/src/data/github/rest/commit.py new file mode 100644 index 00000000..a61a2a1e --- /dev/null +++ b/frontend/backend/src/data/github/rest/commit.py @@ -0,0 +1,28 @@ +from typing import List, Optional + +from src.data.github.rest.models import RawCommitFile +from src.data.github.rest.template import get_template + +BASE_URL = "https://api.github.com/repos/" + + +def get_commit_files( + owner: str, repo: str, sha: str, access_token: Optional[str] = None +) -> Optional[List[RawCommitFile]]: + """ + Returns raw repository data + :param owner: repository owner + :param repo: repository name + :param sha: commit sha + :param access_token: GitHub access token + :return: repository data + """ + + try: + output = get_template( + BASE_URL + owner + "/" + repo + "/commits/" + sha, access_token + ) + files = output["files"] + return [RawCommitFile.model_validate(f) for f in files] + except Exception: + return None diff --git a/frontend/backend/src/data/github/rest/models.py b/frontend/backend/src/data/github/rest/models.py new file mode 100644 index 00000000..d441d84a --- /dev/null +++ b/frontend/backend/src/data/github/rest/models.py @@ -0,0 +1,14 @@ +from datetime import datetime + +from pydantic import BaseModel + + +class RawCommit(BaseModel): + timestamp: datetime + node_id: str + + +class RawCommitFile(BaseModel): + filename: str + additions: int + deletions: int diff --git a/frontend/backend/src/data/github/rest/repo.py b/frontend/backend/src/data/github/rest/repo.py new file mode 100644 index 00000000..a394259b --- /dev/null +++ b/frontend/backend/src/data/github/rest/repo.py @@ -0,0 +1,174 @@ +import logging +from datetime import datetime +from typing import Any, Dict, List, Optional + +from src.data.github.rest.models import RawCommit +from src.data.github.rest.template import RESTError, get_template, get_template_plural + +BASE_URL = "https://api.github.com/repos/" + + +# NOTE: unused, untested +def get_repo(access_token: str, owner: str, repo: str) -> Dict[str, Any]: + """ + Returns raw repository data + :param access_token: GitHub access token + :param owner: repository owner + :param repo: repository name + :return: repository data + """ + return get_template(BASE_URL + owner + "/" + repo, access_token) + + +# NOTE: unused, untested +def get_repo_languages( + access_token: str, owner: str, repo: str +) -> List[Dict[str, Any]]: + """ + Returns repository language breakdown + :param access_token: GitHub access token + :param owner: repository owner + :param repo: repository name + :return: repository language breakdown + """ + return get_template_plural( + BASE_URL + owner + "/" + repo + "/languages", access_token + ) + + +def get_repo_stargazers( + access_token: str, owner: str, repo: str, per_page: int = 100, page: int = 1 +) -> List[Dict[str, Any]]: + """ + Returns stargazers with timestamp for repository + :param access_token: GitHub access token + :param owner: repository owner + :param repo: repository name + :param per_page: number of items per page + :param page: page number + :return: stargazers with timestamp for repository + """ + return get_template_plural( + BASE_URL + owner + "/" + repo + "/stargazers", + access_token, + per_page=per_page, + page=page, + accept_header="applicaiton/vnd.github.v3.star+json", + ) + + +# NOTE: unused, untested +# does not accept per page, exceeds if necessary +def get_repo_code_frequency(access_token: str, owner: str, repo: str) -> Dict[str, Any]: + """ + Returns code frequency for repository + :param access_token: GitHub access token + :param owner: repository owner + :param repo: repository name + :return: code frequency for repository + """ + return get_template( + BASE_URL + owner + "/" + repo + "/stats/code_frequency", access_token + ) + + +# NOTE: unused, untested +def get_repo_commit_activity( + access_token: str, owner: str, repo: str +) -> Dict[str, Any]: + """ + Returns commit activity for past year, broken by week + :param access_token: GitHub access token + :param owner: repository owner + :param repo: repository name + :return: commit activity for past year, broken by week + """ + return get_template( + BASE_URL + owner + "/" + repo + "/stats/commit_activity", access_token + ) + + +# NOTE: unused, untested +def get_repo_contributors(access_token: str, owner: str, repo: str) -> Dict[str, Any]: + """ + Returns contributors for a repository + :param access_token: GitHub access token + :param owner: repository owner + :param repo: repository name + :return: contributors for a repository + """ + return get_template( + BASE_URL + owner + "/" + repo + "/stats/contributors", access_token + ) + + +# NOTE: unused, untested +def get_repo_weekly_commits(access_token: str, owner: str, repo: str) -> Dict[str, Any]: + """ + Returns contributions by week, owner/non-owner + :param access_token: GitHub access token + :param owner: repository owner + :param repo: repository name + :return: contributions by week, owner/non-owner + """ + return get_template( + BASE_URL + owner + "/" + repo + "/stats/participation", access_token + ) + + +# NOTE: unused, untested +def get_repo_hourly_commits(access_token: str, owner: str, repo: str) -> Dict[str, Any]: + """ + Returns contributions by day, hour for repository + :param access_token: GitHub access token + :param owner: repository owner + :param repo: repository name + """ + return get_template( + BASE_URL + owner + "/" + repo + "/stats/punch_card", access_token + ) + + +def get_repo_commits( + owner: str, + repo: str, + user: Optional[str] = None, + since: Optional[datetime] = None, + until: Optional[datetime] = None, + page: int = 1, + access_token: Optional[str] = None, +) -> List[RawCommit]: + """ + Returns most recent commits + :param access_token: GitHub access token + :param owner: repository owner + :param repo: repository name + :param user: optional GitHub user if not owner + :param since: optional datetime to start from + :param until: optional datetime to end at + :param page: optional page number + :return: Up to 100 commits from page + """ + user = user if user is not None else owner + query = BASE_URL + owner + "/" + repo + "/commits?author=" + user + if since is not None: + query += f"&since={str(since)}" + if until is not None: + query += f"&until={str(until)}" + try: + data = get_template_plural(query, access_token, page=page) + + def extract_info(x: Any) -> RawCommit: + dt = x["commit"]["committer"]["date"] + temp = { + "timestamp": datetime.strptime(dt, "%Y-%m-%dT%H:%M:%SZ"), + "node_id": x["node_id"], + } + return RawCommit.model_validate(temp) + + return list(map(extract_info, data)) + except RESTError: + return [] + except Exception as e: + logging.exception(e) + return [] diff --git a/frontend/backend/src/data/github/rest/template.py b/frontend/backend/src/data/github/rest/template.py new file mode 100644 index 00000000..b82185cd --- /dev/null +++ b/frontend/backend/src/data/github/rest/template.py @@ -0,0 +1,112 @@ +from datetime import datetime +from typing import Any, Dict, List, Optional + +import requests +from requests.exceptions import ReadTimeout + +from src.constants import TIMEOUT +from src.data.github.utils import get_access_token + +s = requests.session() + + +class RESTError(Exception): + pass + + +class RESTErrorUnauthorized(RESTError): + pass + + +class RESTErrorNotFound(RESTError): + pass + + +class RESTErrorEmptyRepo(RESTError): + pass + + +class RESTErrorTimeout(RESTError): + pass + + +def _get_template( + query: str, + params: Dict[str, Any], + accept_header: str, + access_token: Optional[str] = None, + retries: int = 0, +) -> Any: + """ + Internal template for interacting with the GitHub REST API + :param query: The query to be sent to the GitHub API + :param params: The parameters to be sent to the GitHub API + :param access_token: The access token to be sent to the GitHub API + :param accept_header: The accept header to be sent to the GitHub API + :return: The response from the GitHub API + """ + start = datetime.now() + + new_access_token = get_access_token(access_token) + headers: Dict[str, str] = { + "Accept": accept_header, + "Authorization": f"bearer {new_access_token}", + } + + try: + r = s.get(query, params=params, headers=headers, timeout=TIMEOUT) + except ReadTimeout: + raise RESTErrorTimeout("REST Error: Request Timeout") + + if r.status_code == 200: + print("REST API", new_access_token, datetime.now() - start) + return r.json() + + if r.status_code == 401: + raise RESTErrorUnauthorized("REST Error: Unauthorized") + + if r.status_code == 404: + raise RESTErrorNotFound("REST Error: Not Found") + + if r.status_code == 409: + raise RESTErrorEmptyRepo("REST Error: Empty Repository") + + if retries < 3: + print("REST Error, Retrying:", new_access_token) + return _get_template(query, params, accept_header, access_token, retries + 1) + raise RESTError(f"REST Error: {str(r.status_code)}") + + +def get_template( + query: str, + access_token: Optional[str] = None, + accept_header: str = "application/vnd.github.v3+json", +) -> Dict[str, Any]: + """ + Template for interacting with the GitHub REST API (singular) + :param query: The query to be sent to the GitHub API + :param access_token: The access token to be sent to the GitHub API + :param accept_header: The accept header to be sent to the GitHub API + :return: The response from the GitHub API + """ + return _get_template(query, {}, accept_header, access_token) + + +def get_template_plural( + query: str, + access_token: Optional[str] = None, + per_page: int = 100, + page: int = 1, + accept_header: str = "application/vnd.github.v3+json", +) -> List[Dict[str, Any]]: + """ + Template for interacting with the GitHub REST API (plural) + :param query: The query to be sent to the GitHub API + :param access_token: The access token to be sent to the GitHub API + :param per_page: The number of items to be returned per page + :param page: The page number to be returned + :param accept_header: The accept header to be sent to the GitHub API + :return: The response from the GitHub API + """ + params: Dict[str, str] = {"per_page": str(per_page), "page": str(page)} + return _get_template(query, params, accept_header, access_token) diff --git a/frontend/backend/src/data/github/rest/user.py b/frontend/backend/src/data/github/rest/user.py new file mode 100644 index 00000000..527049b5 --- /dev/null +++ b/frontend/backend/src/data/github/rest/user.py @@ -0,0 +1,32 @@ +from typing import Any, Dict, List + +from src.data.github.rest.template import get_template, get_template_plural + +BASE_URL = "https://api.github.com/users/" + + +def get_user(user_id: str, access_token: str) -> Dict[str, Any]: + """ + Returns raw user data + :param user_id: GitHub user id + :param access_token: GitHub access token + """ + return get_template(BASE_URL + user_id, access_token) + + +def get_user_starred_repos( + user_id: str, access_token: str, per_page: int = 100, page: int = 1 +) -> List[Dict[str, Any]]: + """ + Returns list of starred repos + :param user_id: GitHub user id + :param access_token: GitHub access token + :param per_page: number of repos to return per page + """ + return get_template_plural( + BASE_URL + user_id + "/starred", + access_token, + per_page=per_page, + page=page, + accept_header="application/vnd.github.v3.star+json", + ) diff --git a/frontend/backend/src/data/github/utils.py b/frontend/backend/src/data/github/utils.py new file mode 100644 index 00000000..f3975892 --- /dev/null +++ b/frontend/backend/src/data/github/utils.py @@ -0,0 +1,7 @@ +from typing import Optional + +from src.data.mongo.secret import get_random_key + + +def get_access_token(access_token: Optional[str] = None) -> str: + return access_token if access_token is not None else get_random_key() diff --git a/frontend/backend/src/data/mongo/__init__.py b/frontend/backend/src/data/mongo/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/frontend/backend/src/data/mongo/main.py b/frontend/backend/src/data/mongo/main.py new file mode 100644 index 00000000..c8964063 --- /dev/null +++ b/frontend/backend/src/data/mongo/main.py @@ -0,0 +1,29 @@ +from motor.core import AgnosticCollection +from motor.motor_asyncio import AsyncIOMotorClient + +from src.constants import LOCAL, MONGODB_PASSWORD, PROD + + +def get_conn_str(password: str, database: str) -> str: + return f"mongodb+srv://root:{password}@backend2.e50j8dp.mongodb.net/{database}?retryWrites=true&w=majority" + + +if LOCAL: + DB = None +elif PROD: + conn_str = get_conn_str(MONGODB_PASSWORD, "prod_backend") + CLIENT = AsyncIOMotorClient( + conn_str, serverSelectionTimeoutMS=5000, tlsInsecure=True + ) + DB = CLIENT.prod_backend # type: ignore +else: + conn_str = get_conn_str(MONGODB_PASSWORD, "dev_backend") + CLIENT = AsyncIOMotorClient( # type: ignore + conn_str, serverSelectionTimeoutMS=5000, tlsInsecure=True + ) + DB = CLIENT.dev_backend # type: ignore + +# Overwrite type since only None if Local=True +SECRETS: AgnosticCollection = None if DB is None else DB.secrets # type: ignore +USERS: AgnosticCollection = None if DB is None else DB.users # type: ignore +USER_MONTHS: AgnosticCollection = None if DB is None else DB.user_months # type: ignore diff --git a/frontend/backend/src/data/mongo/secret/__init__.py b/frontend/backend/src/data/mongo/secret/__init__.py new file mode 100644 index 00000000..2ed1ba10 --- /dev/null +++ b/frontend/backend/src/data/mongo/secret/__init__.py @@ -0,0 +1,3 @@ +from src.data.mongo.secret.functions import get_random_key, update_keys + +__all__ = ["get_random_key", "update_keys"] diff --git a/frontend/backend/src/data/mongo/secret/functions.py b/frontend/backend/src/data/mongo/secret/functions.py new file mode 100644 index 00000000..809ba919 --- /dev/null +++ b/frontend/backend/src/data/mongo/secret/functions.py @@ -0,0 +1,34 @@ +from datetime import timedelta +from random import randint +from typing import Any, Dict, List, Optional, Tuple + +from src.constants import TEST_TOKEN +from src.data.mongo.main import SECRETS +from src.data.mongo.secret.models import SecretModel +from src.utils import alru_cache + + +@alru_cache(ttl=timedelta(minutes=15)) +async def get_keys(no_cache: bool = False) -> Tuple[bool, List[str]]: + secrets: Optional[Dict[str, Any]] = await SECRETS.find_one({"project": "main"}) + if secrets is None: + return (False, []) + + tokens = SecretModel.model_validate(secrets).access_tokens + return (True, tokens) + + +secret_keys: List[str] = [] + + +async def update_keys(no_cache: bool = False) -> None: + global secret_keys + secret_keys = await get_keys(no_cache=no_cache) + + +def get_random_key() -> str: + global secret_keys + if len(secret_keys) == 0: + return TEST_TOKEN + + return secret_keys[randint(0, len(secret_keys) - 1)] diff --git a/frontend/backend/src/data/mongo/secret/models.py b/frontend/backend/src/data/mongo/secret/models.py new file mode 100644 index 00000000..c3d1eff9 --- /dev/null +++ b/frontend/backend/src/data/mongo/secret/models.py @@ -0,0 +1,8 @@ +from typing import List + +from pydantic import BaseModel + + +class SecretModel(BaseModel): + project: str + access_tokens: List[str] diff --git a/frontend/backend/src/data/mongo/user/__init__.py b/frontend/backend/src/data/mongo/user/__init__.py new file mode 100644 index 00000000..a33c74fa --- /dev/null +++ b/frontend/backend/src/data/mongo/user/__init__.py @@ -0,0 +1,13 @@ +from src.data.mongo.user.functions import delete_user, is_user_key, update_user +from src.data.mongo.user.get import get_full_user, get_public_user +from src.data.mongo.user.models import FullUserModel, PublicUserModel + +__all__ = [ + "delete_user", + "is_user_key", + "update_user", + "get_full_user", + "get_public_user", + "FullUserModel", + "PublicUserModel", +] diff --git a/frontend/backend/src/data/mongo/user/functions.py b/frontend/backend/src/data/mongo/user/functions.py new file mode 100644 index 00000000..74b7f7fa --- /dev/null +++ b/frontend/backend/src/data/mongo/user/functions.py @@ -0,0 +1,28 @@ +from typing import Any, Dict, Optional + +from src.data.mongo.main import USERS + + +async def is_user_key(user_id: str, user_key: str) -> bool: + user: Optional[dict[str, str]] = await USERS.find_one( + {"user_id": user_id}, {"user_key": 1} + ) + return user is not None and user.get("user_key", "") == user_key + + +async def update_user(user_id: str, raw_user: Dict[str, Any]) -> None: + await USERS.update_one( + {"user_id": user_id}, + {"$set": raw_user}, + upsert=True, + ) + + +async def delete_user(user_id: str, user_key: str, use_user_key: bool = True) -> bool: + if use_user_key: + is_key = await is_user_key(user_id, user_key) + if not is_key: + return False + + await USERS.delete_one({"user_id": user_id}) + return True diff --git a/frontend/backend/src/data/mongo/user/get.py b/frontend/backend/src/data/mongo/user/get.py new file mode 100644 index 00000000..bf51ab94 --- /dev/null +++ b/frontend/backend/src/data/mongo/user/get.py @@ -0,0 +1,37 @@ +from typing import Any, Dict, Optional, Tuple + +from pydantic import ValidationError + +from src.data.mongo.main import USERS +from src.data.mongo.user.models import FullUserModel, PublicUserModel +from src.utils import alru_cache + + +@alru_cache() +async def get_public_user( + user_id: str, no_cache: bool = False +) -> Tuple[bool, Optional[PublicUserModel]]: + user: Optional[Dict[str, Any]] = await USERS.find_one({"user_id": user_id}) + if user is None: + # flag is false, don't cache + return (False, None) + try: + return (True, PublicUserModel.model_validate(user)) + except (TypeError, KeyError, ValidationError): + return (False, None) + + +@alru_cache() +async def get_full_user( + user_id: str, no_cache: bool = False +) -> Tuple[bool, Optional[FullUserModel]]: + user: Optional[Dict[str, Any]] = await USERS.find_one({"user_id": user_id}) + + if user is None: + # flag is false, don't cache + return (False, None) + + try: + return (True, FullUserModel.model_validate(user)) + except (TypeError, KeyError, ValidationError): + return (False, None) diff --git a/frontend/backend/src/data/mongo/user/models.py b/frontend/backend/src/data/mongo/user/models.py new file mode 100644 index 00000000..26ef9320 --- /dev/null +++ b/frontend/backend/src/data/mongo/user/models.py @@ -0,0 +1,21 @@ +from typing import Optional + +from pydantic import BaseModel, validator + + +class PublicUserModel(BaseModel): + user_id: str + access_token: str + private_access: Optional[bool] + + class Config: + from_attributes = True + validate_assignment = True + + @validator("private_access", pre=True, always=True) + def set_name(cls, private_access: Optional[bool]): + return False if private_access is None else private_access + + +class FullUserModel(PublicUserModel): + user_key: Optional[str] diff --git a/frontend/backend/src/data/mongo/user_months/__init__.py b/frontend/backend/src/data/mongo/user_months/__init__.py new file mode 100644 index 00000000..1619d06e --- /dev/null +++ b/frontend/backend/src/data/mongo/user_months/__init__.py @@ -0,0 +1,5 @@ +from src.data.mongo.user_months.functions import set_user_month +from src.data.mongo.user_months.get import get_user_months +from src.data.mongo.user_months.models import UserMonth + +__all__ = ["set_user_month", "get_user_months", "UserMonth"] diff --git a/frontend/backend/src/data/mongo/user_months/functions.py b/frontend/backend/src/data/mongo/user_months/functions.py new file mode 100644 index 00000000..78f97667 --- /dev/null +++ b/frontend/backend/src/data/mongo/user_months/functions.py @@ -0,0 +1,13 @@ +from src.data.mongo.main import USER_MONTHS +from src.data.mongo.user_months.models import UserMonth + + +async def set_user_month(user_month: UserMonth): + compressed_user_month = user_month.model_dump() + compressed_user_month["data"] = user_month.data.compress() + + await USER_MONTHS.update_one( + {"user_id": user_month.user_id, "month": user_month.month}, + {"$set": compressed_user_month}, + upsert=True, + ) diff --git a/frontend/backend/src/data/mongo/user_months/get.py b/frontend/backend/src/data/mongo/user_months/get.py new file mode 100644 index 00000000..22f09767 --- /dev/null +++ b/frontend/backend/src/data/mongo/user_months/get.py @@ -0,0 +1,52 @@ +from datetime import date, datetime +from typing import Any, Dict, List + +from src.constants import API_VERSION, USER_WHITELIST +from src.data.mongo.main import USER_MONTHS +from src.data.mongo.user_months.models import UserMonth +from src.models import UserPackage + + +async def get_user_months( + user_id: str, private_access: bool, start_month: date, end_month: date +) -> List[UserMonth]: + start = datetime(start_month.year, start_month.month, 1) + end = datetime(end_month.year, end_month.month, 28) + today = datetime.now() + + filters = { + "user_id": user_id, + "month": {"$gte": start, "$lte": end}, + "version": API_VERSION, + } + + if private_access: + filters["private"] = True + + months: List[Dict[str, Any]] = await USER_MONTHS.find(filters).to_list(length=None) # type: ignore + + months_data: List[UserMonth] = [] + for month in months: + date_obj: datetime = month["month"] + complete = ( + not (date_obj.year == today.year and date_obj.month == today.month) + or user_id in USER_WHITELIST + ) + try: + data = UserPackage.decompress(month["data"]) + months_data.append( + UserMonth.model_validate( + { + "user_id": user_id, + "month": month["month"], + "version": API_VERSION, + "private": month["private"], + "complete": complete, + "data": data.model_dump(), + } + ) + ) + except Exception: + pass + + return months_data diff --git a/frontend/backend/src/data/mongo/user_months/models.py b/frontend/backend/src/data/mongo/user_months/models.py new file mode 100644 index 00000000..76ff34bd --- /dev/null +++ b/frontend/backend/src/data/mongo/user_months/models.py @@ -0,0 +1,14 @@ +from datetime import datetime + +from pydantic import BaseModel + +from src.models import UserPackage + + +class UserMonth(BaseModel): + user_id: str + month: datetime + version: float + private: bool + complete: bool + data: UserPackage diff --git a/frontend/backend/src/main.py b/frontend/backend/src/main.py new file mode 100644 index 00000000..07df7095 --- /dev/null +++ b/frontend/backend/src/main.py @@ -0,0 +1,70 @@ +from typing import Dict + +import sentry_sdk +from dotenv import find_dotenv, load_dotenv +from fastapi import FastAPI +from fastapi.middleware.cors import CORSMiddleware +from sentry_sdk.integrations.asgi import SentryAsgiMiddleware + +load_dotenv(find_dotenv()) + +# flake8: noqa E402 + +# add endpoints here (after load dotenv) +from src.constants import PROD, SENTRY_DSN +from src.routers import ( + asset_router, + auth_router, + dev_router, + user_router, + wrapped_router, +) + +""" +SETUP +""" + +app = FastAPI() + +origins = [ + "http://localhost:3000", + "http://localhost:3001", + "https://githubtrends.io", + "https://www.githubtrends.io", + "https://githubwrapped.io", + "https://www.githubwrapped.io", +] + +app.add_middleware( + CORSMiddleware, + allow_origins=origins, + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], +) + +sentry_sdk.init( + SENTRY_DSN, + traces_sample_rate=(1.0 if PROD else 1.0), +) + +app.add_middleware( + SentryAsgiMiddleware, +) + + +@app.get("/") +async def read_root() -> Dict[str, str]: + return {"Hello": "World"} + + +@app.get("/info") +def get_info() -> Dict[str, bool]: + return {"PROD": PROD} + + +app.include_router(asset_router, prefix="/assets", tags=["Assets"]) +app.include_router(auth_router, prefix="/auth", tags=["Auth"]) +app.include_router(dev_router, prefix="/dev", tags=["Dev"]) +app.include_router(user_router, prefix="/user", tags=["Users"]) +app.include_router(wrapped_router, prefix="/wrapped", tags=["Wrapped"]) diff --git a/frontend/backend/src/models/__init__.py b/frontend/backend/src/models/__init__.py new file mode 100644 index 00000000..a9f4ca8c --- /dev/null +++ b/frontend/backend/src/models/__init__.py @@ -0,0 +1,46 @@ +from src.models.user.contribs import ( + ContributionDay, + Language, + RepoContributionStats, + UserContributions, +) +from src.models.user.follows import User, UserFollows +from src.models.user.main import UserPackage +from src.models.wrapped.calendar import ( + CalendarData, + CalendarDayDatum, + CalendarLanguageDayDatum, +) +from src.models.wrapped.langs import LangData, LangDatum +from src.models.wrapped.main import WrappedPackage +from src.models.wrapped.numeric import ContribStats, LOCStats, MiscStats, NumericData +from src.models.wrapped.repos import RepoData, RepoDatum +from src.models.wrapped.time import DayData, MonthData, TimeDatum +from src.models.wrapped.timestamps import TimestampData, TimestampDatum + +__all__ = [ + "ContributionDay", + "Language", + "RepoContributionStats", + "UserContributions", + "User", + "UserFollows", + "UserPackage", + "CalendarData", + "CalendarDayDatum", + "CalendarLanguageDayDatum", + "LangData", + "LangDatum", + "WrappedPackage", + "ContribStats", + "LOCStats", + "MiscStats", + "NumericData", + "RepoData", + "RepoDatum", + "DayData", + "MonthData", + "TimeDatum", + "TimestampData", + "TimestampDatum", +] diff --git a/frontend/backend/src/models/background.py b/frontend/backend/src/models/background.py new file mode 100644 index 00000000..3676af09 --- /dev/null +++ b/frontend/backend/src/models/background.py @@ -0,0 +1,12 @@ +from datetime import date +from typing import Optional + +from pydantic import BaseModel + + +class UpdateUserBackgroundTask(BaseModel): + user_id: str + access_token: Optional[str] + private_access: bool + start_date: Optional[date] + end_date: Optional[date] diff --git a/frontend/backend/src/models/svg.py b/frontend/backend/src/models/svg.py new file mode 100644 index 00000000..ee26a2a7 --- /dev/null +++ b/frontend/backend/src/models/svg.py @@ -0,0 +1,23 @@ +from typing import List, Optional + +from pydantic import BaseModel + + +class LanguageStats(BaseModel): + lang: str + loc: int + percent: float + color: Optional[str] + + +class RepoLanguage(BaseModel): + lang: str + color: Optional[str] + loc: int + + +class RepoStats(BaseModel): + repo: str + private: bool + langs: List[RepoLanguage] + loc: int diff --git a/frontend/backend/src/models/user/__init__.py b/frontend/backend/src/models/user/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/frontend/backend/src/models/user/contribs.py b/frontend/backend/src/models/user/contribs.py new file mode 100644 index 00000000..6d56894b --- /dev/null +++ b/frontend/backend/src/models/user/contribs.py @@ -0,0 +1,310 @@ +from datetime import date, datetime +from typing import Any, Dict, List, Optional, Tuple + +from pydantic import BaseModel + + +class Language(BaseModel): + color: Optional[str] + additions: int + deletions: int + + def compress(self) -> List[Any]: + return [self.color, self.additions, self.deletions] + + @classmethod + def decompress(cls, data: List[Any]) -> "Language": + return Language(color=data[0], additions=data[1], deletions=data[2]) + + def __add__(self, other: "Language") -> "Language": + return Language( + color=self.color, + additions=self.additions + other.additions, + deletions=self.deletions + other.deletions, + ) + + +class ContributionStats(BaseModel): + contribs_count: int + commits_count: int + issues_count: int + prs_count: int + reviews_count: int + repos_count: int + other_count: int + languages: Dict[str, Language] + + def compress(self) -> List[Any]: + out: List[Any] = [ + [ + self.contribs_count, + self.commits_count, + self.issues_count, + self.prs_count, + self.reviews_count, + self.repos_count, + self.other_count, + ], + *[[name] + stats.compress() for name, stats in self.languages.items()], + ] + + return out + + @classmethod + def decompress(cls, data: List[Any]) -> "ContributionStats": + return ContributionStats( + contribs_count=data[0][0], + commits_count=data[0][1], + issues_count=data[0][2], + prs_count=data[0][3], + reviews_count=data[0][4], + repos_count=data[0][5], + other_count=data[0][6], + languages={x[0]: Language.decompress(x[1:]) for x in data[1:]}, + ) + + def __add__(self, other: "ContributionStats") -> "ContributionStats": + languages = self.languages + for lang, lang_obj in other.languages.items(): + if lang in languages: + languages[lang] += lang_obj + else: + languages[lang] = lang_obj + + return ContributionStats( + contribs_count=self.contribs_count + other.contribs_count, + commits_count=self.commits_count + other.commits_count, + issues_count=self.issues_count + other.issues_count, + prs_count=self.prs_count + other.prs_count, + reviews_count=self.reviews_count + other.reviews_count, + repos_count=self.repos_count + other.repos_count, + other_count=self.other_count + other.other_count, + languages=languages, + ) + + @classmethod + def empty(cls) -> "ContributionStats": + return ContributionStats( + contribs_count=0, + commits_count=0, + issues_count=0, + prs_count=0, + reviews_count=0, + repos_count=0, + other_count=0, + languages={}, + ) + + +class ContributionLists(BaseModel): + commits: List[datetime] + issues: List[datetime] + prs: List[datetime] + reviews: List[datetime] + repos: List[datetime] + + def compress(self) -> List[Any]: + return [self.commits, self.issues, self.prs, self.reviews, self.repos] + + @classmethod + def decompress(cls, data: List[Any]) -> "ContributionLists": + return ContributionLists( + commits=data[0], issues=data[1], prs=data[2], reviews=data[3], repos=data[4] + ) + + +class ContributionDay(BaseModel): + date: str + weekday: int + stats: ContributionStats + lists: ContributionLists + + def compress(self) -> List[Any]: + return [ + self.date, + self.weekday, + self.stats.compress(), + self.lists.compress(), + ] + + @classmethod + def decompress(cls, data: List[Any]) -> "ContributionDay": + return ContributionDay( + date=data[0], + weekday=data[1], + stats=ContributionStats.decompress(data[2]), + lists=ContributionLists.decompress(data[3]), + ) + + +class RepoContributionStats(ContributionStats, BaseModel): + private: bool + contribs_count: int + commits_count: int + issues_count: int + prs_count: int + reviews_count: int + repos_count: int + other_count: int + languages: Dict[str, Language] + + def compress(self) -> List[Any]: + out = super().compress() + out[0].append(self.private) + return out + + @classmethod + def decompress(cls, data: List[Any]) -> "RepoContributionStats": + contribs = super().decompress(data).model_dump() + contribs["private"] = data[0][7] + return RepoContributionStats(**contribs) + + def __add__( # type: ignore + self, other: "RepoContributionStats" + ) -> "RepoContributionStats": + new_self = ContributionStats(**self.model_dump()) + new_other = ContributionStats(**other.model_dump()) + combined = (new_self + new_other).model_dump() + combined["private"] = self.private + return RepoContributionStats(**combined) + + +class UserContributions(BaseModel): + total_stats: ContributionStats + public_stats: ContributionStats + total: List[ContributionDay] + public: List[ContributionDay] + repo_stats: Dict[str, RepoContributionStats] + repos: Dict[str, List[ContributionDay]] + + def compress(self) -> List[Any]: + new_total_stats = self.total_stats.compress() + new_public_stats = self.public_stats.compress() + new_total = [x.compress() for x in self.total] + new_public = [x.compress() for x in self.public] + new_repo_stats = {k: v.compress() for k, v in self.repo_stats.items()} + new_repos = {k: [x.compress() for x in v] for k, v in self.repos.items()} + + return [ + new_total_stats, + new_public_stats, + new_total, + new_public, + new_repo_stats, + new_repos, + ] + + @classmethod + def decompress(cls, data: List[Any]) -> "UserContributions": + total_stats = ContributionStats.decompress(data[0]) + public_stats = ContributionStats.decompress(data[1]) + total = [ContributionDay.decompress(x) for x in data[2]] + public = [ContributionDay.decompress(x) for x in data[3]] + repo_stats = { + k: RepoContributionStats.decompress(v) for k, v in data[4].items() + } + repos = { + k: [ContributionDay.decompress(x) for x in v] for k, v in data[5].items() + } + + return UserContributions( + total_stats=total_stats, + public_stats=public_stats, + total=total, + public=public, + repo_stats=repo_stats, + repos=repos, + ) + + def __add__(self, other: "UserContributions") -> "UserContributions": + new_total_stats = self.total_stats + other.total_stats + new_public_stats = self.public_stats + other.public_stats + new_total = sorted(self.total + other.total, key=lambda x: x.date) + new_public = sorted(self.public + other.public, key=lambda x: x.date) + new_repo_stats = self.repo_stats + for repo, stats in other.repo_stats.items(): + if repo in new_repo_stats: + new_repo_stats[repo] += stats + else: + new_repo_stats[repo] = stats + new_repos = self.repos + for repo, days in other.repos.items(): + if repo in new_repos: + new_repos[repo] = sorted(new_repos[repo] + days, key=lambda x: x.date) + else: + new_repos[repo] = days + + return UserContributions( + total_stats=new_total_stats, + public_stats=new_public_stats, + total=new_total, + public=new_public, + repo_stats=new_repo_stats, + repos=new_repos, + ) + + @staticmethod + def trim_contribs( + contribs: List[ContributionDay], start_date: date, end_date: date + ) -> Tuple[List[ContributionDay], ContributionStats]: + new_total: List[ContributionDay] = [] + for day in contribs: + curr_date = datetime.strptime(day.date, "%Y-%m-%d").date() + if curr_date >= start_date and curr_date <= end_date: + new_total.append(day) + + new_languages: Dict[str, Language] = {} + for day in new_total: + for lang in day.stats.languages: + if lang in new_languages: + new_languages[lang] += day.stats.languages[lang] + else: + new_languages[lang] = day.stats.languages[lang] + + new_stats = ContributionStats( + contribs_count=sum(x.stats.contribs_count for x in new_total), + commits_count=sum(x.stats.commits_count for x in new_total), + issues_count=sum(x.stats.issues_count for x in new_total), + prs_count=sum(x.stats.prs_count for x in new_total), + reviews_count=sum(x.stats.reviews_count for x in new_total), + repos_count=sum(x.stats.repos_count for x in new_total), + other_count=sum(x.stats.other_count for x in new_total), + languages=new_languages, + ) + + return new_total, new_stats + + def trim(self, start: date, end: date) -> "UserContributions": + new_total, new_total_stats = self.trim_contribs(self.total, start, end) + new_public, new_public_stats = self.trim_contribs(self.public, start, end) + + new_repos_dict: Dict[str, List[ContributionDay]] = {} + new_repo_stats_dict: Dict[str, RepoContributionStats] = {} + for repo_name, repo in self.repos.items(): + new_repo_total, _new_repo_stats = self.trim_contribs(repo, start, end) + if len(new_repo_total) > 0: + new_repos_dict[repo_name] = new_repo_total + raw_new_repo_stats = _new_repo_stats.model_dump() + raw_new_repo_stats["private"] = self.repo_stats[repo_name].private + new_repo_stats = RepoContributionStats(**raw_new_repo_stats) + new_repo_stats_dict[repo_name] = new_repo_stats + + return UserContributions( + total_stats=new_total_stats, + public_stats=new_public_stats, + total=new_total, + public=new_public, + repo_stats=new_repo_stats_dict, + repos=new_repos_dict, + ) + + @classmethod + def empty(cls) -> "UserContributions": + return UserContributions( + total_stats=ContributionStats.empty(), + public_stats=ContributionStats.empty(), + total=[], + public=[], + repo_stats={}, + repos={}, + ) diff --git a/frontend/backend/src/models/user/follows.py b/frontend/backend/src/models/user/follows.py new file mode 100644 index 00000000..ee878fa8 --- /dev/null +++ b/frontend/backend/src/models/user/follows.py @@ -0,0 +1,17 @@ +from typing import List, Optional + +from pydantic import BaseModel + + +class User(BaseModel): + name: Optional[str] + login: str + url: str + + class Config: + allow_none = True + + +class UserFollows(BaseModel): + followers: List[User] + following: List[User] diff --git a/frontend/backend/src/models/user/main.py b/frontend/backend/src/models/user/main.py new file mode 100644 index 00000000..ed3a0b62 --- /dev/null +++ b/frontend/backend/src/models/user/main.py @@ -0,0 +1,34 @@ +from datetime import date +from typing import Any, Dict + +from pydantic import BaseModel + +from src.models.user.contribs import UserContributions + +# from src.models.user.follows import UserFollows + + +class UserPackage(BaseModel): + contribs: UserContributions + incomplete: bool = False + + def compress(self): + return { + "c": self.contribs.compress(), + } + + @classmethod + def decompress(cls, data: Dict[str, Any]) -> "UserPackage": + return UserPackage( + contribs=UserContributions.decompress(data["c"]), + ) + + def __add__(self, other: "UserPackage") -> "UserPackage": + return UserPackage(contribs=self.contribs + other.contribs) + + def trim(self, start: date, end: date) -> "UserPackage": + return UserPackage(contribs=self.contribs.trim(start, end)) + + @classmethod + def empty(cls) -> "UserPackage": + return UserPackage(contribs=UserContributions.empty()) diff --git a/frontend/backend/src/models/wrapped/__init__.py b/frontend/backend/src/models/wrapped/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/frontend/backend/src/models/wrapped/calendar.py b/frontend/backend/src/models/wrapped/calendar.py new file mode 100644 index 00000000..5019561e --- /dev/null +++ b/frontend/backend/src/models/wrapped/calendar.py @@ -0,0 +1,28 @@ +from typing import Dict, List + +from pydantic import BaseModel + + +class CalendarLanguageDayDatum(BaseModel): + loc_added: int + loc_changed: int + + +class CalendarDayDatum(BaseModel): + day: str + contribs: int + commits: int + issues: int + prs: int + reviews: int + loc_added: int + loc_changed: int + top_langs: Dict[str, CalendarLanguageDayDatum] + + +class CalendarData(BaseModel): + days: List[CalendarDayDatum] + + @classmethod + def empty(cls) -> "CalendarData": + return CalendarData(days=[]) diff --git a/frontend/backend/src/models/wrapped/langs.py b/frontend/backend/src/models/wrapped/langs.py new file mode 100644 index 00000000..e68abcfb --- /dev/null +++ b/frontend/backend/src/models/wrapped/langs.py @@ -0,0 +1,20 @@ +from typing import List + +from pydantic import BaseModel + + +class LangDatum(BaseModel): + id: str + label: str + value: int + formatted_value: str + color: str + + +class LangData(BaseModel): + langs_changed: List[LangDatum] + langs_added: List[LangDatum] + + @classmethod + def empty(cls) -> "LangData": + return LangData(langs_changed=[], langs_added=[]) diff --git a/frontend/backend/src/models/wrapped/main.py b/frontend/backend/src/models/wrapped/main.py new file mode 100644 index 00000000..0fe759a4 --- /dev/null +++ b/frontend/backend/src/models/wrapped/main.py @@ -0,0 +1,31 @@ +from pydantic import BaseModel + +from src.models.wrapped.calendar import CalendarData +from src.models.wrapped.langs import LangData +from src.models.wrapped.numeric import NumericData +from src.models.wrapped.repos import RepoData +from src.models.wrapped.time import DayData, MonthData +from src.models.wrapped.timestamps import TimestampData + + +class WrappedPackage(BaseModel): + month_data: MonthData + day_data: DayData + calendar_data: CalendarData + numeric_data: NumericData + repo_data: RepoData + lang_data: LangData + timestamp_data: TimestampData + incomplete: bool = False + + @classmethod + def empty(cls) -> "WrappedPackage": + return WrappedPackage( + month_data=MonthData.empty(), + day_data=DayData.empty(), + calendar_data=CalendarData.empty(), + numeric_data=NumericData.empty(), + repo_data=RepoData.empty(), + lang_data=LangData.empty(), + timestamp_data=TimestampData.empty(), + ) diff --git a/frontend/backend/src/models/wrapped/numeric.py b/frontend/backend/src/models/wrapped/numeric.py new file mode 100644 index 00000000..3a3a0e04 --- /dev/null +++ b/frontend/backend/src/models/wrapped/numeric.py @@ -0,0 +1,85 @@ +from typing import Optional, Tuple + +from pydantic import BaseModel + + +class ContribStats(BaseModel): + contribs: int + commits: int + issues: int + prs: int + reviews: int + other: int + + @classmethod + def empty(cls) -> "ContribStats": + return ContribStats( + contribs=0, + commits=0, + issues=0, + prs=0, + reviews=0, + other=0, + ) + + +class MiscStats(BaseModel): + total_days: int + longest_streak: int + longest_streak_days: Tuple[int, int, str, str] + longest_gap: int + longest_gap_days: Tuple[int, int, str, str] + weekend_percent: int + best_day_count: int + best_day_date: Optional[str] + best_day_index: Optional[int] + + @classmethod + def empty(cls) -> "MiscStats": + return MiscStats( + total_days=0, + longest_streak=0, + longest_streak_days=(0, 0, "", ""), + longest_gap=0, + longest_gap_days=(0, 0, "", ""), + weekend_percent=0, + best_day_count=0, + best_day_date=None, + best_day_index=None, + ) + + +class LOCStats(BaseModel): + loc_additions: str + loc_deletions: str + loc_changed: str + loc_added: str + loc_additions_per_commit: int + loc_deletions_per_commit: int + loc_changed_per_day: int + + @classmethod + def empty(cls) -> "LOCStats": + return LOCStats( + loc_additions="0", + loc_deletions="0", + loc_changed="0", + loc_added="0", + loc_additions_per_commit=0, + loc_deletions_per_commit=0, + loc_changed_per_day=0, + ) + + +class NumericData(BaseModel): + contribs: ContribStats + misc: MiscStats + loc: LOCStats + + @classmethod + def empty(cls) -> "NumericData": + return NumericData( + contribs=ContribStats.empty(), + misc=MiscStats.empty(), + loc=LOCStats.empty(), + ) diff --git a/frontend/backend/src/models/wrapped/repos.py b/frontend/backend/src/models/wrapped/repos.py new file mode 100644 index 00000000..ab89abd1 --- /dev/null +++ b/frontend/backend/src/models/wrapped/repos.py @@ -0,0 +1,19 @@ +from typing import List + +from pydantic import BaseModel + + +class RepoDatum(BaseModel): + id: int + label: str + value: int + formatted_value: str + + +class RepoData(BaseModel): + repos_changed: List[RepoDatum] + repos_added: List[RepoDatum] + + @classmethod + def empty(cls) -> "RepoData": + return RepoData(repos_changed=[], repos_added=[]) diff --git a/frontend/backend/src/models/wrapped/time.py b/frontend/backend/src/models/wrapped/time.py new file mode 100644 index 00000000..6e863119 --- /dev/null +++ b/frontend/backend/src/models/wrapped/time.py @@ -0,0 +1,26 @@ +from typing import List + +from pydantic import BaseModel + + +class TimeDatum(BaseModel): + index: int + contribs: int + loc_changed: int + formatted_loc_changed: str + + +class MonthData(BaseModel): + months: List[TimeDatum] + + @classmethod + def empty(cls) -> "MonthData": + return MonthData(months=[]) + + +class DayData(BaseModel): + days: List[TimeDatum] + + @classmethod + def empty(cls) -> "DayData": + return DayData(days=[]) diff --git a/frontend/backend/src/models/wrapped/timestamps.py b/frontend/backend/src/models/wrapped/timestamps.py new file mode 100644 index 00000000..8f9f44c7 --- /dev/null +++ b/frontend/backend/src/models/wrapped/timestamps.py @@ -0,0 +1,17 @@ +from typing import List + +from pydantic import BaseModel + + +class TimestampDatum(BaseModel): + type: str + weekday: int + timestamp: int + + +class TimestampData(BaseModel): + contribs: List[TimestampDatum] + + @classmethod + def empty(cls) -> "TimestampData": + return TimestampData(contribs=[]) diff --git a/frontend/backend/src/processing/auth.py b/frontend/backend/src/processing/auth.py new file mode 100644 index 00000000..c014772a --- /dev/null +++ b/frontend/backend/src/processing/auth.py @@ -0,0 +1,67 @@ +from typing import Any, Dict, Optional, Tuple + +from src.data.github.auth import authenticate as github_authenticate +from src.data.mongo.user import ( + PublicUserModel, + delete_user as db_delete_user, + get_public_user as db_get_public_user, + update_user as db_update_user, +) +from src.models.background import UpdateUserBackgroundTask + +# frontend first calls set_user_key with code and user_key +# next they call authenticate which determines the user_id to associate with the code/user_key +# these actions should happen sequentially, so in-memory storage is fine +code_key_map: Dict[str, str] = {} + + +async def set_user_key(code: str, user_key: str) -> str: + code_key_map[code] = user_key + return user_key + + +async def authenticate( + code: str, private_access: bool +) -> Tuple[str, Optional[UpdateUserBackgroundTask]]: + user_id, access_token = await github_authenticate(code) + + curr_user: Optional[PublicUserModel] = await db_get_public_user(user_id) + + raw_user: Dict[str, Any] = { + "user_id": user_id, + "access_token": access_token, + "user_key": code_key_map.get(code, None), + "private_access": private_access, + } + + background_task = None + if curr_user is not None: + curr_private_access = curr_user.private_access + new_private_access = curr_private_access or private_access + raw_user["private_access"] = new_private_access + + if new_private_access != curr_private_access: + # new private access status + background_task = UpdateUserBackgroundTask( + user_id=user_id, + access_token=access_token, + private_access=new_private_access, + start_date=None, + end_date=None, + ) + else: + # first time sign up + background_task = UpdateUserBackgroundTask( + user_id=user_id, + access_token=access_token, + private_access=private_access, + start_date=None, + end_date=None, + ) + + await db_update_user(user_id, raw_user) + return user_id, background_task + + +async def delete_user(user_id: str, user_key: str, use_user_key: bool = True) -> bool: + return await db_delete_user(user_id, user_key, use_user_key) diff --git a/frontend/backend/src/processing/user/__init__.py b/frontend/backend/src/processing/user/__init__.py new file mode 100644 index 00000000..c34d1792 --- /dev/null +++ b/frontend/backend/src/processing/user/__init__.py @@ -0,0 +1,4 @@ +from src.processing.user.commits import get_top_languages, get_top_repos +from src.processing.user.svg import svg_base + +__all__ = ["get_top_languages", "get_top_repos", "svg_base"] diff --git a/frontend/backend/src/processing/user/commits.py b/frontend/backend/src/processing/user/commits.py new file mode 100644 index 00000000..38f90470 --- /dev/null +++ b/frontend/backend/src/processing/user/commits.py @@ -0,0 +1,136 @@ +from typing import Any, Dict, List, Optional, Tuple, Union + +from src.constants import DEFAULT_COLOR +from src.models import UserPackage +from src.models.svg import LanguageStats, RepoStats + +dict_type = Dict[str, Union[str, int, float]] + + +def loc_metric_func(loc_metric: str, additions: int, deletions: int) -> int: + if loc_metric == "changed": + return additions + deletions + return additions - deletions + + +def get_top_languages( + data: UserPackage, loc_metric: str, include_private: bool +) -> Tuple[List[LanguageStats], int]: + raw_languages = ( + data.contribs.total_stats.languages + if include_private + else data.contribs.public_stats.languages + ) + + languages_list = [ + LanguageStats( + lang=lang, + color=stats.color or DEFAULT_COLOR, + loc=loc_metric_func(loc_metric, stats.additions, stats.deletions), + percent=-1, + ) + for lang, stats in raw_languages.items() + ] + + languages_list = list(filter(lambda x: x.loc > 0, languages_list)) + + total_loc = sum(x.loc for x in languages_list) + 1 + total = LanguageStats(lang="Total", color=None, loc=total_loc, percent=100) + + languages_list = sorted(languages_list, key=lambda x: x.loc, reverse=True) + other = LanguageStats(lang="Other", color="#ededed", loc=0, percent=-1) + for language in languages_list[4:]: + other.loc = other.loc + language.loc + + languages_list = [total] + languages_list[:4] + [other] + + new_languages_list: List[LanguageStats] = [] + for lang in languages_list: + lang.percent = float(round(100 * lang.loc / total_loc, 2)) + if lang.percent > 1: # 1% minimum to show + new_languages_list.append(LanguageStats.model_validate(lang)) + + commits_excluded = data.contribs.public_stats.other_count + if include_private: + commits_excluded = data.contribs.total_stats.other_count + + return new_languages_list, commits_excluded + + +def get_top_repos( + data: UserPackage, loc_metric: str, include_private: bool, group: str +) -> Tuple[List[RepoStats], int]: + repos: List[Any] = [ + { + "repo": repo, + "private": repo_stats.private, + "langs": [ + { + "lang": x[0], + "color": x[1].color, + "loc": loc_metric_func(loc_metric, x[1].additions, x[1].deletions), + } + for x in list(repo_stats.languages.items()) + ], + } + for repo, repo_stats in data.contribs.repo_stats.items() + if include_private or not repo_stats.private + ] + + for repo in repos: + repo["loc"] = sum(x["loc"] for x in repo["langs"]) # first estimate + repos = list(filter(lambda x: x["loc"] > 0, repos)) + + for repo in repos: + repo["langs"] = [x for x in repo["langs"] if x["loc"] > 0.05 * repo["loc"]] + repo["loc"] = sum(x["loc"] for x in repo["langs"]) # final estimate + repos = sorted(repos, key=lambda x: x["loc"], reverse=True) + + new_repos = [ + RepoStats.model_validate(x) for x in repos if x["loc"] > 0.01 * repos[0]["loc"] + ] + + commits_excluded = data.contribs.public_stats.other_count + if include_private: + commits_excluded = data.contribs.total_stats.other_count + + # With n bars, group from n onwards into the last bar + bars = 4 # TODO: make this configurable (see issues) + + if group == "none" or len(new_repos) <= bars: + return new_repos[:bars], commits_excluded + + out_repos = [] + other_repos = [] + if group == "other": + out_repos = new_repos[: bars - 1] + other_repos = new_repos[bars - 1 :] + elif group == "private": + public_repos = [x for x in new_repos if not x.private] + private_repos = [x for x in new_repos if x.private] + if len(public_repos) < 4 and len(private_repos) > 0: + public_repos += private_repos[: bars - len(public_repos) - 1] + private_repos = private_repos[bars - len(public_repos) - 1 :] + out_repos = sorted(public_repos[: bars - 1], key=lambda x: x.loc, reverse=True) + other_repos = public_repos[bars - 1 :] + private_repos + else: + raise ValueError("Invalid group value") + + other: Dict[str, Tuple[int, Optional[str]]] = {} + for repo in other_repos: + for _lang in repo.langs: + lang = _lang.lang + if lang not in other: + other[lang] = (0, _lang.color) + other[lang] = (other[lang][0] + _lang.loc, other[lang][1]) + + out_repos.append( + RepoStats( + repo="other/repos", + private=False, + langs=[{"lang": k, "loc": v[0], "color": v[1]} for k, v in other.items()], # type: ignore + loc=sum(v[0] for v in other.values()), + ) + ) + + return out_repos, commits_excluded diff --git a/frontend/backend/src/processing/user/svg.py b/frontend/backend/src/processing/user/svg.py new file mode 100644 index 00000000..6a3ee8af --- /dev/null +++ b/frontend/backend/src/processing/user/svg.py @@ -0,0 +1,32 @@ +from datetime import date +from typing import Optional, Tuple + +from src.aggregation.layer2.user import get_user, get_user_demo +from src.models import UserPackage +from src.models.background import UpdateUserBackgroundTask +from src.utils import use_time_range + + +async def svg_base( + user_id: str, + start_date: date, + end_date: date, + time_range: str, + demo: bool, + no_cache: bool = False, +) -> Tuple[Optional[UserPackage], bool, Optional[UpdateUserBackgroundTask], str]: + # process time_range, start_date, end_date + time_range = "one_month" if demo else time_range + start_date, end_date, time_str = use_time_range(time_range, start_date, end_date) + complete = True # overwritten later if not complete + background_task = None + + # fetch data, either using demo or user method + if demo: + output = await get_user_demo(user_id, start_date, end_date, no_cache=no_cache) + else: + output, complete, background_task = await get_user( + user_id, start_date, end_date, no_cache=no_cache + ) + + return output, complete, background_task, time_str diff --git a/frontend/backend/src/processing/wrapped/__init__.py b/frontend/backend/src/processing/wrapped/__init__.py new file mode 100644 index 00000000..a04782ee --- /dev/null +++ b/frontend/backend/src/processing/wrapped/__init__.py @@ -0,0 +1,3 @@ +from src.processing.wrapped.main import query_wrapped_user + +__all__ = ["query_wrapped_user"] diff --git a/frontend/backend/src/processing/wrapped/calendar.py b/frontend/backend/src/processing/wrapped/calendar.py new file mode 100644 index 00000000..a0905a7a --- /dev/null +++ b/frontend/backend/src/processing/wrapped/calendar.py @@ -0,0 +1,51 @@ +from datetime import datetime, timedelta +from typing import Any, Dict, List + +from src.models import CalendarData, CalendarDayDatum, UserPackage + + +def get_calendar_data(data: UserPackage, year: int) -> CalendarData: + top_langs = [ + x[0] + for x in sorted( + data.contribs.total_stats.languages.items(), + key=lambda x: x[1].additions + x[1].deletions, + reverse=True, + )[:5] + ] + + total_out: List[CalendarDayDatum] = [] + items_dict = {item.date: item for item in data.contribs.total} + for i in range(365): + date = (datetime(year, 1, 1) + timedelta(days=i - 1)).strftime("%Y-%m-%d") + item = items_dict.get(date) + out: Dict[str, Any] = { + "day": date, + "contribs": 0, + "commits": 0, + "issues": 0, + "prs": 0, + "reviews": 0, + "loc_added": 0, + "loc_changed": 0, + "top_langs": {k: {"loc_added": 0, "loc_changed": 0} for k in top_langs}, + } + + if item is not None: + out["contribs"] = item.stats.contribs_count + out["commits"] = item.stats.commits_count + out["issues"] = item.stats.issues_count + out["prs"] = item.stats.prs_count + out["reviews"] = item.stats.reviews_count + + for k, v in item.stats.languages.items(): + if k in top_langs: + out["top_langs"][k]["loc_added"] = v.additions - v.deletions + out["top_langs"][k]["loc_changed"] = v.additions + v.deletions + out["loc_added"] += v.additions - v.deletions + out["loc_changed"] += v.additions + v.deletions + + out_obj = CalendarDayDatum.model_validate(out) + total_out.append(out_obj) + + return CalendarData.model_validate({"days": total_out}) diff --git a/frontend/backend/src/processing/wrapped/langs.py b/frontend/backend/src/processing/wrapped/langs.py new file mode 100644 index 00000000..d07aeb55 --- /dev/null +++ b/frontend/backend/src/processing/wrapped/langs.py @@ -0,0 +1,47 @@ +from typing import List + +from src.constants import DEFAULT_COLOR +from src.models import LangData, LangDatum, Language, UserPackage +from src.utils import format_number + + +def _count_loc(x: Language, metric: str) -> int: + if metric == "changed": + return x.additions + x.deletions + return x.additions - x.deletions + + +def get_lang_data(data: UserPackage) -> LangData: + out = {} + for m in ["changed", "added"]: + langs = sorted( + data.contribs.total_stats.languages.items(), + key=lambda x: _count_loc(x[1], m), + reverse=True, + ) + lang_objs: List[LangDatum] = [] + for k, v in list(langs)[:5]: + lang_data = { + "id": k, + "label": k, + "value": _count_loc(v, m), + "formatted_value": format_number(_count_loc(v, m)), + "color": v.color, + } + lang_objs.append(LangDatum.model_validate(lang_data)) + + # remaining languages + total_count = sum(_count_loc(v, m) for _, v in list(langs)[5:]) + lang_data = { + "id": "other", + "label": "other", + "value": total_count, + "formatted_value": format_number(total_count), + "color": DEFAULT_COLOR, + } + if total_count > 100: + lang_objs.append(LangDatum.model_validate(lang_data)) + + out[f"langs_{m}"] = lang_objs + + return LangData.model_validate(out) diff --git a/frontend/backend/src/processing/wrapped/main.py b/frontend/backend/src/processing/wrapped/main.py new file mode 100644 index 00000000..1fb30464 --- /dev/null +++ b/frontend/backend/src/processing/wrapped/main.py @@ -0,0 +1,34 @@ +from datetime import date, timedelta +from typing import Optional, Tuple + +from src.aggregation.layer1 import query_user +from src.data.mongo.user import PublicUserModel, get_public_user as db_get_public_user +from src.models import UserPackage, WrappedPackage +from src.processing.wrapped.package import get_wrapped_data +from src.utils import alru_cache + + +@alru_cache(ttl=timedelta(hours=12)) +async def query_wrapped_user( + user_id: str, year: int, no_cache: bool = False +) -> Tuple[bool, Optional[WrappedPackage]]: + start_date, end_date = date(year, 1, 1), date(year, 12, 31) + user: Optional[PublicUserModel] = await db_get_public_user(user_id) + private_access = False + access_token = None + if user is not None and user.private_access: + private_access = True + access_token = user.access_token + user_package: UserPackage = await query_user( + user_id, + access_token, + private_access, + start_date, + end_date, + max_time=40, + no_cache=True, + ) + wrapped_package = get_wrapped_data(user_package, year) + + # Don't cache if incomplete + return (not wrapped_package.incomplete, wrapped_package) diff --git a/frontend/backend/src/processing/wrapped/numeric.py b/frontend/backend/src/processing/wrapped/numeric.py new file mode 100644 index 00000000..5d9e374b --- /dev/null +++ b/frontend/backend/src/processing/wrapped/numeric.py @@ -0,0 +1,129 @@ +from collections import defaultdict +from datetime import datetime +from typing import Dict + +from src.models import ContribStats, LOCStats, MiscStats, NumericData, UserPackage + + +def get_contrib_stats(data: UserPackage) -> ContribStats: + return ContribStats.model_validate( + { + "contribs": data.contribs.total_stats.contribs_count, + "commits": data.contribs.total_stats.commits_count, + "issues": data.contribs.total_stats.issues_count, + "prs": data.contribs.total_stats.prs_count, + "reviews": data.contribs.total_stats.reviews_count, + "other": data.contribs.total_stats.other_count, + } + ) + + +def get_misc_stats(data: UserPackage, year: int) -> MiscStats: + weekdays: Dict[int, int] = defaultdict(int) + yeardays, distinct_days, total_contribs = {}, 0, 0 + for item in data.contribs.total: + count = item.stats.contribs_count + weekdays[item.weekday] += count + total_contribs += item.stats.contribs_count + if count > 0: + date = datetime.fromisoformat(item.date) + yeardays[date.timetuple().tm_yday - 1] = 1 + distinct_days += 1 + curr, best, best_dates = 0, 0, (1, 1) + for i in range(366): + curr = curr + 1 if i in yeardays else 0 + if curr > best: + best = curr + best_dates = (i - curr + 2, i + 1) + longest_streak = max(best, curr) + longest_streak_days = ( + best_dates[0], + best_dates[1], + datetime.fromordinal(max(1, best_dates[0])).strftime("%b %d"), + datetime.fromordinal(max(1, best_dates[1])).strftime("%b %d"), + ) + curr, best, best_dates = 0, 0, (1, 1) + days = (datetime.now() - datetime(year, 1, 1)).days + for i in range(min(days, 365)): + curr = 0 if i in yeardays else curr + 1 + if curr > best: + best = curr + best_dates = (i - curr + 2, i + 1) + longest_gap = max(best, curr) + longest_gap_days = ( + best_dates[0], + best_dates[1], + datetime.fromordinal(max(1, best_dates[0])).strftime("%b %d"), + datetime.fromordinal(max(1, best_dates[1])).strftime("%b %d"), + ) + weekend_percent = (weekdays[0] + weekdays[6]) / max(1, total_contribs) + + best_day_count, best_day_date, best_day_index = 0, None, None + if len(data.contribs.total) > 0: + best_day = max(data.contribs.total, key=lambda x: x.stats.contribs_count) + best_day_index = datetime.fromisoformat(best_day.date).timetuple().tm_yday + best_day_count = best_day.stats.contribs_count + best_day_date = best_day.date + + return MiscStats.model_validate( + { + "total_days": distinct_days, + "longest_streak": longest_streak, + "longest_streak_days": longest_streak_days, + "longest_gap": longest_gap, + "longest_gap_days": longest_gap_days, + "weekend_percent": round(100 * weekend_percent), + "best_day_count": best_day_count, + "best_day_date": best_day_date, + "best_day_index": best_day_index, + } + ) + + +def format_loc_number(number: int) -> str: + if number < 1e3: + return str(100 * round(number / 100)) + if number < 1e6: + return f"{str(round(number / 1000.0))},000" + return f"{str(round(number / 1000000.0))},000,000" + + +def get_loc_stats(data: UserPackage) -> LOCStats: + dataset = data.contribs.total_stats.languages.values() + return LOCStats.model_validate( + { + "loc_additions": format_loc_number(sum(x.additions for x in dataset)), + "loc_deletions": format_loc_number(sum(x.deletions for x in dataset)), + "loc_changed": format_loc_number( + sum(x.additions + x.deletions for x in dataset) + ), + "loc_added": format_loc_number( + sum(x.additions - x.deletions for x in dataset) + ), + "loc_additions_per_commit": round( + ( + sum(x.additions for x in dataset) + / max(1, data.contribs.total_stats.commits_count) + ) + ), + "loc_deletions_per_commit": round( + ( + sum(x.deletions for x in dataset) + / max(1, data.contribs.total_stats.commits_count) + ) + ), + "loc_changed_per_day": round( + sum(x.additions + x.deletions for x in dataset) / 365 + ), + } + ) + + +def get_numeric_data(data: UserPackage, year: int) -> NumericData: + return NumericData.model_validate( + { + "contribs": get_contrib_stats(data), + "misc": get_misc_stats(data, year), + "loc": get_loc_stats(data), + } + ) diff --git a/frontend/backend/src/processing/wrapped/package.py b/frontend/backend/src/processing/wrapped/package.py new file mode 100644 index 00000000..8d451b27 --- /dev/null +++ b/frontend/backend/src/processing/wrapped/package.py @@ -0,0 +1,32 @@ +from src.models import UserPackage, WrappedPackage +from src.processing.wrapped.calendar import get_calendar_data +from src.processing.wrapped.langs import get_lang_data +from src.processing.wrapped.numeric import get_numeric_data +from src.processing.wrapped.repos import get_repo_data +from src.processing.wrapped.time import get_day_data, get_month_data +from src.processing.wrapped.timestamps import get_timestamp_data + +# from src.processing.user.follows import get_user_follows + + +def get_wrapped_data(user_package: UserPackage, year: int) -> WrappedPackage: + """packages all processing steps for the user query""" + + month_data = get_month_data(user_package) + day_data = get_day_data(user_package) + calendar_data = get_calendar_data(user_package, year) + numeric_data = get_numeric_data(user_package, year) + repo_data = get_repo_data(user_package) + lang_data = get_lang_data(user_package) + timestamp_data = get_timestamp_data(user_package) + + return WrappedPackage( + month_data=month_data, + day_data=day_data, + calendar_data=calendar_data, + numeric_data=numeric_data, + repo_data=repo_data, + lang_data=lang_data, + timestamp_data=timestamp_data, + incomplete=user_package.incomplete, + ) diff --git a/frontend/backend/src/processing/wrapped/repos.py b/frontend/backend/src/processing/wrapped/repos.py new file mode 100644 index 00000000..0bff49a5 --- /dev/null +++ b/frontend/backend/src/processing/wrapped/repos.py @@ -0,0 +1,50 @@ +from typing import List + +from src.models import Language, RepoContributionStats, RepoData, RepoDatum, UserPackage +from src.utils import format_number + + +def _count_loc(x: Language, metric: str) -> int: + if metric == "changed": + return x.additions + x.deletions + return x.additions - x.deletions + + +def _count_repo_loc(x: RepoContributionStats, metric: str) -> int: + return sum(_count_loc(lang, metric) for lang in x.languages.values()) + + +def get_repo_data(data: UserPackage) -> RepoData: + out = {} + for m in ["changed", "added"]: + repos = sorted( + data.contribs.repo_stats.items(), + key=lambda x: _count_repo_loc(x[1], m), + reverse=True, + ) + repo_objs: List[RepoDatum] = [] + + # first five repositories + for i, (k, v) in enumerate(list(repos)[:5]): + repo_data = { + "id": i, + "label": "private/repository" if v.private else k, + "value": _count_repo_loc(v, m), + "formatted_value": format_number(_count_repo_loc(v, m)), + } + repo_objs.append(RepoDatum.model_validate(repo_data)) + + # remaining repositories + total_count = sum(_count_repo_loc(v, m) for _, v in list(repos)[5:]) + repo_data = { + "id": -1, + "label": "other", + "value": total_count, + "formatted_value": format_number(total_count), + } + if total_count > 100: + repo_objs.append(RepoDatum.model_validate(repo_data)) + + out[f"repos_{m}"] = repo_objs + + return RepoData.model_validate(out) diff --git a/frontend/backend/src/processing/wrapped/time.py b/frontend/backend/src/processing/wrapped/time.py new file mode 100644 index 00000000..c5b8df6a --- /dev/null +++ b/frontend/backend/src/processing/wrapped/time.py @@ -0,0 +1,62 @@ +from collections import defaultdict +from datetime import datetime +from typing import Dict, List, Union + +from src.models import DayData, MonthData, TimeDatum, UserPackage +from src.utils import format_number + + +def get_month_data(data: UserPackage) -> MonthData: + months: Dict[int, Dict[str, int]] = defaultdict( + lambda: {"contribs": 0, "loc_changed": 0} + ) + + for item in data.contribs.total: + month = datetime.fromisoformat(item.date).month - 1 + months[month]["contribs"] += item.stats.contribs_count + loc_changed = sum( + x.additions + x.deletions for x in item.stats.languages.values() + ) + + months[month]["loc_changed"] += loc_changed + + out: List[TimeDatum] = [] + for k in range(12): + v = months[k] + _obj: Dict[str, Union[str, int]] = { + "index": k, + **v, + "formatted_loc_changed": format_number(v["loc_changed"]), + } + + out.append(TimeDatum.model_validate(_obj)) + + return MonthData(months=out) + + +def get_day_data(data: UserPackage) -> DayData: + days: Dict[int, Dict[str, int]] = defaultdict( + lambda: {"contribs": 0, "loc_changed": 0} + ) + + for item in data.contribs.total: + day = (datetime.fromisoformat(item.date).weekday() + 1) % 7 + days[day]["contribs"] += item.stats.contribs_count + loc_changed = sum( + x.additions + x.deletions for x in item.stats.languages.values() + ) + + days[day]["loc_changed"] += loc_changed + + out: List[TimeDatum] = [] + for k in range(7): + v = days[k] + _obj: Dict[str, Union[str, int]] = { + "index": k, + **v, + "formatted_loc_changed": format_number(v["loc_changed"]), + } + + out.append(TimeDatum.model_validate(_obj)) + + return DayData(days=out) diff --git a/frontend/backend/src/processing/wrapped/timestamps.py b/frontend/backend/src/processing/wrapped/timestamps.py new file mode 100644 index 00000000..cf338ff4 --- /dev/null +++ b/frontend/backend/src/processing/wrapped/timestamps.py @@ -0,0 +1,33 @@ +from datetime import datetime +from random import shuffle +from typing import Any, List + +from src.models import TimestampData, TimestampDatum, UserPackage + +MAX_ITEMS = 200 + + +def date_to_seconds_since_midnight(date: datetime) -> int: + return (date.hour * 60 * 60) + (date.minute * 60) + date.second + + +def get_timestamp_data(data: UserPackage) -> TimestampData: + out: List[Any] = [] + for item in data.contribs.total: + lists = item.lists + lists = [lists.commits, lists.issues, lists.prs, lists.reviews] + for type, list in zip(["commit", "issue", "pr", "review"], lists): + out.extend( + { + "type": type, + "weekday": item.weekday, + "timestamp": date_to_seconds_since_midnight(obj), + } + for obj in list + ) + + shuffle(out) + out = out[:MAX_ITEMS] + out = [TimestampDatum.model_validate(x) for x in out] + + return TimestampData(contribs=out) diff --git a/frontend/backend/src/render/__init__.py b/frontend/backend/src/render/__init__.py new file mode 100644 index 00000000..ad179f3a --- /dev/null +++ b/frontend/backend/src/render/__init__.py @@ -0,0 +1,17 @@ +from src.render.error import ( + get_empty_demo_svg, + get_error_svg, + get_loading_svg, + get_no_data_svg, +) +from src.render.top_langs import get_top_langs_svg +from src.render.top_repos import get_top_repos_svg + +__all__ = [ + "get_empty_demo_svg", + "get_error_svg", + "get_loading_svg", + "get_no_data_svg", + "get_top_langs_svg", + "get_top_repos_svg", +] diff --git a/frontend/backend/src/render/error.py b/frontend/backend/src/render/error.py new file mode 100644 index 00000000..03b0b86d --- /dev/null +++ b/frontend/backend/src/render/error.py @@ -0,0 +1,127 @@ +# type: ignore + +from svgwrite import Drawing + +from src.constants import BACKEND_URL +from src.render.style import styles_no_animation, themes +from src.render.template import get_template + +THEME = "classic" + + +def get_error_svg() -> Drawing: + d = Drawing(size=(300, 285)) + d.defs.add(d.style(styles_no_animation[THEME])) + + d.add( + d.rect( + size=(299, 284), + insert=(0.5, 0.5), + rx=4.5, + stroke=themes[THEME]["border_color"], + fill=themes[THEME]["bg_color"], + ) + ) + + d.add(d.text("Unknown Error", insert=(25, 35), class_=f"{THEME}-header")) + + d.add( + d.text( + "Please try again later or raise a ticket on GitHub", + insert=(25, 60), + class_=f"{THEME}-lang-name", + ) + ) + + d.add( + d.image(f"{BACKEND_URL}/assets/error", insert=(85, 100), style="opacity: 50%") + ) + + return d + + +def get_empty_demo_svg(header: str) -> Drawing: + d = Drawing(size=(300, 285)) + d.defs.add(d.style(styles_no_animation[THEME])) + + d.add( + d.rect( + size=(299, 284), + insert=(0.5, 0.5), + rx=4.5, + stroke=themes[THEME]["border_color"], + fill=themes[THEME]["bg_color"], + ) + ) + + d.add(d.text(header, insert=(25, 35), class_=f"{THEME}-header")) + + d.add( + d.text( + "Enter your username to start!", + insert=(25, 60), + class_=f"{THEME}-lang-name", + ) + ) + + d.add( + d.image( + f"{BACKEND_URL}/assets/stopwatch", insert=(85, 100), style="opacity: 50%" + ) + ) + + return d + + +def get_loading_svg() -> Drawing: + d = Drawing(size=(300, 285)) + d.defs.add(d.style(styles_no_animation[THEME])) + + d.add( + d.rect( + size=(299, 284), + insert=(0.5, 0.5), + rx=4.5, + stroke=themes[THEME]["border_color"], + fill=themes[THEME]["bg_color"], + ) + ) + + d.add( + d.text("Loading data, hang tight!", insert=(25, 35), class_=f"{THEME}-header") + ) + + d.add( + d.text( + "Please wait a couple seconds and refresh the page.", + insert=(25, 60), + class_=f"{THEME}-lang-name", + ) + ) + + d.add( + d.image( + f"{BACKEND_URL}/assets/stopwatch", insert=(85, 100), style="opacity: 50%" + ) + ) + + return d + + +def get_no_data_svg(header: str, subheader: str) -> Drawing: + d, dp = get_template( + width=300, + height=285, + padding=20, + header_text=header, + subheader_text=subheader, + debug=False, + theme=THEME, + ) + + d.add(d.image(f"{BACKEND_URL}/assets/error", insert=(85, 80), style="opacity: 50%")) + + dp.add(d.text("No data to show", insert=(55, 220), class_=f"{THEME}-image-text")) + + d.add(dp) + return d diff --git a/frontend/backend/src/render/style.py b/frontend/backend/src/render/style.py new file mode 100644 index 00000000..e4777972 --- /dev/null +++ b/frontend/backend/src/render/style.py @@ -0,0 +1,116 @@ +from typing import List, Tuple + +themes = { + "classic": { + "header_color": "#2f80ed", + "subheader_color": "#666", + "text_color": "#333", + "bg_color": "#fffefe", + "border_color": "#e4e2e2", + "bar_color": "#ddd", + }, + "dark": { + "header_color": "#fff", + "subheader_color": "#9f9f9f", + "text_color": "#9f9f9f", + "bg_color": "#151515", + "border_color": "#e4e2e2", + "bar_color": "#333", + }, + "bright_lights": { + "header_color": "#fff", + "subheader_color": "#0e86d4", + "text_color": "#b1d4e0", + "bg_color": "#003060", + "border_color": "#0e86d4", + "bar_color": "#ddd", + }, + "rosettes": { + "header_color": "#fff", + "subheader_color": "#b6e2d3", + "text_color": "#fae8e0", + "bg_color": "#ef7c8e", + "border_color": "#b6e2d3", + "bar_color": "#ddd", + }, + "ferns": { + "header_color": "#116530", + "subheader_color": "#18a558", + "text_color": "#116530", + "bg_color": "#a3ebb1", + "border_color": "#21b6a8", + "bar_color": "#ddd", + }, + "synthwaves": { + "header_color": "#e2e9ec", + "subheader_color": "#e5289e", + "text_color": "#ef8539", + "bg_color": "#2b213a", + "border_color": "#e5289e", + "bar_color": "#ddd", + }, +} + + +def get_style(theme: str = "classic", use_animation: bool = True) -> str: + # List[Tuple["selector", List[Tuple["property", "is_animation"]], "is_animation"]] + _style: List[Tuple[str, List[Tuple[str, bool]], bool]] = [ + ( + ".header", + [ + ("font: 600 18px 'Segoe UI', Ubuntu, Sans-Serif;", False), + ("fill: " + themes[theme]["header_color"] + ";", False), + ("animation: fadeInAnimation 0.8s ease-in-out forwards;", True), + ], + False, + ), + ( + ".subheader", + [ + ("font: 500 10px 'Segoe UI', Ubuntu, Sans-Serif;", False), + ("fill: " + themes[theme]["subheader_color"] + ";", False), + ("animation: fadeInAnimation 0.8s ease-in-out forwards;", True), + ], + False, + ), + ( + ".lang-name", + [ + ("font: 400 11px 'Segoe UI', Ubuntu, Sans-Serif;", False), + ("fill: " + themes[theme]["text_color"] + ";", False), + ], + False, + ), + ( + ".image-text", + [ + ("font: 500 20px 'Segoe UI', Ubuntu, Sans-Serif;", False), + ("fill: " + themes[theme]["text_color"] + ";", False), + ("opacity: 50%;", False), + ], + False, + ), + ( + "@keyframes fadeInAnimation", + [("from { opacity: 0; } to { opacity: 1; }", True)], + True, + ), + ] + + return "\n".join( + [ + ( + (rule[0].replace(".", f".{theme}-") + " {") + + "\n".join( + item[0] for item in rule[1] if (use_animation or not item[1]) + ) + + "}" + ) + for rule in _style + if use_animation or not rule[2] + ] + ) + + +styles = {k: get_style(k) for k in themes.keys()} +styles_no_animation = {k: get_style(k, False) for k in themes.keys()} diff --git a/frontend/backend/src/render/template.py b/frontend/backend/src/render/template.py new file mode 100644 index 00000000..7b5ab1d9 --- /dev/null +++ b/frontend/backend/src/render/template.py @@ -0,0 +1,126 @@ +# type: ignore + +from typing import List, Tuple + +from svgwrite import Drawing +from svgwrite.container import Group +from svgwrite.shapes import Circle + +from src.constants import DEFAULT_COLOR +from src.render.style import styles, styles_no_animation, themes + + +def get_template( + width: int, + height: int, + padding: int, + header_text: str, + subheader_text: str, + theme: str, + use_animation: bool = True, + debug: bool = False, +) -> Tuple[Drawing, Group]: + d = Drawing(size=(width, height)) + + style = styles[theme] + style_no_animation = styles_no_animation[theme] + d.defs.add(d.style(style if use_animation else style_no_animation)) + + d.add( + d.rect( + size=(width - 1, height - 1), + insert=(0.5, 0.5), + rx=4.5, + stroke=themes[theme]["border_color"], + fill=themes[theme]["bg_color"], + ) + ) + + d.add( + d.rect( + size=(width - 2 * padding, height - 2 * padding), + insert=(padding, padding), + fill="#eee" if debug else themes[theme]["bg_color"], + ) + ) + + dp = Group(transform=f"translate({padding}, {padding})") + + dp.add(d.text(header_text, insert=(0, 13), class_=f"{theme}-header")) + dp.add(d.text(subheader_text, insert=(0, 31), class_=f"{theme}-subheader")) + + return d, dp + + +def get_bar_section( + d: Drawing, + dataset: List[Tuple[str, str, List[Tuple[float, str]]]], + theme: str, + padding: int = 45, + bar_width: int = 210, +) -> Group: + section = Group(transform=f"translate(0, {padding})") + for i, (top_text, right_text, data_row) in enumerate(dataset): + translate = f"translate(0, {str(40 * i)})" + row = Group(transform=translate) + row.add(d.text(top_text, insert=(2, 15), class_=f"{theme}-lang-name")) + row.add( + d.text(right_text, insert=(bar_width + 10, 33), class_=f"{theme}-lang-name") + ) + + progress = Drawing(width=str(bar_width), x="0", y="25") + progress.add( + d.rect( + size=(bar_width, 8), + insert=(0, 0), + rx=5, + ry=5, + fill=themes[theme]["bar_color"], + ) + ) + total_percent, total_items = 0, len(data_row) + diff = max(0, 300 / bar_width - data_row[-1][0]) + for j, (percent, color) in enumerate(data_row): + color = color or DEFAULT_COLOR + if j == 0: + percent -= diff + elif j == total_items - 1: + percent += diff + bar_percent = bar_width * percent / 100 + bar_total = bar_width * total_percent / 100 + box_size, insert = (bar_percent, 8), (bar_total, 0) + progress.add(d.rect(size=box_size, insert=insert, rx=5, ry=5, fill=color)) + + width = min(bar_percent / 2, 5) + if total_items > 1: + box_left, box_right = j > 0, j < total_items - 1 + box_size, insert = bar_percent - 2 * width, bar_total + width + if box_left: + box_size += width + insert -= width + if box_right: + box_size += width + progress.add(d.rect(size=(box_size, 8), insert=(insert, 0), fill=color)) + + total_percent += percent + row.add(progress) + section.add(row) + return section + + +def get_lang_name_section( + d: Drawing, + data: List[Tuple[str, str]], + theme: str, + columns: int = 2, + padding: int = 80, +) -> Group: + section = Group(transform=f"translate(0, {padding})") + for i, x in enumerate(data): + x_translate = str((260 / columns) * (i % columns)) + y_translate = str(20 * (i // columns)) + lang = Group(transform=f"translate({x_translate}, {y_translate})") + lang.add(Circle(center=(5, 5), r=5, fill=(data[i][1] or DEFAULT_COLOR))) + lang.add(d.text(data[i][0], insert=(14, 9), class_=f"{theme}-lang-name")) + section.add(lang) + return section diff --git a/frontend/backend/src/render/top_langs.py b/frontend/backend/src/render/top_langs.py new file mode 100644 index 00000000..cec169e8 --- /dev/null +++ b/frontend/backend/src/render/top_langs.py @@ -0,0 +1,74 @@ +# type: ignore + +from typing import List, Tuple + +from svgwrite import Drawing + +from src.models.svg import LanguageStats +from src.render.error import get_no_data_svg +from src.render.template import get_bar_section, get_lang_name_section, get_template +from src.utils import format_number + + +def get_top_langs_svg( + data: List[LanguageStats], + time_str: str, + use_percent: bool, + loc_metric: str, + complete: bool, + commits_excluded: int, + compact: bool, + use_animation: bool, + theme: str, +) -> Drawing: + header = "Most Used Languages" + subheader = time_str + if not use_percent: + subheader += " | " + ("LOC Changed" if loc_metric == "changed" else "LOC Added") + if not complete: + subheader += " | Incomplete (refresh to update)" + elif commits_excluded > 50: + subheader += f" | {commits_excluded} commits excluded" + + if len(data) <= 1: + return get_no_data_svg(header, subheader) + + d, dp = get_template( + width=300, + height=175 if compact else 285, + padding=20, + header_text=header, + subheader_text=subheader, + use_animation=use_animation, + debug=False, + theme=theme, + ) + + dataset: List[Tuple[str, str, List[Tuple[float, str]]]] = [] + padding, width = 0, 0 + if compact: + data_row = [(x.percent, x.color) for x in data[1:6]] + dataset.append(("", "", data_row)) + padding, width = 30, 260 + else: + max_length = max(data[i].loc for i in range(1, len(data))) + for x in data[1:6]: + if use_percent: + dataset.append((x.lang, f"{str(x.percent)}%", [(x.percent, x.color)])) + else: + percent = 100 * x.loc / max_length + dataset.append((x.lang, format_number(x.loc), [(percent, x.color)])) + padding, width = 45, 210 if use_percent else 195 + + dp.add( + get_bar_section( + d=d, dataset=dataset, theme=theme, padding=padding, bar_width=width + ) + ) + + langs = [(f"{x.lang} {str(x.percent)}%", x.color) for x in data[1:6]] + if compact: + dp.add(get_lang_name_section(d=d, data=langs, theme=theme)) + + d.add(dp) + return d diff --git a/frontend/backend/src/render/top_repos.py b/frontend/backend/src/render/top_repos.py new file mode 100644 index 00000000..08833364 --- /dev/null +++ b/frontend/backend/src/render/top_repos.py @@ -0,0 +1,76 @@ +# type: ignore + +from collections import defaultdict +from typing import List, Tuple + +from svgwrite import Drawing + +from src.models.svg import RepoStats +from src.render.error import get_no_data_svg +from src.render.template import get_bar_section, get_lang_name_section, get_template +from src.utils import format_number + + +def get_top_repos_svg( + data: List[RepoStats], + time_str: str, + loc_metric: str, + complete: bool, + commits_excluded: int, + use_animation: bool, + theme: str, +) -> Drawing: + header = "Most Contributed Repositories" + subheader = time_str + subheader += " | " + ("LOC Changed" if loc_metric == "changed" else "LOC Added") + if not complete: + subheader += " | Incomplete (refresh to update)" + elif commits_excluded > 50: + subheader += f" | {commits_excluded} commits excluded" + + if len(data) == 0: + return get_no_data_svg(header, subheader) + + d, dp = get_template( + width=300, + height=285, + padding=20, + header_text=header, + subheader_text=subheader, + use_animation=use_animation, + debug=False, + theme=theme, + ) + + dataset: List[Tuple[str, str, List[Tuple[float, str]]]] = [] + total = max(x.loc for x in data) + for x in data[:4]: + data_row = [ + (100 * lang.loc / total, lang.color) + for lang in sorted(x.langs, key=lambda x: x.loc, reverse=True) + ] + + name = "private/repository" if x.private else x.repo + dataset.append((name, format_number(x.loc), data_row)) + + dp.add( + get_bar_section(d=d, dataset=dataset, theme=theme, padding=45, bar_width=195) + ) + + langs = defaultdict(int) + for x in data[:4]: + for lang in x.langs: + langs[(lang.lang, lang.color)] += lang.loc + langs = sorted(langs.items(), key=lambda x: x[1], reverse=True) + langs = [lang[0] for lang in langs[:6]] + + columns = {1: 1, 2: 2, 3: 3, 4: 2, 5: 3, 6: 3}[len(langs)] + padding = 215 + (10 if columns == len(langs) else 0) + dp.add( + get_lang_name_section( + d=d, data=langs, theme=theme, columns=columns, padding=padding + ) + ) + + d.add(dp) + return d diff --git a/frontend/backend/src/routers/__init__.py b/frontend/backend/src/routers/__init__.py new file mode 100644 index 00000000..9003fcad --- /dev/null +++ b/frontend/backend/src/routers/__init__.py @@ -0,0 +1,7 @@ +from src.routers.assets.assets import router as asset_router +from src.routers.auth.main import router as auth_router +from src.routers.dev import router as dev_router +from src.routers.users.main import router as user_router +from src.routers.wrapped import router as wrapped_router + +__all__ = ["asset_router", "auth_router", "dev_router", "user_router", "wrapped_router"] diff --git a/frontend/backend/src/routers/assets/__init__.py b/frontend/backend/src/routers/assets/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/frontend/backend/src/routers/assets/assets.py b/frontend/backend/src/routers/assets/assets.py new file mode 100644 index 00000000..917dd45c --- /dev/null +++ b/frontend/backend/src/routers/assets/assets.py @@ -0,0 +1,14 @@ +from fastapi import APIRouter, status +from fastapi.responses import FileResponse + +router = APIRouter() + + +@router.get("/error", status_code=status.HTTP_200_OK, include_in_schema=False) +async def get_error_img(): + return FileResponse("./src/routers/assets/assets/error.png") + + +@router.get("/stopwatch", status_code=status.HTTP_200_OK, include_in_schema=False) +async def get_stopwatch_img(): + return FileResponse("./src/routers/assets/assets/stopwatch.png") diff --git a/frontend/backend/src/routers/assets/assets/error.png b/frontend/backend/src/routers/assets/assets/error.png new file mode 100644 index 00000000..6282e1d4 Binary files /dev/null and b/frontend/backend/src/routers/assets/assets/error.png differ diff --git a/frontend/backend/src/routers/assets/assets/stopwatch.png b/frontend/backend/src/routers/assets/assets/stopwatch.png new file mode 100644 index 00000000..9467b20b Binary files /dev/null and b/frontend/backend/src/routers/assets/assets/stopwatch.png differ diff --git a/frontend/backend/src/routers/auth/__init__.py b/frontend/backend/src/routers/auth/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/frontend/backend/src/routers/auth/main.py b/frontend/backend/src/routers/auth/main.py new file mode 100644 index 00000000..b3606853 --- /dev/null +++ b/frontend/backend/src/routers/auth/main.py @@ -0,0 +1,8 @@ +from fastapi import APIRouter + +from src.routers.auth.standalone import router as standalone_router +from src.routers.auth.website import router as website_router + +router = APIRouter() +router.include_router(standalone_router, prefix="") +router.include_router(website_router, prefix="/web") diff --git a/frontend/backend/src/routers/auth/standalone.py b/frontend/backend/src/routers/auth/standalone.py new file mode 100644 index 00000000..8104c8ff --- /dev/null +++ b/frontend/backend/src/routers/auth/standalone.py @@ -0,0 +1,46 @@ +import logging +from typing import Optional + +from fastapi import APIRouter +from fastapi.responses import RedirectResponse + +from src.constants import OAUTH_CLIENT_ID +from src.processing.auth import authenticate, delete_user +from src.routers.decorators import get_redirect_url + +router = APIRouter() + + +@router.get("/signup/public") +def redirect_public(user_id: Optional[str] = None) -> RedirectResponse: + return RedirectResponse(get_redirect_url(private=False, user_id=user_id)) + + +@router.get("/signup/private") +def redirect_private(user_id: Optional[str] = None) -> RedirectResponse: + return RedirectResponse(get_redirect_url(private=True, user_id=user_id)) + + +@router.get("/redirect", include_in_schema=False) +async def redirect_return(code: str = "", private_access: bool = False) -> str: + try: + user_id = await authenticate(code=code, private_access=private_access) + return f"You ({user_id}) are now authenticated!" + except Exception as e: + logging.exception(e) + return "Unknown Error. Please try again later." + + +@router.get("/delete/{user_id}") +async def delete_account_auth(user_id: str) -> RedirectResponse: + return RedirectResponse( + get_redirect_url(prefix=f"delete/{user_id}", private=False, user_id=user_id) + ) + + +@router.get("/redirect/delete/{user_id}", include_in_schema=False) +async def delete_account(user_id: str) -> RedirectResponse: + await delete_user(user_id, user_key="", use_user_key=False) + return RedirectResponse( + f"https://github.com/settings/connections/applications/{OAUTH_CLIENT_ID}" + ) diff --git a/frontend/backend/src/routers/auth/website.py b/frontend/backend/src/routers/auth/website.py new file mode 100644 index 00000000..37b53211 --- /dev/null +++ b/frontend/backend/src/routers/auth/website.py @@ -0,0 +1,53 @@ +from typing import Any, Dict + +from fastapi import BackgroundTasks, status +from fastapi.responses import Response +from fastapi.routing import APIRouter + +from src.processing.auth import authenticate, delete_user, set_user_key +from src.routers.background import run_in_background +from src.utils import async_fail_gracefully + +router = APIRouter() + + +@router.post( + "/set_user_key/{code}/{user_key}", + status_code=status.HTTP_200_OK, + include_in_schema=False, + response_model=Dict[str, Any], +) +@async_fail_gracefully +async def set_user_key_endpoint(response: Response, code: str, user_key: str) -> str: + return await set_user_key(code, user_key) + + +@router.post( + "/login/{code}", + status_code=status.HTTP_200_OK, + include_in_schema=False, + response_model=Dict[str, Any], +) +@async_fail_gracefully +async def authenticate_endpoint( + response: Response, + background_tasks: BackgroundTasks, + code: str, + private_access: bool = False, +) -> str: + output, background_task = await authenticate(code, private_access) + if background_task is not None: + # set a background task to update the user + background_tasks.add_task(run_in_background, task=background_task) + return output + + +@router.get( + "/delete/{user_id}", + status_code=status.HTTP_200_OK, + include_in_schema=False, + response_model=Dict[str, Any], +) +@async_fail_gracefully +async def delete_user_endpoint(response: Response, user_id: str, user_key: str) -> bool: + return await delete_user(user_id, user_key=user_key) diff --git a/frontend/backend/src/routers/background.py b/frontend/backend/src/routers/background.py new file mode 100644 index 00000000..2892f02a --- /dev/null +++ b/frontend/backend/src/routers/background.py @@ -0,0 +1,32 @@ +from typing import Dict + +from src.aggregation.layer1 import query_user +from src.models.background import UpdateUserBackgroundTask + +# create a cache for the function +cache: Dict[str, Dict[str, bool]] = {"update_user": {}} + + +async def run_in_background(task: UpdateUserBackgroundTask): + if isinstance(task, UpdateUserBackgroundTask): # type: ignore + inputs = { + "user_id": task.user_id, + "access_token": task.access_token, + "private_access": task.private_access, + "start_date": task.start_date, + "end_date": task.end_date, + } + + inputs = {k: v for k, v in inputs.items() if v is not None} + + # check if the task is already running + if task.user_id in cache["update_user"]: + return + + # add the task to the cache + cache["update_user"][task.user_id] = True + + await query_user(**inputs) # type: ignore + + # remove the task from the cache + del cache["update_user"][task.user_id] diff --git a/frontend/backend/src/routers/decorators.py b/frontend/backend/src/routers/decorators.py new file mode 100644 index 00000000..e6536644 --- /dev/null +++ b/frontend/backend/src/routers/decorators.py @@ -0,0 +1,80 @@ +import io +import logging +from datetime import datetime +from functools import wraps +from typing import Any, Callable, Dict, List, Optional + +from fastapi import Response, status +from starlette.responses import RedirectResponse +from svgwrite.drawing import Drawing # type: ignore + +from src.constants import OAUTH_CLIENT_ID, OAUTH_REDIRECT_URI +from src.render import get_error_svg + + +# for standalone auth routes +def get_redirect_url( + prefix: str = "", private: bool = False, user_id: Optional[str] = None +) -> str: + url = ( + "https://github.com/login/oauth/authorize?client_id=" + + OAUTH_CLIENT_ID + + "&redirect_uri=" + + OAUTH_REDIRECT_URI + + "/redirect" + ) + + # add prefix to redirect to different backend routes + if prefix != "": + url += f"/{prefix}" + + # add private flag to request correct permissions + if private: + url += "?private_access=True&scope=user,repo" + else: + url += "?private_access=False" + + # add user_id to hint if provided + if user_id is not None: + url += f"&login={user_id}" + + return url + + +# NOTE: implied async, sync not implemented yet +def svg_fail_gracefully(func: Callable[..., Any]): + @wraps(func) # needed to play nice with FastAPI decorator + async def wrapper( + response: Response, *args: List[Any], **kwargs: Dict[str, Any] + ) -> Any: + d: Drawing + start = datetime.now() + cache_max_age = 3600 + try: + d = await func(response, *args, **kwargs) + except LookupError as e: + if "user_id" in kwargs: + user_id: str = kwargs["user_id"] # type: ignore + url = get_redirect_url(private=False, user_id=user_id) + return RedirectResponse(url) + logging.exception(e) + d = get_error_svg() + cache_max_age = 0 + except Exception as e: + logging.exception(e) + d = get_error_svg() + cache_max_age = 0 + + sio = io.StringIO() + d.write(sio) # type: ignore + + print("SVG", datetime.now() - start) + + return Response( + sio.getvalue(), + media_type="image/svg+xml", + status_code=status.HTTP_200_OK, + headers={"Cache-Control": f"public, max-age={cache_max_age}"}, + ) + + return wrapper diff --git a/frontend/backend/src/routers/dev.py b/frontend/backend/src/routers/dev.py new file mode 100644 index 00000000..459d1f91 --- /dev/null +++ b/frontend/backend/src/routers/dev.py @@ -0,0 +1,50 @@ +from datetime import date, timedelta +from typing import Any, Dict, Optional + +from fastapi import APIRouter, Response, status + +from src.aggregation.layer0 import get_user_data +from src.data.mongo.secret import update_keys +from src.models import UserPackage, WrappedPackage +from src.processing.wrapped.package import get_wrapped_data +from src.utils import async_fail_gracefully, use_time_range + +router = APIRouter() + + +@router.get( + "/user/{user_id}", status_code=status.HTTP_200_OK, response_model=Dict[str, Any] +) +@async_fail_gracefully +async def get_user_raw( + response: Response, + user_id: str, + access_token: Optional[str] = None, + start_date: date = date.today() - timedelta(365), + end_date: date = date.today(), + time_range: str = "one_month", + timezone_str: str = "US/Eastern", + full: bool = False, +) -> UserPackage: + await update_keys() + start_date, end_date, _ = use_time_range(time_range, start_date, end_date) + return await get_user_data( + user_id, start_date, end_date, timezone_str, access_token + ) + + +@router.get( + "/wrapped/{user_id}", status_code=status.HTTP_200_OK, response_model=Dict[str, Any] +) +@async_fail_gracefully +async def get_wrapped_user_raw( + response: Response, + user_id: str, + year: int = 2024, + access_token: Optional[str] = None, +) -> WrappedPackage: + await update_keys() + user_data = await get_user_data( + user_id, date(year, 1, 1), date(year, 12, 31), "US/Eastern", access_token + ) + return get_wrapped_data(user_data, year) diff --git a/frontend/backend/src/routers/users/__init__.py b/frontend/backend/src/routers/users/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/frontend/backend/src/routers/users/db.py b/frontend/backend/src/routers/users/db.py new file mode 100644 index 00000000..d5e07b5e --- /dev/null +++ b/frontend/backend/src/routers/users/db.py @@ -0,0 +1,47 @@ +from typing import Any, Dict, Optional + +from fastapi import APIRouter, Response, status + +from src.data.mongo.secret import update_keys +from src.data.mongo.user import PublicUserModel, get_public_user as db_get_public_user +from src.utils import async_fail_gracefully + +router = APIRouter() + + +@router.get( + "/update_keys", + status_code=status.HTTP_200_OK, + include_in_schema=False, + response_model=Dict[str, Any], +) +@async_fail_gracefully +async def update_keys_endpoint(response: Response) -> bool: + await update_keys(no_cache=True) + return True + + +@router.get( + "/get/metadata/{user_id}", + status_code=status.HTTP_200_OK, + include_in_schema=False, + response_model=Dict[str, Any], +) +@async_fail_gracefully +async def get_db_public_user( + response: Response, user_id: str, no_cache: bool = False +) -> Optional[PublicUserModel]: + return await db_get_public_user(user_id, no_cache=no_cache) + + +""" +@router.get("/get/{user_id}", status_code=status.HTTP_200_OK, include_in_schema=False) +@async_fail_gracefully +async def get_db_user( + response: Response, user_id: str, no_cache: bool = False +) -> Optional[ExternalUserModel]: + user: Optional[UserModel] = await get_user_by_user_id(user_id, no_cache=no_cache) + if user is None: + return None + return ExternalUserModel.parse_obj(user.dict()) +""" diff --git a/frontend/backend/src/routers/users/main.py b/frontend/backend/src/routers/users/main.py new file mode 100644 index 00000000..bf7f5fe2 --- /dev/null +++ b/frontend/backend/src/routers/users/main.py @@ -0,0 +1,41 @@ +from datetime import date, timedelta +from typing import Any, Dict, Optional + +from fastapi import APIRouter, BackgroundTasks, Response, status + +from src.aggregation.layer2 import get_user +from src.models import UserPackage +from src.routers.background import run_in_background +from src.routers.users.db import router as db_router +from src.routers.users.svg import router as svg_router +from src.utils import async_fail_gracefully + +router = APIRouter() +router.include_router(db_router, prefix="/db") +router.include_router(svg_router, prefix="/svg") + + +""" +ANALYTICS +""" + + +@router.get("/{user_id}", status_code=status.HTTP_200_OK, response_model=Dict[str, Any]) +@async_fail_gracefully +async def get_user_endpoint( + response: Response, + background_tasks: BackgroundTasks, + user_id: str, + start_date: date = date.today() - timedelta(365), + end_date: date = date.today(), + timezone_str: str = "US/Eastern", + no_cache: bool = False, +) -> Optional[UserPackage]: + output, _, background_task = await get_user( + user_id, start_date, end_date, no_cache=no_cache + ) + if background_task is not None: + # set a background task to update the user + background_tasks.add_task(run_in_background, task=background_task) + + return output diff --git a/frontend/backend/src/routers/users/svg.py b/frontend/backend/src/routers/users/svg.py new file mode 100644 index 00000000..2ad36875 --- /dev/null +++ b/frontend/backend/src/routers/users/svg.py @@ -0,0 +1,127 @@ +from datetime import date, timedelta +from typing import Any + +from fastapi import BackgroundTasks, Response, status +from fastapi.responses import HTMLResponse +from fastapi.routing import APIRouter + +from src.processing.user import get_top_languages, get_top_repos, svg_base +from src.render import ( + get_empty_demo_svg, + get_loading_svg, + get_top_langs_svg, + get_top_repos_svg, +) +from src.routers.background import run_in_background +from src.routers.decorators import svg_fail_gracefully + +router = APIRouter() + + +@router.get( + "/{user_id}/langs", status_code=status.HTTP_200_OK, response_class=HTMLResponse +) +@svg_fail_gracefully +async def get_user_lang_svg( + response: Response, + background_tasks: BackgroundTasks, + user_id: str, + start_date: date = date.today() - timedelta(30), + end_date: date = date.today(), + time_range: str = "one_year", + timezone_str: str = "US/Eastern", + use_percent: bool = False, + include_private: bool = False, + loc_metric: str = "added", + compact: bool = False, + demo: bool = False, + no_cache: bool = False, + use_animation: bool = True, + theme: str = "classic", +) -> Any: + output, complete, background_task, time_str = await svg_base( + user_id, start_date, end_date, time_range, demo, no_cache + ) + if background_task is not None: + # set a background task to update the user + background_tasks.add_task(run_in_background, task=background_task) + + # if no data, return loading svg + if output is None: + return get_loading_svg() + + # get top languages + processed, commits_excluded = get_top_languages(output, loc_metric, include_private) + return get_top_langs_svg( + data=processed, + time_str=time_str, + use_percent=use_percent, + loc_metric=loc_metric, + complete=complete, + commits_excluded=commits_excluded, + compact=compact, + use_animation=use_animation, + theme=theme, + ) + + +@router.get( + "/{user_id}/repos", status_code=status.HTTP_200_OK, response_class=HTMLResponse +) +@svg_fail_gracefully +async def get_user_repo_svg( + response: Response, + background_tasks: BackgroundTasks, + user_id: str, + start_date: date = date.today() - timedelta(30), + end_date: date = date.today(), + time_range: str = "one_year", + timezone_str: str = "US/Eastern", + include_private: bool = False, + group: str = "none", + loc_metric: str = "added", + demo: bool = False, + no_cache: bool = False, + use_animation: bool = True, + theme: str = "classic", +) -> Any: + output, complete, background_task, time_str = await svg_base( + user_id, start_date, end_date, time_range, demo, no_cache + ) + if background_task is not None: + # set a background task to update the user + background_tasks.add_task(run_in_background, task=background_task) + + # if no data, return loading svg + if output is None: + return get_loading_svg() + + # get top repos + processed, commits_excluded = get_top_repos( + output, loc_metric, include_private, group + ) + return get_top_repos_svg( + data=processed, + time_str=time_str, + loc_metric=loc_metric, + complete=complete, + commits_excluded=commits_excluded, + use_animation=use_animation, + theme=theme, + ) + + +@router.get( + "/demo", + status_code=status.HTTP_200_OK, + response_class=HTMLResponse, + include_in_schema=False, +) +@svg_fail_gracefully +async def get_demo_svg(response: Response, card: str) -> Any: + if card == "langs": + return get_empty_demo_svg("Most Used Languages") + elif card == "repos": + return get_empty_demo_svg("Most Contributed Repositories") + else: + return get_empty_demo_svg(card) diff --git a/frontend/backend/src/routers/wrapped.py b/frontend/backend/src/routers/wrapped.py new file mode 100644 index 00000000..ebb8a11f --- /dev/null +++ b/frontend/backend/src/routers/wrapped.py @@ -0,0 +1,30 @@ +from typing import Any, Dict, Optional + +from fastapi import APIRouter, Response, status + +from src.aggregation.layer2 import get_is_valid_user +from src.models import WrappedPackage +from src.processing.wrapped import query_wrapped_user +from src.utils import async_fail_gracefully + +router = APIRouter() + + +@router.get( + "/valid/{user_id}", status_code=status.HTTP_200_OK, response_model=Dict[str, Any] +) +@async_fail_gracefully +async def check_valid_user(response: Response, user_id: str) -> str: + return await get_is_valid_user(user_id) + + +@router.get("/{user_id}", status_code=status.HTTP_200_OK, response_model=Dict[str, Any]) +@async_fail_gracefully +async def get_wrapped_user( + response: Response, user_id: str, year: int = 2024, no_cache: bool = False +) -> Optional[WrappedPackage]: + valid_user = await get_is_valid_user(user_id) + if "Valid user" not in valid_user: + return WrappedPackage.empty() + + return await query_wrapped_user(user_id, year, no_cache=no_cache) diff --git a/frontend/backend/src/utils/__init__.py b/frontend/backend/src/utils/__init__.py new file mode 100644 index 00000000..52532cbd --- /dev/null +++ b/frontend/backend/src/utils/__init__.py @@ -0,0 +1,14 @@ +from src.utils.alru_cache import alru_cache +from src.utils.decorators import async_fail_gracefully, fail_gracefully +from src.utils.gather import gather +from src.utils.utils import date_to_datetime, format_number, use_time_range + +__all__ = [ + "alru_cache", + "async_fail_gracefully", + "fail_gracefully", + "gather", + "date_to_datetime", + "format_number", + "use_time_range", +] diff --git a/frontend/backend/src/utils/alru_cache.py b/frontend/backend/src/utils/alru_cache.py new file mode 100644 index 00000000..c6b0305d --- /dev/null +++ b/frontend/backend/src/utils/alru_cache.py @@ -0,0 +1,79 @@ +from datetime import datetime, timedelta +from functools import wraps +from typing import ( + Any, + Awaitable, + Callable, + Dict, + FrozenSet, + List, + ParamSpec, + Tuple, + TypeVar, +) + +Param = ParamSpec("Param") +TOutput = TypeVar("TOutput") + +TKey = Tuple[Tuple[Any, ...], FrozenSet[Tuple[str, Any]]] + + +def alru_cache(max_size: int = 128, ttl: timedelta = timedelta(minutes=1)): + def decorator( + func: Callable[Param, Awaitable[Tuple[bool, TOutput]]] + ) -> Callable[Param, Awaitable[TOutput]]: + cache: Dict[TKey, Tuple[datetime, TOutput]] = {} + keys: List[TKey] = [] + + def in_cache(key: TKey) -> bool: + # key not in cache + if key not in cache: + return False + + # key in cache but expired + if datetime.now() - cache[key][0] > ttl: + return False + + # key in cache and not expired + return True + + def update_cache_and_return(key: TKey, flag: bool, value: TOutput) -> TOutput: + # if flag = False, do not update cache and return value + if not flag: + return value + + # if flag = True, update cache + now = datetime.now() + cache[key] = (now, value) + keys.append(key) + + # remove oldest key if cache is full + if len(keys) > max_size: + try: + # Should not raise KeyError, but just in case + del cache[keys.pop(0)] + except KeyError: + # Already deleted by another thread + pass + + # return value from cache + return value # equal to cache[key][1] + + @wraps(func) + async def wrapper(*args: Param.args, **kwargs: Param.kwargs) -> TOutput: + key: TKey = tuple(args), frozenset( + [(k, v) for k, v in kwargs.items() if k not in ["no_cache"]] + ) + if "no_cache" in kwargs and kwargs["no_cache"]: + (flag, value) = await func(*args, **kwargs) + return update_cache_and_return(key, flag, value) + + if in_cache(key): + return cache[key][1] + + (flag, value) = await func(*args, **kwargs) + return update_cache_and_return(key, flag, value) + + return wrapper + + return decorator diff --git a/frontend/backend/src/utils/decorators.py b/frontend/backend/src/utils/decorators.py new file mode 100644 index 00000000..dd814ed2 --- /dev/null +++ b/frontend/backend/src/utils/decorators.py @@ -0,0 +1,46 @@ +import logging +from datetime import datetime +from functools import wraps +from typing import Any, Callable, Dict, List + +from fastapi import Response, status + + +def fail_gracefully(func: Callable[..., Any]): + @wraps(func) # needed to play nice with FastAPI decorator + def wrapper(response: Response, *args: List[Any], **kwargs: Dict[str, Any]) -> Any: + start = datetime.now() + try: + data = func(response, *args, **kwargs) + return {"data": data, "message": "200 OK", "time": datetime.now() - start} + except Exception as e: + logging.exception(e) + response.status_code = status.HTTP_500_INTERNAL_SERVER_ERROR + return { + "data": [], + "message": f"Error {str(e)}", + "time": datetime.now() - start, + } + + return wrapper + + +def async_fail_gracefully(func: Callable[..., Any]): + @wraps(func) # needed to play nice with FastAPI decorator + async def wrapper( + response: Response, *args: List[Any], **kwargs: Dict[str, Any] + ) -> Any: + start = datetime.now() + try: + data = await func(response, *args, **kwargs) + return {"data": data, "message": "200 OK", "time": datetime.now() - start} + except Exception as e: + logging.exception(e) + response.status_code = status.HTTP_500_INTERNAL_SERVER_ERROR + return { + "data": [], + "message": f"Error {str(e)}", + "time": datetime.now() - start, + } + + return wrapper diff --git a/frontend/backend/src/utils/gather.py b/frontend/backend/src/utils/gather.py new file mode 100644 index 00000000..ac43cdec --- /dev/null +++ b/frontend/backend/src/utils/gather.py @@ -0,0 +1,42 @@ +import asyncio +from functools import partial, wraps +from typing import Any, Callable, Dict, List + + +def async_function(func: Callable[..., Any]) -> Callable[..., Any]: + @wraps(func) + async def run(*args: List[Any], **kwargs: Dict[str, Any]) -> Any: + loop = asyncio.get_event_loop() + pfunc = partial(func, *args, **kwargs) + return await loop.run_in_executor(executor=None, func=pfunc) + + return run + + +async def gather_with_concurrency( + n: int, *tasks: List[Callable[..., Any]] +) -> List[Any]: + semaphore = asyncio.Semaphore(n) + + async def sem_task(task: Callable[..., Any]) -> Any: + async with semaphore: + return await task # type: ignore + + return await asyncio.gather(*(sem_task(task) for task in tasks)) # type: ignore + + +async def gather( + funcs: List[Callable[..., Any]], + args_dicts: List[Dict[str, Any]], + max_threads: int = 5, +) -> List[Any]: + """runs the given functions asynchronously""" + + output: List[Any] = list( + await gather_with_concurrency( + max_threads, + *(async_function(func)(**kwargs) for func, kwargs in zip(funcs, args_dicts)) + ) + ) + + return output diff --git a/frontend/backend/src/utils/utils.py b/frontend/backend/src/utils/utils.py new file mode 100644 index 00000000..969bb9d8 --- /dev/null +++ b/frontend/backend/src/utils/utils.py @@ -0,0 +1,45 @@ +from datetime import date, datetime, timedelta +from typing import Tuple + + +def date_to_datetime( + dt: date, hour: int = 0, minute: int = 0, second: int = 0 +) -> datetime: + return datetime(dt.year, dt.month, dt.day, hour, minute, second) + + +# returns start date, end date, string representing time range +def use_time_range( + time_range: str, start_date: date, end_date: date +) -> Tuple[date, date, str]: + duration_options = { + "one_month": (30, "Past 1 Month"), + "three_months": (90, "Past 3 Months"), + "six_months": (180, "Past 6 Months"), + "one_year": (365, "Past 1 Year"), + "all_time": (365 * 10, "All Time"), + } + + start_str = start_date.strftime("X%m/X%d/%Y").replace("X0", "X").replace("X", "") + end_str = end_date.strftime("X%m/X%d/%Y").replace("X0", "X").replace("X", "") + if end_date == date.today(): + end_str = "Present" + time_str = f"{start_str} - {end_str}" + + if time_range in duration_options: + days, time_str = duration_options[time_range] + end_date = date.today() + start_date = date.today() - timedelta(days) + + return start_date, end_date, time_str + + +def format_number(num: int) -> str: + if num > 10000: + return f"~{str(num // 1000)}k lines" + elif num > 1000: + return f"~{str(num // 100 / 10)}k lines" + elif num > 100: + return f"~{str(num // 100 * 100)} lines" + else: + return "<100 lines" diff --git a/frontend/backend/tests/__init__.py b/frontend/backend/tests/__init__.py new file mode 100644 index 00000000..134bb6a6 --- /dev/null +++ b/frontend/backend/tests/__init__.py @@ -0,0 +1,3 @@ +from dotenv import find_dotenv, load_dotenv + +load_dotenv(find_dotenv(), verbose=True) diff --git a/frontend/backend/tests/aggregation/__init__.py b/frontend/backend/tests/aggregation/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/frontend/backend/tests/aggregation/layer0/__init__.py b/frontend/backend/tests/aggregation/layer0/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/frontend/backend/tests/aggregation/layer0/test_contributions.py b/frontend/backend/tests/aggregation/layer0/test_contributions.py new file mode 100644 index 00000000..47afd4d8 --- /dev/null +++ b/frontend/backend/tests/aggregation/layer0/test_contributions.py @@ -0,0 +1,20 @@ +from datetime import date, timedelta + +from aiounittest.case import AsyncTestCase + +from src.aggregation.layer0.contributions import get_contributions +from src.constants import TEST_TOKEN as TOKEN, TEST_USER_ID as USER_ID +from src.models import UserContributions + + +class TestTemplate(AsyncTestCase): + async def test_get_contributions(self): + response = await get_contributions( + user_id=USER_ID, + start_date=date.today() - timedelta(days=30), + end_date=date.today(), + access_token=TOKEN, + ) + self.assertIsInstance(response, UserContributions) + + # TODO: Add further validation diff --git a/frontend/backend/tests/aggregation/layer0/test_follows.py b/frontend/backend/tests/aggregation/layer0/test_follows.py new file mode 100644 index 00000000..7664c806 --- /dev/null +++ b/frontend/backend/tests/aggregation/layer0/test_follows.py @@ -0,0 +1,13 @@ +import unittest + +from src.aggregation.layer0.follows import get_user_follows +from src.constants import TEST_TOKEN as TOKEN, TEST_USER_ID as USER_ID +from src.models import UserFollows + + +class TestTemplate(unittest.TestCase): + def test_get_follows(self): + response = get_user_follows(USER_ID, TOKEN) + self.assertIsInstance(response, UserFollows) + + # TODO: Add further validation diff --git a/frontend/backend/tests/data/__init__.py b/frontend/backend/tests/data/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/frontend/backend/tests/data/github/__init__.py b/frontend/backend/tests/data/github/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/frontend/backend/tests/data/github/auth/__init__.py b/frontend/backend/tests/data/github/auth/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/frontend/backend/tests/data/github/auth/test_main.py b/frontend/backend/tests/data/github/auth/test_main.py new file mode 100644 index 00000000..decda2e4 --- /dev/null +++ b/frontend/backend/tests/data/github/auth/test_main.py @@ -0,0 +1,16 @@ +import unittest + +from src.constants import TEST_TOKEN as TOKEN, TEST_USER_ID as USER_ID +from src.data.github.auth.main import get_unknown_user + + +class TestTemplate(unittest.TestCase): + def test_get_unknown_user_valid(self): + user_id = get_unknown_user(TOKEN) + self.assertEqual(user_id, USER_ID) + + def test_get_unknown_user_invalid(self): + user_id = get_unknown_user("") + self.assertEqual(user_id, None) + + # TODO: test authenticate() diff --git a/frontend/backend/tests/data/github/graphql/__init__.py b/frontend/backend/tests/data/github/graphql/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/frontend/backend/tests/data/github/graphql/test_commits.py b/frontend/backend/tests/data/github/graphql/test_commits.py new file mode 100644 index 00000000..a7190597 --- /dev/null +++ b/frontend/backend/tests/data/github/graphql/test_commits.py @@ -0,0 +1,34 @@ +import unittest + +from src.constants import TEST_NODE_IDS as NODE_IDS, TEST_TOKEN as TOKEN +from src.data.github.graphql import RawCommit, get_commits + + +class TestTemplate(unittest.TestCase): + def test_get_commits(self): + node_ids = get_commits(access_token=TOKEN, node_ids=NODE_IDS, catch_errors=True) + + # assert returns equal number of commits + self.assertIsInstance(node_ids, list) + self.assertEqual(len(node_ids), len(NODE_IDS)) + + self.assertIsInstance(node_ids[0], RawCommit) + + def test_get_commits_invalid_access_token(self): + node_ids = get_commits(access_token="", node_ids=NODE_IDS, catch_errors=True) + + # assert returns list of Nones + self.assertIsInstance(node_ids, list) + self.assertEqual(len(node_ids), len(NODE_IDS)) + self.assertIsInstance(node_ids[0], type(None)) + + def test_get_commits_invalid_node_ids(self): + node_ids = get_commits( + access_token=TOKEN, + node_ids=[NODE_IDS[0], "", NODE_IDS[1]], + catch_errors=True, + ) + + self.assertIsInstance(node_ids[0], RawCommit) + self.assertIsInstance(node_ids[1], type(None)) + self.assertIsInstance(node_ids[2], RawCommit) diff --git a/frontend/backend/tests/data/github/graphql/test_repo.py b/frontend/backend/tests/data/github/graphql/test_repo.py new file mode 100644 index 00000000..8e81ee5c --- /dev/null +++ b/frontend/backend/tests/data/github/graphql/test_repo.py @@ -0,0 +1,34 @@ +import unittest + +from src.constants import ( + TEST_REPO as REPO, + TEST_TOKEN as TOKEN, + TEST_USER_ID as USER_ID, +) +from src.data.github.graphql import RawRepo, get_repo + + +class TestTemplate(unittest.TestCase): + def test_get_repo(self): + repo: RawRepo = get_repo( # type: ignore + access_token=TOKEN, owner=USER_ID, repo=REPO, catch_errors=True + ) + + # assert returns equal number of commits + self.assertIsInstance(repo, RawRepo) + self.assertEqual(repo.is_private, False) + self.assertGreater(repo.fork_count, 0) + self.assertGreater(repo.stargazer_count, 0) + + def test_get_repo_invalid_access_token(self): + repo = get_repo(access_token="", owner=USER_ID, repo=REPO, catch_errors=True) + + # assert returns None + self.assertIsInstance(repo, type(None)) + + def test_get_commits_invalid_args(self): + repo = get_repo( + access_token=TOKEN, owner="abc123", repo=REPO, catch_errors=True + ) + + self.assertIsInstance(repo, type(None)) diff --git a/frontend/backend/tests/data/github/graphql/test_user_contribs.py b/frontend/backend/tests/data/github/graphql/test_user_contribs.py new file mode 100644 index 00000000..f8e7b2be --- /dev/null +++ b/frontend/backend/tests/data/github/graphql/test_user_contribs.py @@ -0,0 +1,32 @@ +import unittest +from datetime import datetime, timedelta + +from src.constants import TEST_TOKEN as TOKEN, TEST_USER_ID as USER_ID +from src.data.github.graphql import ( + RawCalendar, + RawEvents, + get_user_contribution_calendar, + get_user_contribution_events, +) + + +class TestTemplate(unittest.TestCase): + def test_get_user_contribution_calendar(self): + response = get_user_contribution_calendar( + user_id=USER_ID, + access_token=TOKEN, + start_date=datetime.now() - timedelta(days=30), + end_date=datetime.now(), + ) + + self.assertIsInstance(response, RawCalendar) + + def test_get_user_contribution_events(self): + response = get_user_contribution_events( + user_id=USER_ID, + access_token=TOKEN, + start_date=datetime.now() - timedelta(days=30), + end_date=datetime.now(), + ) + + self.assertIsInstance(response, RawEvents) diff --git a/frontend/backend/tests/data/github/graphql/test_user_follows.py b/frontend/backend/tests/data/github/graphql/test_user_follows.py new file mode 100644 index 00000000..65be59bd --- /dev/null +++ b/frontend/backend/tests/data/github/graphql/test_user_follows.py @@ -0,0 +1,20 @@ +import unittest + +from src.constants import TEST_TOKEN as TOKEN, TEST_USER_ID as USER_ID +from src.data.github.graphql import RawFollows, get_user_followers, get_user_following + + +class TestTemplate(unittest.TestCase): + def test_get_user_followers(self): + response = get_user_followers(user_id=USER_ID, access_token=TOKEN) + self.assertIsInstance(response, RawFollows) + + response = get_user_followers(user_id=USER_ID, access_token=TOKEN, first=1) + self.assertLessEqual(len(response.nodes), 1) + + def test_get_user_following(self): + response = get_user_following(user_id=USER_ID, access_token=TOKEN) + self.assertIsInstance(response, RawFollows) + + response = get_user_following(user_id=USER_ID, access_token=TOKEN, first=1) + self.assertLessEqual(len(response.nodes), 1) diff --git a/frontend/backend/tests/data/github/rest/__init__.py b/frontend/backend/tests/data/github/rest/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/frontend/backend/tests/data/github/rest/test_commit.py b/frontend/backend/tests/data/github/rest/test_commit.py new file mode 100644 index 00000000..d37c27ae --- /dev/null +++ b/frontend/backend/tests/data/github/rest/test_commit.py @@ -0,0 +1,26 @@ +import unittest + +from src.constants import ( + TEST_REPO as REPO, + TEST_SHA as SHA, + TEST_TOKEN as TOKEN, + TEST_USER_ID as USER_ID, +) +from src.data.github.rest import RawCommitFile, get_commit_files + + +class TestTemplate(unittest.TestCase): + def test_get_commit_files(self): + commits = get_commit_files( + access_token=TOKEN, owner=USER_ID, repo=REPO, sha=SHA + ) + self.assertIsInstance(commits, list) + self.assertIsInstance(commits[0], RawCommitFile) # type: ignore + + def test_get_commit_files_invalid_access_token(self): + repo = get_commit_files(access_token="", owner=USER_ID, repo=REPO, sha=SHA) + self.assertEqual(repo, None) + + def test_get_commit_files_invalid_args(self): + repo = get_commit_files(access_token=TOKEN, owner="abc123", repo=REPO, sha=SHA) + self.assertEqual(repo, None) diff --git a/frontend/backend/tests/data/github/rest/test_repo.py b/frontend/backend/tests/data/github/rest/test_repo.py new file mode 100644 index 00000000..06e753f2 --- /dev/null +++ b/frontend/backend/tests/data/github/rest/test_repo.py @@ -0,0 +1,23 @@ +import unittest + +from src.constants import ( + TEST_REPO as REPO, + TEST_TOKEN as TOKEN, + TEST_USER_ID as USER_ID, +) +from src.data.github.rest import RawCommit, get_repo_commits + + +class TestTemplate(unittest.TestCase): + def test_get_repo_commits(self): + commits = get_repo_commits(access_token=TOKEN, owner=USER_ID, repo=REPO) + self.assertIsInstance(commits, list) + self.assertIsInstance(commits[0], RawCommit) + + def test_get_repo_commits_invalid_access_token(self): + repo = get_repo_commits(access_token="", owner=USER_ID, repo=REPO) + self.assertEqual(repo, []) + + def test_get_repo_commits_invalid_args(self): + repo = get_repo_commits(access_token=TOKEN, owner="abc123", repo=REPO) + self.assertEqual(repo, []) diff --git a/frontend/backend/tests/utils/__init__.py b/frontend/backend/tests/utils/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/frontend/backend/tests/utils/test_alru_cache.py b/frontend/backend/tests/utils/test_alru_cache.py new file mode 100644 index 00000000..b9dd4235 --- /dev/null +++ b/frontend/backend/tests/utils/test_alru_cache.py @@ -0,0 +1,111 @@ +from asyncio import sleep +from datetime import timedelta +from typing import Tuple + +from aiounittest.case import AsyncTestCase + +from src.utils import alru_cache + + +class TestTemplate(AsyncTestCase): + async def test_basic_alru_cache(self): + count = 0 + + @alru_cache() + async def f(x: int) -> Tuple[bool, int]: + nonlocal count + count += 1 + return (True, x) + + assert count == 0 + assert await f(1) == 1 + assert count == 1 + assert await f(1) == 1 + assert count == 1 + assert await f(2) == 2 + assert count == 2 + assert await f(2) == 2 + assert count == 2 + assert await f(1) == 1 + assert count == 2 + + async def test_alru_cache_with_flag(self): + count = 0 + + @alru_cache() + async def f(x: int) -> Tuple[bool, int]: + nonlocal count + count += 1 + return (count % 2 == 0, x) + + assert count == 0 + assert await f(1) == 1 + assert count == 1 + assert await f(1) == 1 + assert count == 2 + assert await f(2) == 2 + assert count == 3 + assert await f(3) == 3 + assert count == 4 + assert await f(3) == 3 + assert count == 4 + + async def test_alru_cache_with_maxsize(self): + count = 0 + + @alru_cache(max_size=2) + async def f(x: int) -> Tuple[bool, int]: + nonlocal count + count += 1 + return (True, x) + + assert count == 0 + assert await f(1) == 1 + assert count == 1 + assert await f(2) == 2 + assert count == 2 + assert await f(3) == 3 + assert count == 3 + assert await f(1) == 1 + assert count == 4 + + async def test_alru_cache_with_ttl(self): + count = 0 + + @alru_cache(ttl=timedelta(milliseconds=1)) + async def f(x: int) -> Tuple[bool, int]: + nonlocal count + count += 1 + return (True, x) + + assert count == 0 + assert await f(1) == 1 + assert count == 1 + assert await f(1) == 1 + assert count == 1 + await sleep(0.01) + assert await f(1) == 1 + assert count == 2 + + async def test_alru_cache_with_no_cache(self): + count = 0 + + @alru_cache() + async def f(x: int, no_cache: bool = False) -> Tuple[bool, int]: + nonlocal count + count += 1 + return (True, x) + + assert count == 0 + assert await f(1) == 1 + assert count == 1 + assert await f(1) == 1 + assert count == 1 + assert await f(2) == 2 + assert count == 2 + assert await f(2, no_cache=True) == 2 + assert count == 3 + assert await f(3) == 3 + assert count == 4 + assert await f(3, no_cache=False) == 3 + assert count == 4 diff --git a/frontend/backend/transfer_mongodb.bash b/frontend/backend/transfer_mongodb.bash new file mode 100644 index 00000000..4a1d0e8d --- /dev/null +++ b/frontend/backend/transfer_mongodb.bash @@ -0,0 +1,31 @@ +if [ $# -eq 0 ]; then + echo "Usage: $0 " + exit 1 +fi + +# Export +mongoexport --uri "mongodb+srv://backend.aqlpb.mongodb.net/" --db dev_backend --collection secrets --username root --password "$1" > ./dev_secrets.json +mongoexport --uri "mongodb+srv://backend.aqlpb.mongodb.net/" --db dev_backend --collection users --username root --password "$1" > ./dev_users.json +mongoexport --uri "mongodb+srv://backend.aqlpb.mongodb.net/" --db dev_backend --collection user_months --username root --password "$1" > ./dev_user_months.json + +mongoexport --uri "mongodb+srv://backend.aqlpb.mongodb.net/" --db prod_backend --collection secrets --username root --password "$1" > ./prod_secrets.json +mongoexport --uri "mongodb+srv://backend.aqlpb.mongodb.net/" --db prod_backend --collection users --username root --password "$1" > ./prod_users.json +mongoexport --uri "mongodb+srv://backend.aqlpb.mongodb.net/" --db prod_backend --collection user_months --username root --password "$1" > ./prod_user_months.json + +# Import +mongoimport --uri "mongodb+srv://backend2.e50j8dp.mongodb.net/" --db dev_backend --collection secrets --username root --password "$1" < ./dev_secrets.json +mongoimport --uri "mongodb+srv://backend2.e50j8dp.mongodb.net/" --db dev_backend --collection users --username root --password "$1" < ./dev_users.json +mongoimport --uri "mongodb+srv://backend2.e50j8dp.mongodb.net/" --db dev_backend --collection user_months --username root --password "$1" < ./dev_user_months.json + +mongoimport --uri "mongodb+srv://backend2.e50j8dp.mongodb.net/" --db prod_backend --collection secrets --username root --password "$1" < ./prod_secrets.json +mongoimport --uri "mongodb+srv://backend2.e50j8dp.mongodb.net/" --db prod_backend --collection users --username root --password "$1" < ./prod_users.json +mongoimport --uri "mongodb+srv://backend2.e50j8dp.mongodb.net/" --db prod_backend --collection user_months --username root --password "$1" < ./prod_user_months.json + +# Remove +rm ./dev_secrets.json +rm ./dev_users.json +rm ./dev_user_months.json + +rm ./prod_secrets.json +rm ./prod_users.json +rm ./prod_user_months.json \ No newline at end of file diff --git a/frontend/backend/yarn.lock b/frontend/backend/yarn.lock new file mode 100644 index 00000000..fb57ccd1 --- /dev/null +++ b/frontend/backend/yarn.lock @@ -0,0 +1,4 @@ +# THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. +# yarn lockfile v1 + + diff --git a/frontend/docs/API.md b/frontend/docs/API.md new file mode 100644 index 00000000..9cd7ccda --- /dev/null +++ b/frontend/docs/API.md @@ -0,0 +1,96 @@ +# GitHub Trends API + +GitHub Trends provides two methods to access GitHub Trends data: the Website Workflow at githubtrends.io and the API Workflow described below. + +## Available Cards + +After authenticating with either the public or private workflow (see below), users can create the following cards to display their GitHub Trends data: + +- **[Languages Card](https://github.com/avgupta456/github-trends/blob/main/docs/API.md#languages-card)**: See your top languages over a given time interval, based on all commits to personal and open-source repositories. + +- **[Repositories Card](https://github.com/avgupta456/github-trends/blob/main/docs/API.md#repositories-card)**: See your top repositories based on lines of code contributed over a given time period. This includes both personal and open-source repositories. + +# Authentication + +You will need to create an account with GitHub Trends to create cards. The account is used to assosciate queries to the GitHub API made on your behalf with your GitHub account's API quota. We use less than 5% of your quota in almost all scenarios. There are two levels of authentication possible: + +- Public Workflow: The Public Workflow asks for read-only permission to public information. This will allow us to analyze your public contributions and repositories only. +- Private Workflow: The Private Workflow asks for read and write permission to public and private information. This will allow us to analyze your entire contribution history. See [the FAQ](https://github.com/avgupta456/github-trends/blob/main/docs/FAQ.md) for further information. + +You will only need to authenticate once with GitHub Trends. Subsequent requests will use your stored access token. + +For the public workflow, visit + +```md +https://api.githubtrends.io/auth/signup/public +``` + +For the private workflow, visit + +```md +https://api.githubtrends.io/auth/signup/private +``` + +You will be prompted to allow access, and (hopefully) redirected to a success screen. + +If you have previously authenticated with the public workflow, you can upgrade to the private workflow by using the private link. If you would like to delete your account, go to your GitHub settings and revoke the access token. + +# Languages Card + +See your top languages over a given time interval, based on all commits to personal and open-source repositories. Your top five languages will be displayed. Due to the approximations used internally, LOC metrics will be rounded to the nearest 100 lines. + +After authentication, visit + +```md +https://api.githubtrends.io/user/svg/{user_id}/langs +``` + +## Customization + +The following customization options are available: + +| Option | Description | Default | +| ----------------- | ------------------------------------------------------------------------------------------------------------------------------------------ | ----------- | +| `time_range` | Specifies the time range to query statistics for. Valid options are `one_month`, `three_months`, `six_months`, `one_year`, and `all_time`. | `one_month` | +| `include_private` | Determines if private contributions are included (requires private workflow). | `false` | +| `compact` | Determines if compact layout is used (forces percentages over LOC) | `false` | +| `use_percent` | Valid if `compact=false`, determines if line of code (default) or percentages are displayed. | `false` | +| `loc_metric` | Options are LOC added (`added`) and LOC changed (`changed`). | `added` | +| `theme` | Theme to use for the card. See [docs/THEME.md](https://github.com/avgupta456/github-trends/blob/main/docs/THEME.md) for options. | `classic` | + +Customizations can be appended to the endpoint, separated first with `?` and subsequently with `&`. + +## Example + +Endpoint: `https://api.githubtrends.io/user/svg/avgupta456/langs?time_range=three_months&include_private=true&compact=true` + +[![GitHub Trends SVG](https://api.githubtrends.io/user/svg/avgupta456/langs?time_range=three_months&include_private=true&compact=true)](https://githubtrends.io) + +# Repositories Card + +After authentication, visit + +```md +https://api.githubtrends.io/user/svg/{user_id}/repos +``` + +## Customization + +The following customization options are available: + +| Option | Description | Default | +| ----------------- | ------------------------------------------------------------------------------------------------------------------------------------------ | ----------- | +| `time_range` | Specifies the time range to query statistics for. Valid options are `one_month`, `three_months`, `six_months`, `one_year`, and `all_time`. | `one_month` | +| `include_private` | Determines if private contributions are included (requires private workflow). | `false` | +| `group` | Options are `none` (default), `other` (group all other repos together), and `private` (force private repos to be grouped) | `none` | +| `use_percent` | Valid if `compact=false`, determines if line of code (default) or percentages are displayed. | `false` | +| `loc_metric` | Options are LOC added (`added`) and LOC changed (`changed`). | `added` | +| `theme` | Theme to use for the card. See [docs/THEME.md](https://github.com/avgupta456/github-trends/blob/main/docs/THEME.md) for options. | `classic` | + +Customizations can be appended to the endpoint, separated first with `?` and subsequently with `&`. + +## Example + +Endpoint: `https://api.githubtrends.io/user/svg/avgupta456/repos?time_range=one_year&include_private=true&group=private&loc_metric=changed&theme=dark` + +[![GitHub Trends SVG](https://api.githubtrends.io/user/svg/avgupta456/repos?time_range=one_year&include_private=true&group=private&loc_metric=changed&theme=dark)](https://githubtrends.io) diff --git a/frontend/docs/CONTRIBUTING.md b/frontend/docs/CONTRIBUTING.md new file mode 100644 index 00000000..1570106b --- /dev/null +++ b/frontend/docs/CONTRIBUTING.md @@ -0,0 +1,15 @@ +# GitHub Trends + +If you are interested in contributing to GitHub Trends, take a look through the codebase and at the open issues. Follow the guide below to set up your local environment, and contact Abhijit Gupta at `avgupta456@gmail.com` if you have any questions or need additional permissions. Thank you in advance for contributing! + +## Local Development + +First, copy `backend/.env-template` into `backend/.env` and fill in the missing variables. Similarly, copy `frontend/.env-template` into `frontend/.env` and fill in the missing variables. Create a Google Cloud Platform service account and include the key in `backend/gcloud_key.json`. Then run: + +With Python3.11, install the dependencies from `backend/requirements.txt` and run `yarn start`. + +With Node16 and Yarn, install the dependencies from `frontend/package.json` and run on a separate terminal window `yarn start-trends`. + +## Testing + +Create a pull request and let GitHub Actions run. Alternatively, explore `.github/backend.yaml` and `.github/frontend.yaml` to run tests locally. Backend coverage must increase for PRs to be merged. diff --git a/frontend/docs/FAQ.md b/frontend/docs/FAQ.md new file mode 100644 index 00000000..e382ea06 --- /dev/null +++ b/frontend/docs/FAQ.md @@ -0,0 +1,38 @@ +# FAQ + +The FAQ is in progress. Reach out if you have any unanswered questions or concerns. + +--- + +**Question**: Does GitHub Trends have access to my private code contributions? + +**Answer**: GitHub Trends requires an OAuth access token to make requests on your behalf. The standard public workflow creates a token with read-only access to strictly public information. **This access token can not view or edit any private contributions**. + +Alternatively, users can use the private workflow which creates a token with read and write access to private information. Although GitHub Trends only uses it's read access, GitHub does not allow read-only private access (see [an open issue from 2015](https://github.com/jollygoodcode/jollygoodcode.github.io/issues/6)). While one may scan the repository to confirm this statement, there are inherent security risks to this overallocation. If this poses an issue to you, please use the public workflow instead. + +**Question**: How can I display my images side by side? + +**Answer**: Use HTML (credit: [github-readme-stats](https://github.com/anuraghazra/github-readme-stats#quick-tip-align-the-repo-cards)) + +``` + + + + + + +``` + +**Question**: How can I see my stats without giving GitHub Trends my access token? + +**Answer**: You will need to run the code locally. Clone the repository, navigate to the `backend` folder, install the dependencies (`pip install -r requirements.txt`), and then run the following script: + +```bash +python ./scripts/local.py --user_id=USER_ID --access_token=ACCESS_TOKEN --start_date=2023-01-01 --end_date=2023-01-31 --output_dir=OUTPUT_DIR +``` + +The script will output the raw and processed JSONs into the output directory specified. + +**Question**: What if I find a bug, or want to contribute? + +**Answer**: Raise an [issue](https://github.com/avgupta456/github-trends/issues/new) or [pull request](https://github.com/avgupta456/github-trends/compare) through GitHub. I would be happy to discuss and implement any suggestions or improvements. diff --git a/frontend/docs/THEME.md b/frontend/docs/THEME.md new file mode 100644 index 00000000..15eb2090 --- /dev/null +++ b/frontend/docs/THEME.md @@ -0,0 +1,6 @@ +The following themes are available for all GitHub Trends cards: + +| Themes | | | +| --------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------ | -------------------------------------------------------------------------------------------------------------------------- | +| [![GitHub Trends SVG](https://api.githubtrends.io/user/svg/avgupta456/langs?theme=classic)](https://githubtrends.io) | [![GitHub Trends SVG](https://api.githubtrends.io/user/svg/avgupta456/langs?theme=dark)](https://githubtrends.io) | [![GitHub Trends SVG](https://api.githubtrends.io/user/svg/avgupta456/langs?theme=bright_lights)](https://githubtrends.io) | +| [![GitHub Trends SVG](https://api.githubtrends.io/user/svg/avgupta456/langs?theme=rosettes)](https://githubtrends.io) | [![GitHub Trends SVG](https://api.githubtrends.io/user/svg/avgupta456/langs?theme=ferns)](https://githubtrends.io) | [![GitHub Trends SVG](https://api.githubtrends.io/user/svg/avgupta456/langs?theme=synthwaves)](https://githubtrends.io) | diff --git a/frontend/frontend/.env-template b/frontend/frontend/.env-template new file mode 100644 index 00000000..4a52c1c5 --- /dev/null +++ b/frontend/frontend/.env-template @@ -0,0 +1,3 @@ +REACT_APP_PROD=false + +REACT_APP_CLIENT_ID=abc123 \ No newline at end of file diff --git a/frontend/frontend/.eslintrc.js b/frontend/frontend/.eslintrc.js new file mode 100644 index 00000000..65da3b5f --- /dev/null +++ b/frontend/frontend/.eslintrc.js @@ -0,0 +1,32 @@ +module.exports = { + env: { + browser: true, + es6: true, + }, + extends: ['airbnb', 'plugin:prettier/recommended'], + parserOptions: { + ecmaFeatures: { + jsx: true, + }, + ecmaVersion: 2020, + sourceType: 'module', + }, + plugins: ['react', 'prettier'], + rules: { + 'react/jsx-filename-extension': 'off', + 'react/forbid-prop-types': 'off', + 'react/destructuring-assignment': 'off', + 'import/prefer-default-export': 'off', + 'react/function-component-definition': 'off', + 'react/no-unstable-nested-components': 'off', + 'jsx-a11y/control-has-associated-label': 'off', + 'no-console': 'off', + radix: 'off', + 'prettier/prettier': [ + 'error', + { + endOfLine: 'auto', + }, + ], + }, +}; diff --git a/frontend/frontend/.gitignore b/frontend/frontend/.gitignore new file mode 100644 index 00000000..1130e8b8 --- /dev/null +++ b/frontend/frontend/.gitignore @@ -0,0 +1,26 @@ +# See https://help.github.com/articles/ignoring-files/ for more about ignoring files. + +# dependencies +/node_modules +/.pnp +.pnp.js + +# testing +/coverage + +# production +/build + +# misc +.DS_Store +.env.local +.env.development.local +.env.test.local +.env.production.local + +npm-debug.log* +yarn-debug.log* +yarn-error.log* + +# custom +public/index.html \ No newline at end of file diff --git a/frontend/frontend/.prettierrc.js b/frontend/frontend/.prettierrc.js new file mode 100644 index 00000000..a4db989c --- /dev/null +++ b/frontend/frontend/.prettierrc.js @@ -0,0 +1,7 @@ +module.exports = { + semi: true, + trailingComma: 'all', + singleQuote: true, + printWidth: 80, + tabWidth: 2, +}; diff --git a/frontend/frontend/.yarnrc b/frontend/frontend/.yarnrc new file mode 100644 index 00000000..bdbad5be --- /dev/null +++ b/frontend/frontend/.yarnrc @@ -0,0 +1 @@ +network-timeout 500000 \ No newline at end of file diff --git a/frontend/frontend/README.md b/frontend/frontend/README.md new file mode 100644 index 00000000..5bd14f8c --- /dev/null +++ b/frontend/frontend/README.md @@ -0,0 +1,27 @@ +# Frontend + +## Installation + +``` +yarn install +``` + +## Run Locally + +``` +yarn start-trends +yarn start-wrapped +``` + +## Build + +``` +yarn build-trends +yarn build-wrapped +``` + +Then, just commit on the main branch (Vercel takes care of the rest) + +## Adding a Secret + +Update .env, .env-template, and Vercel. diff --git a/frontend/frontend/deploy/Dockerfile b/frontend/frontend/deploy/Dockerfile new file mode 100644 index 00000000..5256a74e --- /dev/null +++ b/frontend/frontend/deploy/Dockerfile @@ -0,0 +1,12 @@ +FROM node:16-alpine + +WORKDIR /frontend + +ENV PATH /frontend/node_modules/.bin:$PATH + +COPY ../package.json ../yarn.lock /frontend/ +RUN yarn install --network-timeout 100000 + +COPY ../ /frontend + +CMD ["yarn", "start"] \ No newline at end of file diff --git a/frontend/frontend/package.json b/frontend/frontend/package.json new file mode 100644 index 00000000..bbafa61e --- /dev/null +++ b/frontend/frontend/package.json @@ -0,0 +1,78 @@ +{ + "name": "frontend", + "version": "0.1.0", + "private": true, + "dependencies": { + "@babel/runtime": "^7.23.2", + "@nivo/bar": "^0.83.0", + "@nivo/calendar": "^0.83.0", + "@nivo/core": "^0.83.0", + "@nivo/pie": "^0.83.0", + "@nivo/radar": "^0.83.0", + "@nivo/swarmplot": "^0.83.0", + "@nivo/tooltip": "^0.83.0", + "axios": "^1.6.1", + "daisyui": "2.31.0", + "downloadjs": "^1.4.7", + "html-to-image": "^1.11.11", + "moment": "^2.29.4", + "prop-types": "^15.8.1", + "react": "^18.2.0", + "react-dom": "^18.2.0", + "react-icons": "^4.11.0", + "react-loading-skeleton": "^3.3.1", + "react-redux": "^8.1.3", + "react-router-dom": "^6.18.0", + "react-scripts": "^5.0.1", + "react-select": "^5.8.0", + "react-spinners": "^0.13.8", + "react-toastify": "^9.1.3", + "react-typist": "^2.0.5", + "react-typist-loop": "0.0.5", + "redux": "^4.2.1", + "save-svg-as-png": "^1.4.17", + "uuid": "^9.0.1" + }, + "devDependencies": { + "autoprefixer": "^10.4.16", + "eslint": "8.53.0", + "eslint-config-airbnb": "^19.0.4", + "eslint-config-prettier": "^9.0.0", + "eslint-plugin-import": "^2.29.0", + "eslint-plugin-jsx-a11y": "^6.8.0", + "eslint-plugin-prettier": "^5.0.1", + "eslint-plugin-react": "7.33.2", + "eslint-plugin-react-hooks": "4.6.0", + "postcss": "^8.4.31", + "prettier": "^3.0.3", + "tailwindcss": "^3.3.5" + }, + "scripts": { + "setup-trends": "cp ./public/trends.html ./public/index.html", + "setup-wrapped": "cp ./public/wrapped.html ./public/index.html", + "start-trends": "yarn setup-trends && REACT_APP_MODE=trends react-scripts start", + "start-wrapped": "yarn setup-wrapped && REACT_APP_MODE=wrapped PORT=3001 react-scripts start", + "build-trends": "yarn setup-trends && REACT_APP_MODE=trends react-scripts build", + "build-wrapped": "yarn setup-wrapped && REACT_APP_MODE=wrapped react-scripts build", + "test": "react-scripts test", + "eject": "react-scripts eject" + }, + "eslintConfig": { + "extends": [ + "react-app", + "react-app/jest" + ] + }, + "browserslist": { + "production": [ + ">0.2%", + "not dead", + "not op_mini all" + ], + "development": [ + "last 1 chrome version", + "last 1 firefox version", + "last 1 safari version" + ] + } +} diff --git a/frontend/frontend/public/_redirects b/frontend/frontend/public/_redirects new file mode 100644 index 00000000..f8243379 --- /dev/null +++ b/frontend/frontend/public/_redirects @@ -0,0 +1 @@ +/* /index.html 200 \ No newline at end of file diff --git a/frontend/frontend/public/favicon.ico b/frontend/frontend/public/favicon.ico new file mode 100644 index 00000000..d0b5dd96 Binary files /dev/null and b/frontend/frontend/public/favicon.ico differ diff --git a/frontend/frontend/public/logo192.png b/frontend/frontend/public/logo192.png new file mode 100644 index 00000000..2418a501 Binary files /dev/null and b/frontend/frontend/public/logo192.png differ diff --git a/frontend/frontend/public/logo512.png b/frontend/frontend/public/logo512.png new file mode 100644 index 00000000..bf30244f Binary files /dev/null and b/frontend/frontend/public/logo512.png differ diff --git a/frontend/frontend/public/manifest.json b/frontend/frontend/public/manifest.json new file mode 100644 index 00000000..080d6c77 --- /dev/null +++ b/frontend/frontend/public/manifest.json @@ -0,0 +1,25 @@ +{ + "short_name": "React App", + "name": "Create React App Sample", + "icons": [ + { + "src": "favicon.ico", + "sizes": "64x64 32x32 24x24 16x16", + "type": "image/x-icon" + }, + { + "src": "logo192.png", + "type": "image/png", + "sizes": "192x192" + }, + { + "src": "logo512.png", + "type": "image/png", + "sizes": "512x512" + } + ], + "start_url": ".", + "display": "standalone", + "theme_color": "#000000", + "background_color": "#ffffff" +} diff --git a/frontend/frontend/public/preview_trends.png b/frontend/frontend/public/preview_trends.png new file mode 100644 index 00000000..3296a13e Binary files /dev/null and b/frontend/frontend/public/preview_trends.png differ diff --git a/frontend/frontend/public/preview_wrapped.png b/frontend/frontend/public/preview_wrapped.png new file mode 100644 index 00000000..68405a34 Binary files /dev/null and b/frontend/frontend/public/preview_wrapped.png differ diff --git a/frontend/frontend/public/robots.txt b/frontend/frontend/public/robots.txt new file mode 100644 index 00000000..e9e57dc4 --- /dev/null +++ b/frontend/frontend/public/robots.txt @@ -0,0 +1,3 @@ +# https://www.robotstxt.org/robotstxt.html +User-agent: * +Disallow: diff --git a/frontend/frontend/public/trends.html b/frontend/frontend/public/trends.html new file mode 100644 index 00000000..79c20254 --- /dev/null +++ b/frontend/frontend/public/trends.html @@ -0,0 +1,77 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + GitHub Trends + + + +
+ + diff --git a/frontend/frontend/public/wrapped.html b/frontend/frontend/public/wrapped.html new file mode 100644 index 00000000..41a406ac --- /dev/null +++ b/frontend/frontend/public/wrapped.html @@ -0,0 +1,77 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + GitHub Wrapped + + + +
+ + diff --git a/frontend/frontend/src/api/index.js b/frontend/frontend/src/api/index.js new file mode 100644 index 00000000..8e99eb82 --- /dev/null +++ b/frontend/frontend/src/api/index.js @@ -0,0 +1,10 @@ +import { + setUserKey, + authenticate, + getUserMetadata, + deleteAccount, +} from './user'; + +import { getWrapped } from './wrapped'; + +export { setUserKey, authenticate, getUserMetadata, deleteAccount, getWrapped }; diff --git a/frontend/frontend/src/api/user.js b/frontend/frontend/src/api/user.js new file mode 100644 index 00000000..c8514740 --- /dev/null +++ b/frontend/frontend/src/api/user.js @@ -0,0 +1,53 @@ +import axios from 'axios'; +import { v4 as uuidv4 } from 'uuid'; + +import { BACKEND_URL } from '../constants'; + +const URL_PREFIX = BACKEND_URL; + +const setUserKey = async (code) => { + try { + const key = uuidv4(); + const fullUrl = `${URL_PREFIX}/auth/web/set_user_key/${code}/${key}`; + await axios.post(fullUrl); + return key; + } catch (error) { + console.error(error); + return ''; + } +}; + +const authenticate = async (code, privateAccess) => { + try { + const fullUrl = `${URL_PREFIX}/auth/web/login/${code}?private_access=${privateAccess}`; + const result = await axios.post(fullUrl); + return result.data.data; + } catch (error) { + console.error(error); + return ''; + } +}; + +const getUserMetadata = async (userId) => { + try { + const fullUrl = `${URL_PREFIX}/user/db/get/metadata/${userId}`; + const result = await axios.get(fullUrl); + return result.data.data; + } catch (error) { + console.error(error); + return ''; + } +}; + +const deleteAccount = async (userId, userKey) => { + try { + const fullUrl = `${URL_PREFIX}/auth/web/delete/${userId}?user_key=${userKey}`; + const result = await axios.get(fullUrl); + return result.data; // no decorator + } catch (error) { + console.error(error); + return ''; + } +}; + +export { setUserKey, authenticate, getUserMetadata, deleteAccount }; diff --git a/frontend/frontend/src/api/wrapped.js b/frontend/frontend/src/api/wrapped.js new file mode 100644 index 00000000..58d21bd6 --- /dev/null +++ b/frontend/frontend/src/api/wrapped.js @@ -0,0 +1,29 @@ +/* eslint-disable no-return-await */ + +import axios from 'axios'; + +import { BACKEND_URL } from '../constants'; + +const URL_PREFIX = `${BACKEND_URL}/wrapped`; + +const getValidUser = async (userId) => { + try { + const fullUrl = `${URL_PREFIX}/valid/${userId}`; + const result = await axios.get(fullUrl); + return result.data.data; + } catch (error) { + return null; + } +}; + +const getWrapped = async (userId, year) => { + try { + const fullUrl = `${URL_PREFIX}/${userId}?year=${year}`; + const result = await axios.get(fullUrl); + return result.data.data; + } catch (error) { + return null; + } +}; + +export { getWrapped, getValidUser }; diff --git a/frontend/frontend/src/assets/avgupta456_langs.png b/frontend/frontend/src/assets/avgupta456_langs.png new file mode 100644 index 00000000..7dcb3978 Binary files /dev/null and b/frontend/frontend/src/assets/avgupta456_langs.png differ diff --git a/frontend/frontend/src/assets/dhermes_langs.png b/frontend/frontend/src/assets/dhermes_langs.png new file mode 100644 index 00000000..a1e70a02 Binary files /dev/null and b/frontend/frontend/src/assets/dhermes_langs.png differ diff --git a/frontend/frontend/src/assets/mockup.png b/frontend/frontend/src/assets/mockup.png new file mode 100644 index 00000000..a3f07d39 Binary files /dev/null and b/frontend/frontend/src/assets/mockup.png differ diff --git a/frontend/frontend/src/assets/notes.txt b/frontend/frontend/src/assets/notes.txt new file mode 100644 index 00000000..42725561 --- /dev/null +++ b/frontend/frontend/src/assets/notes.txt @@ -0,0 +1,2 @@ +2022: Generated laptop mockups using deviceshots.com +2023: Used https://www.anthonyboyd.graphics/mockups/m2-macbook-air-mockup/ and Adobe Express \ No newline at end of file diff --git a/frontend/frontend/src/assets/reinink_repos.png b/frontend/frontend/src/assets/reinink_repos.png new file mode 100644 index 00000000..e875bf6f Binary files /dev/null and b/frontend/frontend/src/assets/reinink_repos.png differ diff --git a/frontend/frontend/src/assets/rocket.png b/frontend/frontend/src/assets/rocket.png new file mode 100644 index 00000000..a67cddf9 Binary files /dev/null and b/frontend/frontend/src/assets/rocket.png differ diff --git a/frontend/frontend/src/assets/tiangolo_repos.png b/frontend/frontend/src/assets/tiangolo_repos.png new file mode 100644 index 00000000..74894a40 Binary files /dev/null and b/frontend/frontend/src/assets/tiangolo_repos.png differ diff --git a/frontend/frontend/src/assets/wrapped1.png b/frontend/frontend/src/assets/wrapped1.png new file mode 100644 index 00000000..f24a1900 Binary files /dev/null and b/frontend/frontend/src/assets/wrapped1.png differ diff --git a/frontend/frontend/src/assets/wrapped2.png b/frontend/frontend/src/assets/wrapped2.png new file mode 100644 index 00000000..80eb9f94 Binary files /dev/null and b/frontend/frontend/src/assets/wrapped2.png differ diff --git a/frontend/frontend/src/assets/wrapped3.png b/frontend/frontend/src/assets/wrapped3.png new file mode 100644 index 00000000..1fe18597 Binary files /dev/null and b/frontend/frontend/src/assets/wrapped3.png differ diff --git a/frontend/frontend/src/components/Card/Card.js b/frontend/frontend/src/components/Card/Card.js new file mode 100644 index 00000000..7d635018 --- /dev/null +++ b/frontend/frontend/src/components/Card/Card.js @@ -0,0 +1,62 @@ +import React from 'react'; +import { useSelector } from 'react-redux'; +import PropTypes from 'prop-types'; + +import { BACKEND_URL } from '../../constants'; + +import SVG from './SVG'; +import { classnames } from '../../utils'; + +export const Image = ({ imageSrc, compact }) => { + const userId = useSelector((state) => state.user.userId); + const fullImageSrc = `${BACKEND_URL}/user/svg/${userId}/${imageSrc}`; + + return ( +
+ +
+ ); +}; + +Image.propTypes = { + imageSrc: PropTypes.string.isRequired, + compact: PropTypes.bool, +}; + +Image.defaultProps = { + compact: false, +}; + +export const Card = ({ title, description, imageSrc, selected, compact }) => { + return ( +
+

{title}

+

{description}

+ +
+ ); +}; + +Card.propTypes = { + title: PropTypes.string.isRequired, + description: PropTypes.string.isRequired, + imageSrc: PropTypes.string.isRequired, + selected: PropTypes.bool, + compact: PropTypes.bool, +}; + +Card.defaultProps = { + selected: false, + compact: false, +}; diff --git a/frontend/frontend/src/components/Card/SVG.js b/frontend/frontend/src/components/Card/SVG.js new file mode 100644 index 00000000..6ac60aea --- /dev/null +++ b/frontend/frontend/src/components/Card/SVG.js @@ -0,0 +1,71 @@ +/* eslint-disable react/jsx-props-no-spreading */ +/* eslint-disable react/no-danger */ + +import React, { useEffect, useState } from 'react'; +import PropTypes from 'prop-types'; + +import Skeleton from 'react-loading-skeleton'; +import 'react-loading-skeleton/dist/skeleton.css'; + +const SvgInline = (props) => { + const [svg, setSvg] = useState(null); + + // eslint-disable-next-line no-unused-vars + const [loaded, setLoaded] = useState(false); + + let url = `${props.url.split('?')[0]}?cache=${Date.now()}`; + if (props.url.split('?').length > 1) { + url += `&${props.url.split('?')[1]}`; + } + + useEffect(() => { + setLoaded(false); + fetch(url) + .then((res) => res.text()) + .then(setSvg) + .then(() => setLoaded(true)) + .catch((e) => console.error(e)); + }, [props.url]); + + if (props.forceLoading || !loaded) { + if (props.compact) { + return ; + } + return ; + } + + if (props.compact) { + return ( +
${svg}`, + }} + /> + ); + } + + return ( +
${svg}`, + }} + /> + ); +}; + +SvgInline.propTypes = { + className: PropTypes.any, + url: PropTypes.string.isRequired, + forceLoading: PropTypes.bool, + compact: PropTypes.bool, +}; + +SvgInline.defaultProps = { + className: '', + forceLoading: false, + compact: false, +}; + +export default SvgInline; diff --git a/frontend/frontend/src/components/Card/index.js b/frontend/frontend/src/components/Card/index.js new file mode 100644 index 00000000..5e8bad36 --- /dev/null +++ b/frontend/frontend/src/components/Card/index.js @@ -0,0 +1,4 @@ +import SvgInline from './SVG'; +import { Card, Image } from './Card'; + +export { Card, Image, SvgInline }; diff --git a/frontend/frontend/src/components/Generic/Button.js b/frontend/frontend/src/components/Generic/Button.js new file mode 100644 index 00000000..d6f18202 --- /dev/null +++ b/frontend/frontend/src/components/Generic/Button.js @@ -0,0 +1,31 @@ +/* eslint-disable react/jsx-props-no-spreading */ +import React from 'react'; +import PropTypes from 'prop-types'; + +import { classnames } from '../../utils'; + +const Button = (props) => { + return ( + + ); +}; + +Button.propTypes = { + className: PropTypes.string, + children: PropTypes.node.isRequired, +}; + +Button.defaultProps = { + className: '', +}; + +export default Button; diff --git a/frontend/frontend/src/components/Generic/Checkbox.js b/frontend/frontend/src/components/Generic/Checkbox.js new file mode 100644 index 00000000..0e9a114c --- /dev/null +++ b/frontend/frontend/src/components/Generic/Checkbox.js @@ -0,0 +1,37 @@ +/* eslint-disable jsx-a11y/interactive-supports-focus */ +/* eslint-disable jsx-a11y/click-events-have-key-events */ + +import React from 'react'; +import PropTypes from 'prop-types'; + +const Checkbox = ({ question, variable, setVariable, disabled }) => { + return ( +
setVariable(!variable)} + role="button" + > + setVariable(!variable)} + /> + {question} +
+ ); +}; + +Checkbox.propTypes = { + question: PropTypes.string.isRequired, + variable: PropTypes.bool.isRequired, + setVariable: PropTypes.func.isRequired, + disabled: PropTypes.bool, +}; + +Checkbox.defaultProps = { + disabled: false, +}; + +export default Checkbox; diff --git a/frontend/frontend/src/components/Generic/Input.js b/frontend/frontend/src/components/Generic/Input.js new file mode 100644 index 00000000..14948ed2 --- /dev/null +++ b/frontend/frontend/src/components/Generic/Input.js @@ -0,0 +1,65 @@ +import React from 'react'; +import PropTypes from 'prop-types'; + +import { classnames } from '../../utils'; + +// options is of form [{value: '', label: '', disabled: true/false}] +const Input = ({ + options, + selectedOption, + setSelectedOption, + disabled, + className, +}) => { + return ( + + ); +}; + +Input.propTypes = { + options: PropTypes.arrayOf( + PropTypes.shape({ + value: PropTypes.string.isRequired, + label: PropTypes.string.isRequired, + disabled: PropTypes.bool, + }), + ).isRequired, + selectedOption: PropTypes.shape({ + value: PropTypes.string.isRequired, + label: PropTypes.string.isRequired, + }).isRequired, + setSelectedOption: PropTypes.func.isRequired, + disabled: PropTypes.bool, + className: PropTypes.string, +}; + +Input.defaultProps = { + disabled: false, + className: '', +}; + +export default Input; diff --git a/frontend/frontend/src/components/Generic/index.js b/frontend/frontend/src/components/Generic/index.js new file mode 100644 index 00000000..71ec56e1 --- /dev/null +++ b/frontend/frontend/src/components/Generic/index.js @@ -0,0 +1,5 @@ +import Button from './Button'; +import Checkbox from './Checkbox'; +import Input from './Input'; + +export { Button, Checkbox, Input }; diff --git a/frontend/frontend/src/components/Home/CheckboxSection.js b/frontend/frontend/src/components/Home/CheckboxSection.js new file mode 100644 index 00000000..f5ac1a21 --- /dev/null +++ b/frontend/frontend/src/components/Home/CheckboxSection.js @@ -0,0 +1,44 @@ +/* eslint-disable jsx-a11y/interactive-supports-focus */ +/* eslint-disable jsx-a11y/click-events-have-key-events */ + +import React from 'react'; +import PropTypes from 'prop-types'; + +import Section from './Section'; +import { Checkbox } from '../Generic'; + +const CheckboxSection = ({ + title, + text, + question, + variable, + setVariable, + disabled, +}) => { + return ( +
+

{text}

+ +
+ ); +}; + +CheckboxSection.propTypes = { + title: PropTypes.string.isRequired, + text: PropTypes.string.isRequired, + question: PropTypes.string.isRequired, + variable: PropTypes.bool.isRequired, + setVariable: PropTypes.func.isRequired, + disabled: PropTypes.bool, +}; + +CheckboxSection.defaultProps = { + disabled: false, +}; + +export default CheckboxSection; diff --git a/frontend/frontend/src/components/Home/DateRangeSection.js b/frontend/frontend/src/components/Home/DateRangeSection.js new file mode 100644 index 00000000..79ab0911 --- /dev/null +++ b/frontend/frontend/src/components/Home/DateRangeSection.js @@ -0,0 +1,47 @@ +import React from 'react'; +import PropTypes from 'prop-types'; + +import Section from './Section'; +import { Input } from '../Generic'; + +const DateRangeSection = ({ + selectedTimeRange, + setSelectedTimeRange, + // eslint-disable-next-line no-unused-vars + privateAccess, +}) => { + const timeRangeOptions = [ + { id: 1, label: 'Past 1 Month', disabled: false, value: 'one_month' }, + { + id: 2, + label: 'Past 3 Months', + disabled: false, + value: 'three_months', + }, + { id: 2, label: 'Past 6 Months', disabled: false, value: 'six_months' }, + { id: 3, label: 'Past 1 Year', disabled: false, value: 'one_year' }, + // { id: 4, label: 'All Time', disabled: !privateAccess, value: 'all_time' }, + { id: 4, label: 'All Time', disabled: true, value: 'all_time' }, + ]; + + const selectedOption = selectedTimeRange || timeRangeOptions[2]; + + return ( +
+

Select the date range for statistics.

+ +
+ ); +}; + +DateRangeSection.propTypes = { + selectedTimeRange: PropTypes.object.isRequired, + setSelectedTimeRange: PropTypes.func.isRequired, + privateAccess: PropTypes.bool.isRequired, +}; + +export default DateRangeSection; diff --git a/frontend/frontend/src/components/Home/Progress.js b/frontend/frontend/src/components/Home/Progress.js new file mode 100644 index 00000000..6bcf51ff --- /dev/null +++ b/frontend/frontend/src/components/Home/Progress.js @@ -0,0 +1,91 @@ +/* eslint-disable react/no-array-index-key */ +import React from 'react'; +import PropTypes from 'prop-types'; + +import { + FaArrowLeft as LeftArrowIcon, + FaArrowRight as RightArrowIcon, +} from 'react-icons/fa'; + +import { classnames } from '../../utils'; + +const ProgressSection = ({ num, item, passed, onClick }) => { + return ( + + ); +}; + +ProgressSection.propTypes = { + num: PropTypes.number.isRequired, + item: PropTypes.string.isRequired, + passed: PropTypes.bool.isRequired, + onClick: PropTypes.func.isRequired, +}; + +const ProgressBar = ({ items, currItem, setCurrItem }) => { + const leftDisabled = currItem === 0; + const rightDisabled = currItem === items.length - 1; + + return ( +
+ setCurrItem(currItem - 1)} + /> +
+ {items.map((item, index) => { + return ( + = index} + onClick={() => setCurrItem(index)} + /> + ); + })} +
+ setCurrItem(currItem + 1)} + /> +
+ ); +}; + +ProgressBar.propTypes = { + items: PropTypes.array.isRequired, + currItem: PropTypes.number.isRequired, + setCurrItem: PropTypes.func.isRequired, +}; + +export default ProgressBar; diff --git a/frontend/frontend/src/components/Home/Section.js b/frontend/frontend/src/components/Home/Section.js new file mode 100644 index 00000000..f801609a --- /dev/null +++ b/frontend/frontend/src/components/Home/Section.js @@ -0,0 +1,36 @@ +import React from 'react'; +import PropTypes from 'prop-types'; + +import { HiOutlineLightningBolt as LightningIcon } from 'react-icons/hi'; + +const Section = (props) => { + return ( +
+
+
+
+
+ +
+ +
+

+ {props.title} +

+ {props.children} +
+
+ ); +}; + +Section.propTypes = { + title: PropTypes.string, + children: PropTypes.node, +}; + +Section.defaultProps = { + title: 'Test', + children:

This is a test!

, +}; + +export default Section; diff --git a/frontend/frontend/src/components/Home/index.js b/frontend/frontend/src/components/Home/index.js new file mode 100644 index 00000000..dbb0b441 --- /dev/null +++ b/frontend/frontend/src/components/Home/index.js @@ -0,0 +1,5 @@ +import ProgressBar from './Progress'; +import CheckboxSection from './CheckboxSection'; +import DateRangeSection from './DateRangeSection'; + +export { ProgressBar, CheckboxSection, DateRangeSection }; diff --git a/frontend/frontend/src/components/Preview/Preview.js b/frontend/frontend/src/components/Preview/Preview.js new file mode 100644 index 00000000..24425f35 --- /dev/null +++ b/frontend/frontend/src/components/Preview/Preview.js @@ -0,0 +1,73 @@ +import React, { useState, useEffect } from 'react'; +import PropTypes from 'prop-types'; + +import { + FaArrowRight as ArrowRightIcon, + FaArrowLeft as ArrowLeftIcon, +} from 'react-icons/fa'; + +import { classnames } from '../../utils'; + +const Preview = ({ pages, details, showArrows }) => { + const totalPages = pages.length; + const [page, setPage] = useState(0); + + const prevPage = () => { + setPage((page - 1 + totalPages) % totalPages); + }; + + const nextPage = () => { + setPage((page + 1 + totalPages) % totalPages); + }; + + useEffect(() => { + const interval = setInterval(nextPage, 5000); + return () => clearInterval(interval); + }, [page]); + + return ( +
+
+
+ {showArrows && ( + + )} + preview + {showArrows && ( + + )} +
+
+

+ {details[page]} +

+
+ ); +}; + +Preview.propTypes = { + pages: PropTypes.arrayOf(PropTypes.any).isRequired, + details: PropTypes.arrayOf(PropTypes.string).isRequired, + showArrows: PropTypes.bool, +}; + +Preview.defaultProps = { + showArrows: true, +}; + +export default Preview; diff --git a/frontend/frontend/src/components/Preview/index.js b/frontend/frontend/src/components/Preview/index.js new file mode 100644 index 00000000..07345507 --- /dev/null +++ b/frontend/frontend/src/components/Preview/index.js @@ -0,0 +1,3 @@ +import Preview from './Preview'; + +export default Preview; diff --git a/frontend/frontend/src/components/Wrapped/Organization.js b/frontend/frontend/src/components/Wrapped/Organization.js new file mode 100644 index 00000000..481d9065 --- /dev/null +++ b/frontend/frontend/src/components/Wrapped/Organization.js @@ -0,0 +1,61 @@ +/* eslint-disable jsx-a11y/mouse-events-have-key-events */ + +import React from 'react'; +import PropTypes from 'prop-types'; +import { classnames } from '../../utils'; + +const WrappedSection = (props) => { + return ( +
+ {props.useTitle && ( +

{props.title}

+ )} + {props.children} +
+ ); +}; + +WrappedSection.propTypes = { + useTitle: PropTypes.bool, + title: PropTypes.string, + children: PropTypes.node.isRequired, +}; + +WrappedSection.defaultProps = { + useTitle: true, + title: '', +}; + +const WrappedCard = (props) => { + return ( +
+
+ {props.children} +
+
+ ); +}; + +WrappedCard.propTypes = { + children: PropTypes.node.isRequired, + className: PropTypes.string, + onMouseOver: PropTypes.func, + onMouseOut: PropTypes.func, +}; + +WrappedCard.defaultProps = { + className: '', + onMouseOver: () => {}, + onMouseOut: () => {}, +}; + +export { WrappedSection, WrappedCard }; diff --git a/frontend/frontend/src/components/Wrapped/Specifics/Bar.js b/frontend/frontend/src/components/Wrapped/Specifics/Bar.js new file mode 100644 index 00000000..0f25ac7e --- /dev/null +++ b/frontend/frontend/src/components/Wrapped/Specifics/Bar.js @@ -0,0 +1,145 @@ +import React, { useState } from 'react'; +import PropTypes from 'prop-types'; + +import { WrappedCard } from '../Organization'; +import { BarGraph } from '../Templates'; + +const monthNames = [ + 'Jan', + 'Feb', + 'March', + 'April', + 'May', + 'June', + 'July', + 'Aug', + 'Sep', + 'Oct', + 'Nov', + 'Dec', +]; + +const dayNames = [ + 'Sunday', + 'Monday', + 'Tuesday', + 'Wednesday', + 'Thursday', + 'Friday', + 'Saturday', +]; + +const BarMonth = ({ data, downloadLoading }) => { + const newData = data?.month_data?.months || []; + + // eslint-disable-next-line no-unused-vars + const [displayContribs, setDisplayContribs] = useState(true); + + return ( +
+ +
+
+

Contributions by Month

+

+ {displayContribs ? 'By Contribution Count' : 'By LOC Changed'} +

+
+ {!downloadLoading && ( +
+ +
+ )} +
+ {displayContribs ? ( + d.contribs} + legendText="Contributions" + /> + ) : ( + d.formatted_loc_changed.split(' ')[0]} + legendText="LOC Changed" + /> + )} +
+
+ ); +}; + +BarMonth.propTypes = { + data: PropTypes.object.isRequired, + downloadLoading: PropTypes.bool.isRequired, +}; + +const BarDay = ({ data, downloadLoading }) => { + const newData = data?.day_data?.days || []; + + const [displayContribs, setDisplayContribs] = useState(true); + + return ( +
+ +
+
+

Contributions by Day

+

+ {displayContribs ? 'By Contribution Count' : 'By LOC Changed'} +

+
+ {!downloadLoading && ( +
+ +
+ )} +
+ {displayContribs ? ( + d.contribs} + legendText="Contributions" + /> + ) : ( + d.formatted_loc_changed.split(' ')[0]} + legendText="LOC Changed" + /> + )} +
+
+ ); +}; + +BarDay.propTypes = { + data: PropTypes.object.isRequired, + downloadLoading: PropTypes.bool.isRequired, +}; + +export { BarMonth, BarDay }; diff --git a/frontend/frontend/src/components/Wrapped/Specifics/Calendar.js b/frontend/frontend/src/components/Wrapped/Specifics/Calendar.js new file mode 100644 index 00000000..babe1874 --- /dev/null +++ b/frontend/frontend/src/components/Wrapped/Specifics/Calendar.js @@ -0,0 +1,125 @@ +import React from 'react'; +import PropTypes from 'prop-types'; + +import { ResponsiveCalendar } from '@nivo/calendar'; + +import { Input } from '../../Generic'; +import { theme, scale } from '../Templates/theme'; +import { WrappedCard } from '../Organization'; + +const Calendar = ({ + data, + startDate, + endDate, + highlightDays, + highlightColors, + downloadLoading, +}) => { + const newData = data?.calendar_data?.days || []; + + const valueOptions = [ + { value: 'contribs', label: 'Contributions', disabled: false }, + { value: 'commits', label: 'Commits', disabled: false }, + { value: 'issues', label: 'Issues', disabled: false }, + { value: 'prs', label: 'Pull Requests', disabled: false }, + { value: 'reviews', label: 'Reviews', disabled: false }, + ]; + + const [value, setValue] = React.useState(valueOptions[0]); + + const numEvents = Array.isArray(newData) + ? newData.reduce((acc, x) => acc + x[value.value], 0) + : 0; + + let c = 0; + const max = Math.max(...newData.map((x) => x[value.value])); + const quantiles = [ + Math.floor(max * 0.25), + Math.floor(max * 0.5), + Math.floor(max * 0.75), + max, + ]; + + const colorScaleFn = (x) => { + const count = (c % 365) + 1; + c += 1; + + const myColorScale = highlightDays.includes(count) + ? highlightColors + : scale; + + if (x === 0) { + return myColorScale[0]; + } + if (x <= quantiles[0]) { + return myColorScale[1]; + } + if (x <= quantiles[1]) { + return myColorScale[2]; + } + if (x <= quantiles[2]) { + return myColorScale[3]; + } + return myColorScale[4]; + }; + + return ( +
+ +
+

+ Contribution Calendar +

+ {!downloadLoading && ( + + )} +
+
+

{`${numEvents} ${value.label}`}

+ {Array.isArray(newData) && newData.length > 0 ? ( + ({ + day: item.day, + value: item[value.value], + }))} + from={startDate} + to={endDate} + emptyColor="#EBEDF0" + colors={['#9BE9A8', '#40C463', '#30A14E', '#216E39']} + margin={{ top: 10, right: 0, bottom: 0, left: 0 }} + monthBorderColor="#ffffff" + dayBorderWidth={2} + dayBorderColor="#ffffff" + colorScale={colorScaleFn} + /> + ) : ( +
+ No data to show +
+ )} +
+
+
+ ); +}; + +Calendar.propTypes = { + data: PropTypes.object.isRequired, + startDate: PropTypes.string.isRequired, + endDate: PropTypes.string.isRequired, + highlightDays: PropTypes.arrayOf(PropTypes.number), + highlightColors: PropTypes.arrayOf(PropTypes.string).isRequired, + downloadLoading: PropTypes.bool.isRequired, +}; + +Calendar.defaultProps = { + highlightDays: [], +}; + +export default Calendar; diff --git a/frontend/frontend/src/components/Wrapped/Specifics/Numeric.js b/frontend/frontend/src/components/Wrapped/Specifics/Numeric.js new file mode 100644 index 00000000..f7cb30b9 --- /dev/null +++ b/frontend/frontend/src/components/Wrapped/Specifics/Numeric.js @@ -0,0 +1,109 @@ +import React from 'react'; +import PropTypes from 'prop-types'; + +import { WrappedCard } from '../Organization'; + +const numericPropTypes = { + num: PropTypes.any, + label: PropTypes.string.isRequired, +}; + +const numericDefaultProps = { + num: 'N/A', +}; + +const NumericPlusLOC = ({ num, label }) => { + return ( + +

{`+${num}`}

+

{label}

+
+ ); +}; + +NumericPlusLOC.propTypes = numericPropTypes; +NumericPlusLOC.defaultProps = numericDefaultProps; + +const NumericMinusLOC = ({ num, label }) => { + return ( + +

{`-${num}`}

+

{label}

+
+ ); +}; + +NumericMinusLOC.propTypes = numericPropTypes; +NumericMinusLOC.defaultProps = numericDefaultProps; + +const NumericBothLOC = ({ num1, num2, label }) => { + return ( + +
+

+ {num1} +

+

/

+

+ {num2} +

+
+

{label}

+
+ ); +}; + +NumericBothLOC.propTypes = { + num1: PropTypes.any, + num2: PropTypes.any, + label: PropTypes.string.isRequired, +}; + +NumericBothLOC.defaultProps = { + num1: 'N/A', + num2: 'N/A', +}; + +const NumericBestDay = ({ + num, + date, + label, + className, + onMouseOver, + onMouseOut, +}) => { + return ( + +
+

+ {num} Contributions +

+

+ on {date} +

+
+

{label}

+
+ ); +}; + +NumericBestDay.propTypes = { + num: PropTypes.number.isRequired, + date: PropTypes.string.isRequired, + label: PropTypes.string.isRequired, + className: PropTypes.string, + onMouseOver: PropTypes.func, + onMouseOut: PropTypes.func, +}; + +NumericBestDay.defaultProps = { + className: '', + onMouseOver: () => {}, + onMouseOut: () => {}, +}; + +export { NumericPlusLOC, NumericMinusLOC, NumericBothLOC, NumericBestDay }; diff --git a/frontend/frontend/src/components/Wrapped/Specifics/Pie.js b/frontend/frontend/src/components/Wrapped/Specifics/Pie.js new file mode 100644 index 00000000..cd727abb --- /dev/null +++ b/frontend/frontend/src/components/Wrapped/Specifics/Pie.js @@ -0,0 +1,98 @@ +/* eslint-disable react/jsx-curly-newline */ + +import React from 'react'; +import PropTypes from 'prop-types'; + +import { PieChart } from '../Templates'; +import { WrappedCard } from '../Organization'; + +const PieLangs = ({ data, downloadLoading }) => { + const [useLOCAdded, setUseLOCAdded] = React.useState(false); + + const metric = useLOCAdded ? 'added' : 'changed'; + const newData = data?.lang_data?.[`langs_${metric}`] || []; + + return ( +
+ +
+
+

Most Used Languages

+

{useLOCAdded ? 'By LOC Added' : 'By LOC Changed'}

+
+ {!downloadLoading && ( +
+ +
+ )} +
+ e.data.label} + getFormattedValue={(e) => e.formatted_value} + colors={{ datum: 'data.color' }} + /> +
+
+ ); +}; + +PieLangs.propTypes = { + data: PropTypes.object.isRequired, + downloadLoading: PropTypes.bool.isRequired, +}; + +const PieRepos = ({ data, downloadLoading }) => { + const [useLOCAdded, setUseLOCAdded] = React.useState(false); + + const metric = useLOCAdded ? 'added' : 'changed'; + const newData = data?.repo_data?.[`repos_${metric}`] || []; + + return ( +
+ +
+
+

Most Active Repositories

+

{useLOCAdded ? 'By LOC Added' : 'By LOC Changed'}

+
+ {!downloadLoading && ( +
+ +
+ )} +
+ { + if (label && label.includes('/')) { + return label.split('/')[1].replace('repository', 'private'); + } + return label; + }} + getFormattedValue={(e) => e.formatted_value} + colors={{ scheme: 'category10' }} + /> +
+
+ ); +}; + +PieRepos.propTypes = { + data: PropTypes.object.isRequired, + downloadLoading: PropTypes.bool.isRequired, +}; + +export { PieLangs, PieRepos }; diff --git a/frontend/frontend/src/components/Wrapped/Specifics/Radar.js b/frontend/frontend/src/components/Wrapped/Specifics/Radar.js new file mode 100644 index 00000000..5181bdbb --- /dev/null +++ b/frontend/frontend/src/components/Wrapped/Specifics/Radar.js @@ -0,0 +1,59 @@ +import React from 'react'; +import PropTypes from 'prop-types'; + +import { ResponsiveRadar } from '@nivo/radar'; + +import { WrappedCard } from '../Organization'; + +// eslint-disable-next-line no-unused-vars +const Radar = ({ data }) => { + const commits = data?.numeric_data?.contribs?.commits || 0; + const issues = data?.numeric_data?.contribs?.issues || 0; + const prs = data?.numeric_data?.contribs?.prs || 0; + const reviews = data?.numeric_data?.contribs?.reviews || 0; + + const tempData = [ + { + name: 'Commits', + count: Math.log(1 + commits), + }, + { + name: 'Issues', + count: Math.log(1 + issues), + }, + { + name: 'Pull Requests', + count: Math.log(1 + prs), + }, + { + name: 'Reviews', + count: Math.log(1 + reviews), + }, + ]; + + return ( +
+ +

Contributions by Type

+

Log Scale

+ Math.round(Math.exp(d) - 1)} + margin={{ top: 30, right: 50, bottom: 30, left: 60 }} + dotSize={10} + colors={{ scheme: 'category10' }} + blendMode="multiply" + motionConfig="wobbly" + /> +
+
+ ); +}; + +Radar.propTypes = { + data: PropTypes.object.isRequired, +}; + +export default Radar; diff --git a/frontend/frontend/src/components/Wrapped/Specifics/Swarm.js b/frontend/frontend/src/components/Wrapped/Specifics/Swarm.js new file mode 100644 index 00000000..8979419c --- /dev/null +++ b/frontend/frontend/src/components/Wrapped/Specifics/Swarm.js @@ -0,0 +1,49 @@ +import React from 'react'; +import PropTypes from 'prop-types'; + +import { SwarmPlot } from '../Templates'; + +const formatYAxis = (value) => { + if (value === 3600 * 12) { + return 'Noon'; + } + if (value === 3600 * 24) { + return 'Midnight'; + } + let hours = Math.floor(value / 3600); + const suffix = hours % 24 >= 12 ? 'PM' : 'AM'; + hours = hours % 12 === 0 ? 12 : hours % 12; + const minutes = String(Math.floor((value % 3600) / 60 / 10) * 10); + const displayHour = String(hours).padStart(2, '0'); + const displayMinute = String(minutes).padStart(2, '0'); + return `${displayHour}:${displayMinute} ${suffix}`; +}; + +const SwarmDay = ({ data }) => { + let newData = data?.timestamp_data?.contribs || []; + newData = newData.map((d, i) => { + return { + ...d, + groupById: 0, + id: i, + }; + }); + + return ( + ''} + formatYAxis={formatYAxis} + /> + ); +}; + +SwarmDay.propTypes = { + data: PropTypes.object.isRequired, +}; + +export { SwarmDay }; diff --git a/frontend/frontend/src/components/Wrapped/Specifics/index.js b/frontend/frontend/src/components/Wrapped/Specifics/index.js new file mode 100644 index 00000000..ce3a2a99 --- /dev/null +++ b/frontend/frontend/src/components/Wrapped/Specifics/index.js @@ -0,0 +1,7 @@ +import Calendar from './Calendar'; + +export * from './Bar'; +export * from './Numeric'; +export * from './Pie'; +export * from './Swarm'; +export { Calendar }; diff --git a/frontend/frontend/src/components/Wrapped/Templates/Bar.js b/frontend/frontend/src/components/Wrapped/Templates/Bar.js new file mode 100644 index 00000000..0f0abb1e --- /dev/null +++ b/frontend/frontend/src/components/Wrapped/Templates/Bar.js @@ -0,0 +1,80 @@ +/* eslint-disable react/jsx-curly-newline */ + +import React from 'react'; +import PropTypes from 'prop-types'; + +import { ResponsiveBar } from '@nivo/bar'; + +import { theme } from './theme'; + +const BarGraph = ({ data, labels, xTitle, type, getLabel, legendText }) => { + const maxData = Math.max(...data.map((d) => d[type])); + const minData = Math.min( + ...data.filter((d) => d.index < 11).map((d) => d[type]), + ); + + const getColor = (d) => { + // eslint-disable-next-line no-nested-ternary + return d.value === maxData + ? '#2BA02C' + : d.value === minData + ? '#D62728' + : '#468CBF'; + }; + + if (!(Array.isArray(data) && data.length > 0)) { + return ( +
+ No data to show +
+ ); + } + + return ( + labels[value], + }} + axisLeft={{ + tickSize: 5, + tickPadding: 5, + tickRotation: 0, + legend: legendText, + legendPosition: 'middle', + legendOffset: -60, + }} + label={(d) => getLabel(d.data)} + labelSkipWidth={12} + labelSkipHeight={12} + labelTextColor="#fff" + tooltip={() => null} + /> + ); +}; + +BarGraph.propTypes = { + data: PropTypes.array.isRequired, + labels: PropTypes.array.isRequired, + xTitle: PropTypes.string.isRequired, + type: PropTypes.string.isRequired, + getLabel: PropTypes.func.isRequired, + legendText: PropTypes.string.isRequired, +}; + +export default BarGraph; diff --git a/frontend/frontend/src/components/Wrapped/Templates/Numeric.js b/frontend/frontend/src/components/Wrapped/Templates/Numeric.js new file mode 100644 index 00000000..0ac5a504 --- /dev/null +++ b/frontend/frontend/src/components/Wrapped/Templates/Numeric.js @@ -0,0 +1,104 @@ +/* eslint-disable jsx-a11y/mouse-events-have-key-events */ + +import React from 'react'; +import PropTypes from 'prop-types'; + +import { ResponsivePie } from '@nivo/pie'; + +import { WrappedCard } from '../Organization'; + +const Numeric = ({ num, label }) => { + return ( + +

+ {num || 'N/A'} +

+

{label}

+
+ ); +}; + +Numeric.propTypes = { + num: PropTypes.any, + label: PropTypes.string.isRequired, +}; + +Numeric.defaultProps = { + num: 'N/A', +}; + +const NumericOutOf = ({ + num, + outOf, + format, + label, + color, + className, + onMouseOver, + onMouseOut, +}) => { + // eslint-disable-next-line react/prop-types + const CenteredMetric = ({ dataWithArc, centerX, centerY }) => { + let total = 0; + // eslint-disable-next-line react/prop-types + dataWithArc.forEach((datum) => { + total += datum.id === '1' ? datum.value : 0; + }); + + return ( + + {format(total)} + + ); + }; + return ( + +
+ null} + /> +
+

{label}

+
+ ); +}; + +NumericOutOf.propTypes = { + num: PropTypes.number.isRequired, + outOf: PropTypes.number.isRequired, + format: PropTypes.func, + label: PropTypes.string.isRequired, + color: PropTypes.string, + className: PropTypes.string, + onMouseOver: PropTypes.func, + onMouseOut: PropTypes.func, +}; + +NumericOutOf.defaultProps = { + format: (x) => x, + color: '#30A14E', + className: '', + onMouseOver: () => {}, + onMouseOut: () => {}, +}; + +export { Numeric, NumericOutOf }; diff --git a/frontend/frontend/src/components/Wrapped/Templates/Pie.js b/frontend/frontend/src/components/Wrapped/Templates/Pie.js new file mode 100644 index 00000000..b6d89193 --- /dev/null +++ b/frontend/frontend/src/components/Wrapped/Templates/Pie.js @@ -0,0 +1,68 @@ +/* eslint-disable react/jsx-curly-newline */ + +import React from 'react'; +import PropTypes from 'prop-types'; + +import { ResponsivePie } from '@nivo/pie'; + +import { theme } from './theme'; + +const PieChart = ({ data, getArcLinkLabel, getFormattedValue, colors }) => { + if (!(Array.isArray(data) && data.length > 0)) { + return ( +
+ No data to show +
+ ); + } + return ( + getArcLinkLabel(e)} + arcLinkLabelsSkipAngle={45} + arcLinkLabelsTextOffset={0} + arcLinkLabelsTextColor={{ from: 'color' }} + arcLinkLabelsDiagonalLength={5} + arcLinkLabelsStraightLength={5} + arcLinkLabelsThickness={0} + // Arc Label Settings + arcLabel={(e) => getFormattedValue(e.data)} + arcLabelsSkipAngle={45} + arcLabelsTextColor="#fff" + // Tooltip + tooltip={({ datum }) => ( +
+ {datum.label} + {`: ${getFormattedValue(datum.data)}`} +
+ )} + colors={colors} + /> + ); +}; + +PieChart.propTypes = { + data: PropTypes.array.isRequired, + getArcLinkLabel: PropTypes.func.isRequired, + getFormattedValue: PropTypes.func.isRequired, + colors: PropTypes.any.isRequired, +}; + +export default PieChart; diff --git a/frontend/frontend/src/components/Wrapped/Templates/Swarm.js b/frontend/frontend/src/components/Wrapped/Templates/Swarm.js new file mode 100644 index 00000000..7ce57318 --- /dev/null +++ b/frontend/frontend/src/components/Wrapped/Templates/Swarm.js @@ -0,0 +1,94 @@ +/* eslint-disable react/prop-types */ +/* eslint-disable react/jsx-curly-newline */ + +import React from 'react'; +import PropTypes from 'prop-types'; + +import { ResponsiveSwarmPlot } from '@nivo/swarmplot'; + +import { theme } from './theme'; +import { WrappedCard } from '../Organization'; + +const MemoizedResponsiveSwarmPlot = React.memo( + ResponsiveSwarmPlot, + (prevProps, nextProps) => prevProps.data?.length === nextProps.data?.length, +); + +const SwarmPlot = ({ + header, + data, + groupBy, + groups, + legend, + formatXAxis, + formatYAxis, +}) => { + const tickValues = [0, 1, 2, 3, 4, 5, 6, 7, 8].map((i) => 10800 * i); + + return ( +
+ +

{header}

+

{`${data.length} Sampled Contributions, Eastern Time`}

+ {Array.isArray(data) && data.length > 0 ? ( + formatXAxis(value), + }} + axisLeft={{ + orient: 'left', + tickSize: 10, + tickPadding: 5, + tickRotation: 0, + legend: 'Time of Day', + legendPosition: 'middle', + legendOffset: -86, + tickValues, + format: (value) => formatYAxis(value), + }} + /> + ) : ( +
+ No data to show +
+ )} +
+
+ ); +}; + +SwarmPlot.propTypes = { + header: PropTypes.string.isRequired, + data: PropTypes.array.isRequired, + groupBy: PropTypes.string.isRequired, + groups: PropTypes.array.isRequired, + legend: PropTypes.string.isRequired, + formatXAxis: PropTypes.func.isRequired, + formatYAxis: PropTypes.func.isRequired, +}; + +export default SwarmPlot; diff --git a/frontend/frontend/src/components/Wrapped/Templates/index.js b/frontend/frontend/src/components/Wrapped/Templates/index.js new file mode 100644 index 00000000..7c2f9ac7 --- /dev/null +++ b/frontend/frontend/src/components/Wrapped/Templates/index.js @@ -0,0 +1,7 @@ +import BarGraph from './Bar'; +import PieChart from './Pie'; +import SwarmPlot from './Swarm'; + +export * from './Numeric'; +export * from './theme'; +export { BarGraph, PieChart, SwarmPlot }; diff --git a/frontend/frontend/src/components/Wrapped/Templates/theme.js b/frontend/frontend/src/components/Wrapped/Templates/theme.js new file mode 100644 index 00000000..dd542730 --- /dev/null +++ b/frontend/frontend/src/components/Wrapped/Templates/theme.js @@ -0,0 +1,20 @@ +export const theme = { + fontSize: '12px', + fontFamily: 'Segoe UI', +}; + +export const scale = ['#EBEDF0', '#9BE9A8', '#40C463', '#30A14E', '#216E39']; +export const hoverScale = [ + '#A6C9F5', + '#7EC7D1', + '#50B5AF', + '#48A3A4', + '#418A9A', +]; +export const singleHoverScale = [ + '#468CBF', + '#468CBF', + '#468CBF', + '#468CBF', + '#468CBF', +]; diff --git a/frontend/frontend/src/components/Wrapped/index.js b/frontend/frontend/src/components/Wrapped/index.js new file mode 100644 index 00000000..7ce0d018 --- /dev/null +++ b/frontend/frontend/src/components/Wrapped/index.js @@ -0,0 +1,3 @@ +export * from './Organization'; +export * from './Templates'; +export * from './Specifics'; diff --git a/frontend/frontend/src/components/index.js b/frontend/frontend/src/components/index.js new file mode 100644 index 00000000..5eb6a0a8 --- /dev/null +++ b/frontend/frontend/src/components/index.js @@ -0,0 +1,8 @@ +import Preview from './Preview'; + +export * from './Generic'; +export * from './Card'; +export * from './Home'; +export * from './Wrapped'; + +export { Preview }; diff --git a/frontend/frontend/src/constants.js b/frontend/frontend/src/constants.js new file mode 100644 index 00000000..f690c98a --- /dev/null +++ b/frontend/frontend/src/constants.js @@ -0,0 +1,31 @@ +/* eslint-disable no-nested-ternary */ +export const PROD = process.env.REACT_APP_PROD === 'true'; + +export const USE_LOGGER = true; + +export const CLIENT_ID = PROD + ? process.env.REACT_APP_PROD_CLIENT_ID + : process.env.REACT_APP_DEV_CLIENT_ID; + +export const MODE = process.env.REACT_APP_MODE; + +export const REDIRECT_URI = PROD + ? MODE === 'trends' + ? 'https://www.githubtrends.io/user' + : 'https://www.githubtrends.io/user/wrapped' + : MODE === 'trends' + ? 'http://localhost:3000/user' + : 'http://localhost:3000/user/wrapped'; + +export const GITHUB_PRIVATE_AUTH_URL = `https://github.com/login/oauth/authorize?scope=user,repo&client_id=${CLIENT_ID}&redirect_uri=${REDIRECT_URI}/private`; +export const GITHUB_PUBLIC_AUTH_URL = `https://github.com/login/oauth/authorize?client_id=${CLIENT_ID}&redirect_uri=${REDIRECT_URI}/public`; + +export const WRAPPED_URL = PROD + ? 'https://www.githubwrapped.io' + : 'http://localhost:3001'; + +export const BACKEND_URL = PROD + ? 'https://api.githubtrends.io' + : 'http://localhost:8000'; + +export const CURR_YEAR = 2024; diff --git a/frontend/frontend/src/index.css b/frontend/frontend/src/index.css new file mode 100644 index 00000000..33b26daf --- /dev/null +++ b/frontend/frontend/src/index.css @@ -0,0 +1,11 @@ +/* ./src/index.css */ +@tailwind base; +@tailwind components; +@tailwind utilities; + +body { + margin: 0; + font-family: 'Segoe UI', Ubuntu, Sans-Serif; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; +} diff --git a/frontend/frontend/src/index.js b/frontend/frontend/src/index.js new file mode 100644 index 00000000..68fafa98 --- /dev/null +++ b/frontend/frontend/src/index.js @@ -0,0 +1,32 @@ +import React from 'react'; +import ReactDOM from 'react-dom/client'; +import { Provider } from 'react-redux'; + +import configureStore from './redux/store'; +import { AppTrends, AppWrapped } from './pages/App'; +import './index.css'; + +import { MODE } from './constants'; + +export const store = configureStore(); + +const root = ReactDOM.createRoot(document.getElementById('root')); + +if (MODE === 'trends') { + root.render( + + + , + ); +} else if (MODE === 'wrapped') { + root.render( + + + , + ); +} else { + // Throw an error if the mode is not set correctly. + throw new Error( + 'REACT_APP_MODE must be set to "trends" or "wrapped" in your .env file.', + ); +} diff --git a/frontend/frontend/src/pages/App/AppTrends.js b/frontend/frontend/src/pages/App/AppTrends.js new file mode 100644 index 00000000..0c8ebc5e --- /dev/null +++ b/frontend/frontend/src/pages/App/AppTrends.js @@ -0,0 +1,109 @@ +/* eslint-disable jsx-a11y/anchor-is-valid */ + +import React, { useEffect } from 'react'; +import { useSelector, useDispatch } from 'react-redux'; + +import { + BrowserRouter as Router, + Routes, + Route, + useParams, +} from 'react-router-dom'; + +import Header from './Header'; +import LandingScreen from '../Landing'; +import DemoScreen from '../Demo'; +import { SignUpScreen } from '../Auth'; +import HomeScreen from '../Home'; +import SettingsScreen from '../Settings'; +import { NoMatchScreen, RedirectScreen } from '../Misc'; + +import { setPrivateAccess as _setPrivateAccess } from '../../redux/actions/userActions'; +import { getUserMetadata } from '../../api'; +import { WRAPPED_URL } from '../../constants'; +import Footer from './Footer'; + +function WrappedAuthRedirectScreen() { + // for wrapped auth redirects + const { rest } = useParams(); + useEffect(() => { + const code = new URLSearchParams(window.location.search).get('code'); + window.location.href = `${WRAPPED_URL}/${rest}?code=${code}`; + }, [rest]); + + return null; +} + +function WrappedRedirectScreen() { + // redirects /wrapped/* to https://www.githubwrapped.com/* + const { userId, year } = useParams(); + useEffect(() => { + if (userId) { + if (year) { + window.location.href = `${WRAPPED_URL}/${userId}/${year}`; + } else { + window.location.href = `${WRAPPED_URL}/${userId}`; + } + } else { + window.location.href = `${WRAPPED_URL}/`; + } + }, [userId, year]); +} + +function App() { + const userId = useSelector((state) => state.user.userId); + const isAuthenticated = userId && userId.length > 0; + + const dispatch = useDispatch(); + const setPrivateAccess = (access) => dispatch(_setPrivateAccess(access)); + + useEffect(() => { + async function getPrivateAccess() { + if (userId && userId.length > 0) { + const result = await getUserMetadata(userId); + if (result !== null && result.private_access !== undefined) { + setPrivateAccess(result.private_access); + } + } + } + getPrivateAccess(); + }, [userId]); + + return ( +
+ +
+
+ + {!isAuthenticated && ( + } /> + )} + } /> + } /> + } + /> + } /> + } + /> + } + /> + } /> + } /> + } /> + } /> + } /> + +
+
+ +
+ ); +} + +export default App; diff --git a/frontend/frontend/src/pages/App/AppWrapped.js b/frontend/frontend/src/pages/App/AppWrapped.js new file mode 100644 index 00000000..3d483d63 --- /dev/null +++ b/frontend/frontend/src/pages/App/AppWrapped.js @@ -0,0 +1,59 @@ +/* eslint-disable jsx-a11y/anchor-is-valid */ + +import React, { useEffect } from 'react'; +import { useSelector, useDispatch } from 'react-redux'; + +import { BrowserRouter as Router, Routes, Route } from 'react-router-dom'; + +import Header from './Header'; +import { SignUpScreen } from '../Auth'; +import { SelectUserScreen, WrappedScreen } from '../Wrapped'; +import { NoMatchScreen } from '../Misc'; + +import { setPrivateAccess as _setPrivateAccess } from '../../redux/actions/userActions'; +import { getUserMetadata } from '../../api'; +import Footer from './Footer'; + +function App() { + const userId = useSelector((state) => state.user.userId); + const isAuthenticated = userId && userId.length > 0; + + const dispatch = useDispatch(); + const setPrivateAccess = (access) => dispatch(_setPrivateAccess(access)); + + useEffect(() => { + async function getPrivateAccess() { + if (userId && userId.length > 0) { + const result = await getUserMetadata(userId); + if (result !== null && result.private_access !== undefined) { + setPrivateAccess(result.private_access); + } + } + } + getPrivateAccess(); + }, [userId]); + + return ( +
+ +
+
+ + {!isAuthenticated && ( + } /> + )} + } /> + } /> + } /> + } /> + } /> + } /> + +
+
+ +
+ ); +} + +export default App; diff --git a/frontend/frontend/src/pages/App/Footer.js b/frontend/frontend/src/pages/App/Footer.js new file mode 100644 index 00000000..219bbd95 --- /dev/null +++ b/frontend/frontend/src/pages/App/Footer.js @@ -0,0 +1,18 @@ +import React from 'react'; +import { CURR_YEAR } from '../../constants'; + +function Footer() { + return ( +
+
+
+

+ {`© ${CURR_YEAR} GitHub Trends`} +

+
+
+
+ ); +} + +export default Footer; diff --git a/frontend/frontend/src/pages/App/Header.js b/frontend/frontend/src/pages/App/Header.js new file mode 100644 index 00000000..96486dcb --- /dev/null +++ b/frontend/frontend/src/pages/App/Header.js @@ -0,0 +1,204 @@ +import React, { useState } from 'react'; +import { useDispatch, useSelector } from 'react-redux'; +import PropTypes from 'prop-types'; + +import { Link } from 'react-router-dom'; + +import { GiHamburgerMenu as HamburgerIcon } from 'react-icons/gi'; +import { MdSettings as SettingsIcon } from 'react-icons/md'; + +import { logout as _logout } from '../../redux/actions/userActions'; +import rocketIcon from '../../assets/rocket.png'; +import { classnames } from '../../utils'; +import { GITHUB_PUBLIC_AUTH_URL, WRAPPED_URL } from '../../constants'; + +const propTypes = { + to: PropTypes.string.isRequired, + children: PropTypes.node.isRequired, + onClick: PropTypes.func, + className: PropTypes.string, +}; + +const defaultProps = { + onClick: null, + className: null, +}; + +const StandardLink = ({ to, children, onClick, className }) => ( + + {children} + +); + +StandardLink.propTypes = propTypes; + +StandardLink.defaultProps = defaultProps; + +const MobileLink = ({ to, children, onClick, className }) => ( + + {children} + +); + +MobileLink.propTypes = propTypes; + +MobileLink.defaultProps = defaultProps; + +const Header = ({ mode }) => { + const [toggle, setToggle] = useState(false); + + const userId = useSelector((state) => state.user.userId); + const isAuthenticated = userId && userId.length > 0; + + const dispatch = useDispatch(); + const logout = () => dispatch(_logout()); + + return ( +
+
+ {/* GitHub Trends Logo */} + + logo + {mode === 'trends' && ( + GitHub Trends + )} + {mode === 'wrapped' && ( + GitHub Wrapped + )} + + {/* Pages: Wrapped, Dashboard, Demo */} + {mode === 'trends' && ( +
+ + Wrapped + + {isAuthenticated ? ( + Dashboard + ) : ( + Demo + )} +
+ )} + {/* Auth Pages: Sign Up, Log In, Log Out */} +
+ {isAuthenticated ? ( + <> + {mode === 'trends' && ( + + + + )} + + Sign Out + + + ) : ( + <> + + Login + + + Sign Up + + + )} +
+ {/* Hamburger Menu */} +
+ +
+
+ {/* Hamburger Dropdown */} +
+ {mode === 'trends' && ( + <> + setToggle(false)}> + Wrapped + + {isAuthenticated ? ( + setToggle(false)}> + Dashboard + + ) : ( + setToggle(false)}> + Demo + + )} + + )} + {isAuthenticated ? ( + <> + {mode === 'trends' && ( + setToggle(false)}> + Settings + + )} + { + setToggle(false); + logout(); + }} + > + Sign Out + + + ) : ( + <> + + Login + + setToggle(false)} + className="block text-sm px-2 my-2 py-2 rounded-sm bg-blue-500 text-white" + > + Sign Up + + + )} +
+
+ ); +}; + +Header.propTypes = { + mode: PropTypes.string.isRequired, +}; + +export default Header; diff --git a/frontend/frontend/src/pages/App/index.js b/frontend/frontend/src/pages/App/index.js new file mode 100644 index 00000000..fb2616ef --- /dev/null +++ b/frontend/frontend/src/pages/App/index.js @@ -0,0 +1,4 @@ +import AppTrends from './AppTrends'; +import AppWrapped from './AppWrapped'; + +export { AppTrends, AppWrapped }; diff --git a/frontend/frontend/src/pages/Auth/SignUp.js b/frontend/frontend/src/pages/Auth/SignUp.js new file mode 100644 index 00000000..b594c547 --- /dev/null +++ b/frontend/frontend/src/pages/Auth/SignUp.js @@ -0,0 +1,58 @@ +import React from 'react'; +import { useSelector } from 'react-redux'; + +import { FaGithub as GithubIcon } from 'react-icons/fa'; + +import { Button } from '../../components'; +import { + GITHUB_PUBLIC_AUTH_URL, + GITHUB_PRIVATE_AUTH_URL, +} from '../../constants'; +import { classnames } from '../../utils'; + +import mockup from '../../assets/mockup.png'; + +const SignUpScreen = () => { + // eslint-disable-next-line no-unused-vars + const userId = useSelector((state) => state.user.userId); + + return ( +
+
+
+ preview +
+
+
+
+

+ Sign up for
+ GitHub Trends +

+ +
+
+ ); +}; + +export default SignUpScreen; diff --git a/frontend/frontend/src/pages/Auth/index.js b/frontend/frontend/src/pages/Auth/index.js new file mode 100644 index 00000000..05f08ded --- /dev/null +++ b/frontend/frontend/src/pages/Auth/index.js @@ -0,0 +1,3 @@ +import SignUpScreen from './SignUp'; + +export { SignUpScreen }; diff --git a/frontend/frontend/src/pages/Demo/Demo.js b/frontend/frontend/src/pages/Demo/Demo.js new file mode 100644 index 00000000..d9d07dbc --- /dev/null +++ b/frontend/frontend/src/pages/Demo/Demo.js @@ -0,0 +1,136 @@ +import React, { useEffect, useState } from 'react'; + +import { Link } from 'react-router-dom'; + +import { Button, SvgInline } from '../../components'; +import { getValidUser } from '../../api/wrapped'; +import { BACKEND_URL } from '../../constants'; +import { classnames } from '../../utils'; + +const DemoScreen = () => { + const [userName, setUserName] = useState(''); + const [selectedUserName, setSelectedUserName] = useState(''); + const [loading, setLoading] = useState(false); + + let userNameInput; + + useEffect(() => { + userNameInput.focus(); + }, [userNameInput]); + + const [error, setError] = useState(''); + + const handleSubmit = async () => { + const validUser = await getValidUser(userName); + if (validUser.includes('Valid user') || validUser === 'Repo not starred') { + setLoading(true); + setSelectedUserName(userName); + setLoading(false); + } else if (validUser === 'GitHub user not found') { + setError('GitHub user not found. Check your spelling and try again.'); + } + }; + + const firstCardUrl = + selectedUserName.length > 0 + ? `${BACKEND_URL}/user/svg/${selectedUserName}/langs?demo=true` + : `${BACKEND_URL}/user/svg/demo?card=langs`; + + const secondCardUrl = + selectedUserName.length > 0 + ? `${BACKEND_URL}/user/svg/${selectedUserName}/repos?demo=true` + : `${BACKEND_URL}/user/svg/demo?card=repos`; + + return ( +
+
+
+

+ GitHub Trends Demo +

+

+ This is a demo of the GitHub Trends API. Enter your GitHub username + to see statistics about your top languages and repositories from the + past month. +

+
+

Enter your GitHub username to get started!

+
+ { + userNameInput = input; + }} + placeholder="Enter Username" + className={classnames( + 'bg-white text-gray-700 w-full input input-bordered rounded-sm', + error && 'input-error', + )} + onChange={(e) => { + setUserName(e.target.value); + setError(''); + }} + onKeyPress={async (e) => { + if (e.key === 'Enter') { + handleSubmit(); + } + }} + /> + +
+ {error ? ( +
+ Error: {error} +
+ ) : ( +
+ )} +
+

+ This demo uses a public access token that is heavily rate limited. + For full customization, private contributions, and a personal access + token, create an account instead! +

+
+ +
+
+
+
+
+

+ {selectedUserName === '' + ? 'Enter a Username' + : `Example Cards for ${selectedUserName}`} +

+
+
+ +
+
+ +
+
+
+
+
+ ); +}; + +export default DemoScreen; diff --git a/frontend/frontend/src/pages/Demo/index.js b/frontend/frontend/src/pages/Demo/index.js new file mode 100644 index 00000000..4c933a56 --- /dev/null +++ b/frontend/frontend/src/pages/Demo/index.js @@ -0,0 +1,3 @@ +import DemoScreen from './Demo'; + +export default DemoScreen; diff --git a/frontend/frontend/src/pages/Home/Home.js b/frontend/frontend/src/pages/Home/Home.js new file mode 100644 index 00000000..c6a4b61e --- /dev/null +++ b/frontend/frontend/src/pages/Home/Home.js @@ -0,0 +1,238 @@ +import React, { useEffect, useState } from 'react'; +import { useDispatch, useSelector } from 'react-redux'; + +import { useNavigate } from 'react-router-dom'; +import BounceLoader from 'react-spinners/BounceLoader'; +import { FaGithub as GithubIcon } from 'react-icons/fa'; + +import { ProgressBar } from '../../components'; +import { + SelectCardStage, + CustomizeStage, + ThemeStage, + DisplayStage, +} from './stages'; + +import { setUserKey, authenticate } from '../../api'; +import { login as _login } from '../../redux/actions/userActions'; +import { PROD } from '../../constants'; + +const HomeScreen = () => { + const navigate = useNavigate(); + + const [isLoading, setIsLoading] = useState(false); + + const userId = useSelector((state) => state.user.userId); + const privateAccess = useSelector((state) => state.user.privateAccess); + + const isAuthenticated = userId && userId.length > 0; + + const dispatch = useDispatch(); + + const login = (newUserId, userKey) => dispatch(_login(newUserId, userKey)); + + // for all stages + const [stage, setStage] = useState(0); + + // for stage one + const [selectedCard, setSelectedCard] = useState('langs'); + + // for stage two + const defaultTimeRange = { + id: 3, + label: 'Past 1 Year', + disabled: false, + value: 'one_year', + }; + const [selectedTimeRange, setSelectedTimeRange] = useState(defaultTimeRange); + + const [usePercent, setUsePercent] = useState(false); + const [usePrivate, setUsePrivate] = useState(false); + const [groupOther, setGroupOther] = useState(false); + const [groupPrivate, setGroupPrivate] = useState(false); + const [useLocChanged, setUseLocChanged] = useState(false); + const [useCompact, setUseCompact] = useState(false); + + const resetCustomization = () => { + setSelectedTimeRange(defaultTimeRange); + setUsePercent(false); + setUsePrivate(false); + setUseLocChanged(false); + setUseCompact(false); + }; + + useEffect(() => { + resetCustomization(); + }, [selectedCard]); + + const time = selectedTimeRange.value; + let fullSuffix = `${selectedCard}?time_range=${time}`; + + if (usePercent) { + fullSuffix += '&use_percent=True'; + } + + if (usePrivate) { + fullSuffix += '&include_private=True'; + } + + if (usePrivate && groupOther && groupPrivate) { + fullSuffix += '&group=private'; + } else if (groupOther) { + fullSuffix += '&group=other'; + } + + if (useLocChanged) { + fullSuffix += '&loc_metric=changed'; + } + + if (useCompact) { + fullSuffix += '&compact=True'; + } + + // for stage three + const [theme, setTheme] = useState('classic'); + const themeSuffix = `${fullSuffix}&theme=${theme}`; + + useEffect(() => { + async function redirectCode() { + // After requesting Github access, Github redirects back to your app with a code parameter + const url = window.location.href; + + if (url.includes('error=')) { + navigate('/'); + } + + // If Github API returns the code parameter + if (url.includes('code=')) { + const tempPrivateAccess = url.includes('private'); + const newUrl = url.split('?code='); + const subStr = PROD ? 'githubtrends.io' : 'localhost:3000'; + const redirect = `${url.split(subStr)[0]}${subStr}/user`; + window.history.pushState({}, null, redirect); + setIsLoading(true); + const userKey = await setUserKey(newUrl[1]); + const newUserId = await authenticate(newUrl[1], tempPrivateAccess); + login(newUserId, userKey); + setIsLoading(false); + } + } + + redirectCode(); + }, []); + + if (isLoading) { + return ( +
+ +
+ ); + } + + if (!isAuthenticated) { + return ( +
+
+

+ Please sign in to access this page +

+
+
+ ); + } + + return ( +
+
+ +
+
+
+ { + [ + 'Select a Card', + 'Modify Card Parameters', + 'Choose a Theme', + 'Display your Card', + ][stage] + } +
+
+ { + [ + 'You will be able to customize your card in future steps.', + 'Change the date range, include private commits, and more!', + 'Choose from one of our predefined themes (more coming soon!)', + 'Display your card on GitHub, Twitter, or Linkedin', + ][stage] + } +
+
+ {stage === 0 && ( + + )} + {stage === 1 && ( + + )} + {stage === 2 && ( + + )} + {stage === 3 && ( + + )} +
+
+ +
+ ); +}; + +export default HomeScreen; diff --git a/frontend/frontend/src/pages/Home/index.js b/frontend/frontend/src/pages/Home/index.js new file mode 100644 index 00000000..aa0f700f --- /dev/null +++ b/frontend/frontend/src/pages/Home/index.js @@ -0,0 +1,3 @@ +import HomeScreen from './Home'; + +export default HomeScreen; diff --git a/frontend/frontend/src/pages/Home/stages/Customize.js b/frontend/frontend/src/pages/Home/stages/Customize.js new file mode 100644 index 00000000..574e6caa --- /dev/null +++ b/frontend/frontend/src/pages/Home/stages/Customize.js @@ -0,0 +1,116 @@ +import React from 'react'; +import PropTypes from 'prop-types'; + +import { Image, DateRangeSection, CheckboxSection } from '../../../components'; + +const CustomizeStage = ({ + selectedCard, + selectedTimeRange, + setSelectedTimeRange, + usePrivate, + setUsePrivate, + groupOther, + setGroupOther, + groupPrivate, + setGroupPrivate, + privateAccess, + useCompact, + setUseCompact, + usePercent, + setUsePercent, + useLocChanged, + setUseLocChanged, + fullSuffix, +}) => { + return ( +
+
+ + {selectedCard === 'langs' && ( + + )} + + {selectedCard === 'repos' && ( + + )} + {selectedCard === 'repos' && usePrivate && groupOther && ( + + )} + {selectedCard === 'langs' && ( + + )} + +
+
+
+ +
+
+
+ ); +}; + +CustomizeStage.propTypes = { + selectedCard: PropTypes.string.isRequired, + selectedTimeRange: PropTypes.object.isRequired, + setSelectedTimeRange: PropTypes.func.isRequired, + usePrivate: PropTypes.bool.isRequired, + setUsePrivate: PropTypes.func.isRequired, + groupOther: PropTypes.bool.isRequired, + setGroupOther: PropTypes.func.isRequired, + groupPrivate: PropTypes.bool.isRequired, + setGroupPrivate: PropTypes.func.isRequired, + privateAccess: PropTypes.bool.isRequired, + useCompact: PropTypes.bool.isRequired, + setUseCompact: PropTypes.func.isRequired, + usePercent: PropTypes.bool.isRequired, + setUsePercent: PropTypes.func.isRequired, + useLocChanged: PropTypes.bool.isRequired, + setUseLocChanged: PropTypes.func.isRequired, + fullSuffix: PropTypes.string.isRequired, +}; + +export default CustomizeStage; diff --git a/frontend/frontend/src/pages/Home/stages/Display.js b/frontend/frontend/src/pages/Home/stages/Display.js new file mode 100644 index 00000000..3f7bc2ae --- /dev/null +++ b/frontend/frontend/src/pages/Home/stages/Display.js @@ -0,0 +1,87 @@ +/* eslint-disable react/no-array-index-key */ + +import React from 'react'; +import PropTypes from 'prop-types'; + +import { ToastContainer, toast } from 'react-toastify'; +import 'react-toastify/dist/ReactToastify.css'; + +import { saveSvgAsPng } from 'save-svg-as-png'; + +import { Card, Button } from '../../../components'; +import { classnames } from '../../../utils'; + +const DisplayStage = ({ userId, themeSuffix }) => { + const card = themeSuffix.split('?')[0]; + + const downloadPNG = () => { + saveSvgAsPng(document.getElementById('svg-card'), `${userId}_${card}.png`, { + scale: 2, + encoderOptions: 1, + }); + }; + + const copyUrl = () => { + navigator.clipboard.writeText( + `https://api.githubtrends.io/user/svg/${userId}/${themeSuffix}`, + ); + toast.info('Copied to Clipboard!', { + position: 'bottom-right', + autoClose: 1000, + hideProgressBar: true, + closeOnClick: false, + pauseOnHover: false, + draggable: false, + progress: undefined, + }); + }; + + return ( +
+ +
+
+ Copy the image URL or download the PNG. Share on GitHub, Twitter, + LinkedIn, or anywhere else! +
+
+
+ {[ + { title: 'Copy URL', active: true, onClick: copyUrl }, + { title: 'Download PNG', active: true, onClick: downloadPNG }, + ].map((item, index) => ( + + ))} +
+
+
+
+ +
+
+
+ ); +}; + +DisplayStage.propTypes = { + userId: PropTypes.string.isRequired, + themeSuffix: PropTypes.string.isRequired, +}; + +export default DisplayStage; diff --git a/frontend/frontend/src/pages/Home/stages/SelectCard.js b/frontend/frontend/src/pages/Home/stages/SelectCard.js new file mode 100644 index 00000000..243ad7a1 --- /dev/null +++ b/frontend/frontend/src/pages/Home/stages/SelectCard.js @@ -0,0 +1,46 @@ +/* eslint-disable react/no-array-index-key */ + +import React from 'react'; +import PropTypes from 'prop-types'; + +import { Card } from '../../../components'; + +const SelectCardStage = ({ selectedCard, setSelectedCard }) => { + return ( +
+ {[ + { + title: 'Language Contributions', + description: 'See your overall language breakdown', + imageSrc: 'langs', + }, + { + title: 'Repository Contributions', + description: 'See your most contributed repositories', + imageSrc: 'repos', + }, + ].map((card, index) => ( + + ))} +
+ ); +}; + +SelectCardStage.propTypes = { + selectedCard: PropTypes.string.isRequired, + setSelectedCard: PropTypes.func.isRequired, +}; + +export default SelectCardStage; diff --git a/frontend/frontend/src/pages/Home/stages/Theme.js b/frontend/frontend/src/pages/Home/stages/Theme.js new file mode 100644 index 00000000..52939cab --- /dev/null +++ b/frontend/frontend/src/pages/Home/stages/Theme.js @@ -0,0 +1,61 @@ +/* eslint-disable react/no-array-index-key */ + +import React from 'react'; +import PropTypes from 'prop-types'; + +import { Card } from '../../../components'; + +const ThemeStage = ({ theme, setTheme, fullSuffix }) => { + return ( +
+ {[ + { + title: 'Classic', + imageSrc: 'classic', + }, + { + title: 'Dark', + imageSrc: 'dark', + }, + { + title: 'Bright Lights', + imageSrc: 'bright_lights', + }, + { + title: 'Rosettes', + imageSrc: 'rosettes', + }, + { + title: 'Ferns', + imageSrc: 'ferns', + }, + { + title: 'Synthwaves', + imageSrc: 'synthwaves', + }, + ].map((card, index) => ( + + ))} +
+ ); +}; + +ThemeStage.propTypes = { + theme: PropTypes.string.isRequired, + setTheme: PropTypes.func.isRequired, + fullSuffix: PropTypes.string.isRequired, +}; + +export default ThemeStage; diff --git a/frontend/frontend/src/pages/Home/stages/index.js b/frontend/frontend/src/pages/Home/stages/index.js new file mode 100644 index 00000000..befa9ba2 --- /dev/null +++ b/frontend/frontend/src/pages/Home/stages/index.js @@ -0,0 +1,6 @@ +import SelectCardStage from './SelectCard'; +import CustomizeStage from './Customize'; +import ThemeStage from './Theme'; +import DisplayStage from './Display'; + +export { SelectCardStage, CustomizeStage, ThemeStage, DisplayStage }; diff --git a/frontend/frontend/src/pages/Landing/Landing.js b/frontend/frontend/src/pages/Landing/Landing.js new file mode 100644 index 00000000..d2d25050 --- /dev/null +++ b/frontend/frontend/src/pages/Landing/Landing.js @@ -0,0 +1,231 @@ +/* eslint-disable react/jsx-one-expression-per-line */ + +import React from 'react'; +import { useSelector } from 'react-redux'; + +import { Link } from 'react-router-dom'; +import { FaGithub as GithubIcon, FaCheck as CheckIcon } from 'react-icons/fa'; + +import { Button, Preview } from '../../components'; + +import mockup from '../../assets/mockup.png'; +import wrapped from '../../assets/wrapped1.png'; +import avgupta456Langs from '../../assets/avgupta456_langs.png'; +import tiangoloRepos from '../../assets/tiangolo_repos.png'; +import reininkRepos from '../../assets/reinink_repos.png'; +import dhermesLangs from '../../assets/dhermes_langs.png'; +import { WRAPPED_URL } from '../../constants'; + +function LandingScreen() { + const userId = useSelector((state) => state.user.userId); + + const isAuthenticated = userId && userId.length > 0; + + return ( +
+
+
+
+ Discover and share code contribution insights +
+
+ GitHub Trends dives deep into the GitHub API to bring you insightful + metrics on your contributions, broken by repository and language. +
+
+ + + + + + +
+
+
+ preview +
+
+
+
+ +
+
+

+ Display your GitHub stats +
with embeddable cards
+

+

1. Comprehensive

+

+ GitHub Trends counts each individual commit, across all your + open-source contributions. We surface line of code metrics by + repository and languages. +

+
+

2. Customizable

+

+ Using the online dashboard, easily modify the time range, include + private commits, and choose your display theme. +

+
+

3. Shareable

+

+ Share your GitHub Trends cards as a PNG on Twitter, or as a dynamic + embeddable image on your GitHub profile or personal website. +

+
+
+ + + + {!isAuthenticated && ( + + + + )} +
+
+
+
+
+

+ Reflect on your year +
+ with GitHub Wrapped +
+

+

1. Detailed

+

+ GitHub Wrapped provides a breakdown of your contributions by date, + by date, time, repository, and language. Over 20 stats are + displayed. +

+
+

2. Visual

+

+ Understand your coding contributions like never before with an + interactive calendar, bar charts, pie charts, and more. +

+
+

3. Public

+

+ Share your GitHub Wrapped link with your friends and colleagues and + take a look at their contributions too.{' '} + No account required, although rate limiting may + apply. +

+
+
+ + + + + + +
+
+
+
+ preview +
+
+
+
+

GitHub Trends

+

+ GitHub Trends dives deep into the GitHub API to bring you insightful + metrics and visualizations. We access individual commits to compute + accurate and granular statistics. +

+
+ {[ + { + header: 'Measures Contribs', + text: 'Calculates your stats on a per-contribution level, allowing for deeper insights', + }, + { + header: 'LOC Insights', + text: 'See aggregate stats on lines of code (LOC) written across all contributions', + }, + { + header: 'Language Breakdowns', + text: 'Showcase your favorite languages with LOC language breakdowns', + }, + { + header: 'Private Mode', + text: 'Use a PAT to avoid rate limiting and include private contributions', + }, + { + header: 'Exciting Visualizations', + text: 'Visualize your contributions with bar graphs, pie charts, and more', + }, + { + header: 'Shareable Stats', + text: 'Easily add your cards to your GitHub and share them online', + }, + ].map((item, index) => ( + // eslint-disable-next-line react/no-array-index-key +
+
+ +
+
+

{item.header}

+

{item.text}

+
+
+ ))} +
+
+ {!isAuthenticated && ( +
+

Ready to get started?

+

Create an account today.

+
+ + + + + + +
+
+ )} +
+ ); +} + +export default LandingScreen; diff --git a/frontend/frontend/src/pages/Landing/index.js b/frontend/frontend/src/pages/Landing/index.js new file mode 100644 index 00000000..b11123d7 --- /dev/null +++ b/frontend/frontend/src/pages/Landing/index.js @@ -0,0 +1,3 @@ +import LandingScreen from './Landing'; + +export default LandingScreen; diff --git a/frontend/frontend/src/pages/Misc/NoMatch.js b/frontend/frontend/src/pages/Misc/NoMatch.js new file mode 100644 index 00000000..b5c8126e --- /dev/null +++ b/frontend/frontend/src/pages/Misc/NoMatch.js @@ -0,0 +1,32 @@ +import React from 'react'; + +import { Link } from 'react-router-dom'; + +import { Button } from '../../components'; + +const NoMatchScreen = () => { + return ( +
+
+
404
+
+
+
+ Page not Found +
+
+ Please check the URL in the address bar and try again. +
+
+ + + +
+
+
+ ); +}; + +export default NoMatchScreen; diff --git a/frontend/frontend/src/pages/Misc/Redirect.js b/frontend/frontend/src/pages/Misc/Redirect.js new file mode 100644 index 00000000..bde9e449 --- /dev/null +++ b/frontend/frontend/src/pages/Misc/Redirect.js @@ -0,0 +1,25 @@ +// eslint-disable-next-line no-unused-vars +import React, { useEffect } from 'react'; +import { BACKEND_URL } from '../../constants'; + +const RedirectScreen = () => { + useEffect(() => { + async function redirectCode() { + // Take any query parameters and pass to redirect + const url = window.location.href; + const hasCode = url.includes('user/redirect'); + + // If Github API returns the code parameter + if (hasCode) { + const newUrl = url.split('user/redirect'); + window.location.replace(`${BACKEND_URL}/auth/redirect${newUrl[1]}`); + } + } + + redirectCode(); + }, []); + + return null; +}; + +export default RedirectScreen; diff --git a/frontend/frontend/src/pages/Misc/index.js b/frontend/frontend/src/pages/Misc/index.js new file mode 100644 index 00000000..afde4714 --- /dev/null +++ b/frontend/frontend/src/pages/Misc/index.js @@ -0,0 +1,4 @@ +import NoMatchScreen from './NoMatch'; +import RedirectScreen from './Redirect'; + +export { NoMatchScreen, RedirectScreen }; diff --git a/frontend/frontend/src/pages/Settings/Settings.js b/frontend/frontend/src/pages/Settings/Settings.js new file mode 100644 index 00000000..28896553 --- /dev/null +++ b/frontend/frontend/src/pages/Settings/Settings.js @@ -0,0 +1,259 @@ +/* eslint-disable jsx-a11y/click-events-have-key-events */ +/* eslint-disable jsx-a11y/no-static-element-interactions */ + +import React, { useState, useEffect, useRef } from 'react'; +import PropTypes from 'prop-types'; +import { useSelector, useDispatch } from 'react-redux'; + +import { Button } from '../../components'; +import { logout as _logout } from '../../redux/actions/userActions'; +import { deleteAccount } from '../../api'; +import { classnames } from '../../utils'; +import { GITHUB_PRIVATE_AUTH_URL, CLIENT_ID } from '../../constants'; + +const SectionButton = ({ name, implemented, isSelected, setSelected }) => { + return ( +
setSelected('accountTier')} + > + {name} +
+ ); +}; + +SectionButton.propTypes = { + name: PropTypes.string.isRequired, + implemented: PropTypes.bool, + isSelected: PropTypes.bool.isRequired, + setSelected: PropTypes.func.isRequired, +}; + +SectionButton.defaultProps = { + implemented: true, +}; + +function useOutsideAlerter(ref, action) { + useEffect(() => { + /** + * Alert if clicked on outside of element + */ + function handleClickOutside(event) { + if (ref.current && !ref.current.contains(event.target)) { + action(); + } + } + + // Bind the event listener + document.addEventListener('mousedown', handleClickOutside); + return () => { + // Unbind the event listener on clean up + document.removeEventListener('mousedown', handleClickOutside); + }; + }, [ref]); +} + +const SettingsScreen = () => { + const [selected, setSelected] = useState('accountTier'); + const [deleteModal, setDeleteModal] = useState(false); + + const openDeleteModal = () => { + setDeleteModal(true); + }; + + const closeDeleteModal = () => { + setDeleteModal(false); + }; + + const wrapperRef = useRef(null); + useOutsideAlerter(wrapperRef, closeDeleteModal); + + const userId = useSelector((state) => state.user.userId); + const isAuthenticated = userId && userId.length > 0; + const userKey = useSelector((state) => state.user.userKey); + const privateAccess = useSelector((state) => state.user.privateAccess); + const accountTier = privateAccess ? 'Private Workflow' : 'Public Workflow'; + + const dispatch = useDispatch(); + const logout = () => dispatch(_logout()); + + console.log(isAuthenticated, userId, userKey, privateAccess, accountTier); + + if (!isAuthenticated) { + return ( +
+
+

+ Please sign in to access this page +

+
+
+ ); + } + + return ( +
+
+
+

+ Account Settings +

+
+ setSelected('accountTier')} + /> + setSelected('personalization')} + /> + setSelected('deleteAccount')} + /> +
+
+
+ {selected === 'accountTier' && ( +
+

Account Tier

+
+
+

+ Current Tier: + {accountTier} +

+
+ {privateAccess ? ( +

+ You have given GitHub Trends (read and write) access to all + public and private code contributions. We use your GitHub + API access token to make requests on your behalf. All of our + code is open-source and visible on our + + GitHub repository + +

+ ) : ( +

+ You have given GitHub Trends read access to your public + repositories. Upgrading to the Private Workflow will allow + us to better represent your code contributions. We use your + GitHub API access token to make requests on your behalf. All + of our code is open-source and visible on our + + GitHub repository + +

+ )} +
+ {privateAccess ? ( + + ) : ( + + + + )} +
+ )} + {selected === 'personalization' && ( +
+

Personalization

+
+
+

Coming soon!

+
+ )} + {selected === 'deleteAccount' && ( +
+

Delete Account

+
+
+

+ Deleting your account is permanent and cannot be undone. If + you are sure you want to delete your account, click the button + below to remove your statistics from GitHub Trends. This will + redirect you to a GitHub screen where you can revoke your + access token. +

+
+ +
+ )} +
+
+
+ {deleteModal && ( +
+
+
+
+

Delete Account

+
+
+

+ Are you sure you want to continue? This action cannot be + undone. +

+
+
+ + +
+
+
+
+
+ )} +
+ ); +}; + +export default SettingsScreen; diff --git a/frontend/frontend/src/pages/Settings/index.js b/frontend/frontend/src/pages/Settings/index.js new file mode 100644 index 00000000..7556f78b --- /dev/null +++ b/frontend/frontend/src/pages/Settings/index.js @@ -0,0 +1,3 @@ +import SettingsScreen from './Settings'; + +export default SettingsScreen; diff --git a/frontend/frontend/src/pages/Wrapped/SelectUser.js b/frontend/frontend/src/pages/Wrapped/SelectUser.js new file mode 100644 index 00000000..2f57520d --- /dev/null +++ b/frontend/frontend/src/pages/Wrapped/SelectUser.js @@ -0,0 +1,298 @@ +/* eslint-disable no-alert */ +/* eslint-disable no-unused-vars */ + +import React, { useState, useEffect } from 'react'; +import { useDispatch, useSelector } from 'react-redux'; + +import { useNavigate, Link } from 'react-router-dom'; +import { ClipLoader } from 'react-spinners'; +import { BsInfoCircle } from 'react-icons/bs'; +import { FaGithub as GithubIcon, FaCheck as CheckIcon } from 'react-icons/fa'; + +import { getValidUser } from '../../api/wrapped'; +import { Button, Preview } from '../../components'; +import { classnames, sleep } from '../../utils'; +import wrapped1 from '../../assets/wrapped1.png'; +import wrapped2 from '../../assets/wrapped2.png'; +import wrapped3 from '../../assets/wrapped3.png'; +import { PROD } from '../../constants'; +import { authenticate, setUserKey } from '../../api'; +import { login as _login } from '../../redux/actions/userActions'; + +const SelectUserScreen = () => { + const userId = useSelector((state) => state.user.userId || ''); + + const [userName, setUserName] = useState(userId); + + const navigate = useNavigate(); + const dispatch = useDispatch(); + + let userNameInput; + + useEffect(() => { + userNameInput.focus(); + }, [userNameInput]); + + const login = (newUserId, userKey) => dispatch(_login(newUserId, userKey)); + + useEffect(() => { + async function redirectCode() { + // After requesting Github access, Github redirects back to your app with a code parameter + const url = window.location.href; + + if (url.includes('error=')) { + navigate('/'); + } + + // If Github API returns the code parameter + if (url.includes('code=')) { + const tempPrivateAccess = url.includes('private'); + const newUrl = url.split('?code='); + const subStr = PROD ? 'githubwrapped.io' : 'localhost:3001'; + const redirect = `${url.split(subStr)[0]}${subStr}/`; + window.history.pushState({}, null, redirect); + const userKey = await setUserKey(newUrl[1]); + const newUserId = await authenticate(newUrl[1], tempPrivateAccess); + login(newUserId, userKey); + } + } + + redirectCode(); + }, []); + + const [error, setError] = useState(''); + const [isLoading, setIsLoading] = useState(false); + + const handleSubmit = async () => { + setIsLoading(true); + const validUser = await getValidUser(userName); + if (validUser.includes('Valid user')) { + const newUserName = validUser.split(' ')[2]; + await sleep(10); + navigate(`/${newUserName}`); + } else if (validUser === 'GitHub user not found') { + setError('GitHub user not found. Check your spelling and try again.'); + } else if (validUser === 'Repo not starred') { + setError( + 'This user has not starred the GitHub Trends repository. Please star the repo and try again.', + ); + } + setIsLoading(false); + }; + + return ( +
+
+
+

+ Reflect on your year
+ with GitHub Wrapped +

+

+ Powered by{' '} + + + GitHub Trends + + {' '} + (not affiliated with GitHub) +

+
+
+
+
+
+

+ Step 1: Star the GitHub repository.{' '} +

+
+ +
+
+ +

+ Step 2: Enter your GitHub username! +

+
+ { + userNameInput = input; + }} + placeholder="Enter Username" + className={classnames( + 'bg-white text-gray-700 w-full input input-bordered rounded-sm', + error && 'input-error', + )} + defaultValue={userName} + onChange={(e) => { + setUserName(e.target.value); + setError(''); + }} + onKeyPress={async (e) => { + if (e.key === 'Enter') { + handleSubmit(); + } + }} + /> + +
+ {error ? ( +
+ Error: {error} +
+ ) : ( +
+ )} +
+
+
+
+ +
+
+
+
+
+
+

See some examples

+
+ {[ + { + name: 'Linus Torvalds', + username: 'torvalds', + url: 'https://avatars.githubusercontent.com/u/1024025?v=4', + blurb: 'Creator of Linux', + }, + { + name: 'Evan You', + username: 'yyx990803', + url: 'https://avatars.githubusercontent.com/u/499550?v=4', + blurb: 'Creator of Vue', + }, + { + name: 'shadcn', + username: 'shadcn', + url: 'https://avatars.githubusercontent.com/u/124599?v=4', + blurb: 'Vercel, shadcn/ui', + }, + { + name: 'Sindre Sorhus', + username: 'sindresorhus', + url: 'https://avatars.githubusercontent.com/u/170270?v=4', + blurb: 'Open-sourcer', + }, + ].map((user) => ( +
+ +
+ {user.username} +
+ + {user.name} + +

{user.blurb}

+
+
+ +
+ ))} +
+
+
+
+
+

GitHub Trends

+

+ GitHub Trends dives deep into the GitHub API to bring you insightful + metrics and visualizations. We access individual commits to compute + accurate and granular statistics. +

+
+ {[ + { + header: 'Measures Contribs', + text: 'Calculates your stats on a per-contribution level, allowing for deeper insights', + }, + { + header: 'LOC Insights', + text: 'See aggregate stats on lines of code (LOC) written across all contributions', + }, + { + header: 'Language Breakdowns', + text: 'Showcase your favorite languages with LOC language breakdowns', + }, + { + header: 'Private Mode', + text: 'Use a PAT to avoid rate limiting and include private contributions', + }, + { + header: 'Exciting Visualizations', + text: 'Visualize your contributions with bar graphs, pie charts, and more', + }, + { + header: 'Shareable Stats', + text: 'Easily add your cards to your GitHub and share them online', + }, + ].map((item, index) => ( + // eslint-disable-next-line react/no-array-index-key +
+
+ +
+
+

{item.header}

+

{item.text}

+
+
+ ))} +
+
+
+
+ ); +}; + +export default SelectUserScreen; diff --git a/frontend/frontend/src/pages/Wrapped/Wrapped.js b/frontend/frontend/src/pages/Wrapped/Wrapped.js new file mode 100644 index 00000000..3017e240 --- /dev/null +++ b/frontend/frontend/src/pages/Wrapped/Wrapped.js @@ -0,0 +1,323 @@ +/* eslint-disable react/jsx-one-expression-per-line */ + +import React, { useEffect, useState } from 'react'; +import { useSelector } from 'react-redux'; + +import { useParams, Link } from 'react-router-dom'; +import { toPng } from 'html-to-image'; +import download from 'downloadjs'; +import { FaArrowLeft as LeftArrowIcon } from 'react-icons/fa'; +import { BsImage as ImageIcon, BsInfoCircle } from 'react-icons/bs'; +import Select from 'react-select'; +import { ClipLoader } from 'react-spinners'; + +import { getWrapped } from '../../api'; +import { + WrappedSection, + Numeric, + NumericOutOf, + Calendar, + hoverScale, + singleHoverScale, + BarMonth, + BarDay, + PieLangs, + PieRepos, + SwarmDay, + NumericPlusLOC, + NumericMinusLOC, + NumericBothLOC, + NumericBestDay, +} from '../../components'; +import Radar from '../../components/Wrapped/Specifics/Radar'; +import { LoadingScreen } from './sections'; +import { classnames } from '../../utils'; +import { CURR_YEAR } from '../../constants'; + +const WrappedScreen = () => { + // eslint-disable-next-line prefer-const + let { userId, year } = useParams(); + year = year || `${CURR_YEAR}`; + + const currUserId = useSelector((state) => state.user.userId); + const usePrivate = useSelector((state) => state.user.privateAccess); + + const [data, setData] = useState({}); + const [isLoading, setIsLoading] = useState(true); + const [highlightDays, setHighlightDays] = useState([]); + const [highlightColors, setHighlightColors] = useState(hoverScale); + + const [downloadLoading, setDownloadLoading] = useState(false); + + // eslint-disable-next-line no-unused-vars + const downloadImage = async () => { + const dataUrl = await toPng(document.getElementById('screenshot-div')); + download(dataUrl, 'github-wrapped.png'); + }; + + useEffect(() => { + async function getData() { + if (userId?.length > 0 && year > 2010 && year <= CURR_YEAR) { + const output = await getWrapped(userId, year); + if ( + output !== null && + output !== undefined && + Object.keys(output).length > 0 + ) { + setData(output); + setIsLoading(false); + } + } + } + getData(); + }, [userId, year]); + + if (isLoading) { + return ; + } + + const startStreak = data?.numeric_data?.misc?.longest_streak_days?.[0] || 0; + const endStreak = data?.numeric_data?.misc?.longest_streak_days?.[1] || 0; + const startGap = data?.numeric_data?.misc?.longest_gap_days?.[0] || 0; + const endGap = data?.numeric_data?.misc?.longest_gap_days?.[1] || 0; + + const bestDayMonth = + data?.numeric_data?.misc?.best_day_date?.split('-')?.[1] || '-'; + const bestDayDay = + data?.numeric_data?.misc?.best_day_date?.split('-')?.[2] || '-'; + const bestDayYear = + data?.numeric_data?.misc?.best_day_date?.split('-')?.[0] || '-'; + + return ( +
+
+ +
+ {!downloadLoading && ( + + + + )} +

+ {`${userId}'s`} +

+
+