Compare commits
24 commits
remove-oau
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| e40d69d5ff | |||
| 83707701e9 | |||
| 116e2c1577 | |||
| cf96371b90 | |||
| eadd7a5612 | |||
| 62053a1048 | |||
| b4340176da | |||
| 433ff9413d | |||
| 91949622b7 | |||
| 10affeb32f | |||
| e96c6f3528 | |||
| a534a3b691 | |||
| ebbbe99eaf | |||
| c7f90e233e | |||
| 9ba6824dd3 | |||
| 4c5d3aec28 | |||
| dd9aaf467e | |||
| 1417c52007 | |||
| bfd081337b | |||
| b8559f0499 | |||
| b465d0bb8d | |||
| 1a54fdbcd1 | |||
| 34afcc02b6 | |||
| 1574661c57 |
96 changed files with 943 additions and 13786 deletions
16
.coveragerc
16
.coveragerc
|
|
@ -1,16 +0,0 @@
|
|||
[run]
|
||||
source = ./src/newsreader/
|
||||
omit =
|
||||
**/tests/**
|
||||
**/migrations/**
|
||||
**/conf/**
|
||||
**/apps.py
|
||||
**/admin.py
|
||||
**/tests.py
|
||||
**/urls.py
|
||||
**/wsgi.py
|
||||
**/celery.py
|
||||
**/__init__.py
|
||||
|
||||
[html]
|
||||
directory = coverage
|
||||
25
.editorconfig
Normal file
25
.editorconfig
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
# https://editorconfig.org
|
||||
|
||||
# top-most EditorConfig file
|
||||
root = true
|
||||
|
||||
# Unix-style newlines with a newline ending every file
|
||||
[*]
|
||||
end_of_line = lf
|
||||
trim_trailing_whitespace = true
|
||||
|
||||
[*.py]
|
||||
indent_style = space
|
||||
indent_size = 4
|
||||
|
||||
[*.{yaml,yml,toml,md}]
|
||||
indent_style = space
|
||||
indent_size = 2
|
||||
|
||||
[Dockerfile*]
|
||||
indent_style = space
|
||||
indent_size = 4
|
||||
|
||||
[*.json]
|
||||
indent_style = space
|
||||
indent_size = 2
|
||||
12
.isort.cfg
12
.isort.cfg
|
|
@ -1,12 +0,0 @@
|
|||
[settings]
|
||||
include_trailing_comma = true
|
||||
line_length = 88
|
||||
multi_line_output = 3
|
||||
skip = env/, venv/
|
||||
default_section = THIRDPARTY
|
||||
known_first_party = newsreader
|
||||
known_django = django
|
||||
sections = FUTURE,STDLIB,DJANGO,THIRDPARTY,FIRSTPARTY,LOCALFOLDER
|
||||
lines_between_types=1
|
||||
lines_after_imports=2
|
||||
lines_between_types=1
|
||||
|
|
@ -1,10 +0,0 @@
|
|||
{
|
||||
"semi": true,
|
||||
"trailingComma": "es5",
|
||||
"singleQuote": true,
|
||||
"printWidth": 90,
|
||||
"tabWidth": 2,
|
||||
"useTabs": false,
|
||||
"bracketSpacing": true,
|
||||
"arrowParens": "avoid"
|
||||
}
|
||||
|
|
@ -1,8 +1,10 @@
|
|||
when:
|
||||
- event: push
|
||||
- event: pull_request
|
||||
- event: manual
|
||||
|
||||
steps:
|
||||
- image: node:lts
|
||||
- image: node:lts-alpine
|
||||
commands:
|
||||
- npm install
|
||||
- npm run build
|
||||
- npm run build:prod
|
||||
|
|
|
|||
|
|
@ -1,19 +1,18 @@
|
|||
when:
|
||||
- event: push
|
||||
branch: main
|
||||
- event: pull_request
|
||||
- event: manual
|
||||
|
||||
steps:
|
||||
- name: python linting
|
||||
image: python:3.11
|
||||
image: ghcr.io/astral-sh/uv:python3.11-alpine
|
||||
commands:
|
||||
- pip install uv
|
||||
- uv sync --group ci
|
||||
- ./.venv/bin/ruff check src/
|
||||
- ./.venv/bin/ruff format --check src/
|
||||
- uv run --no-sync -- ruff check src/
|
||||
- uv run --no-sync -- ruff format --check src/
|
||||
|
||||
- name: javascript linting
|
||||
image: node:lts
|
||||
image: node:lts-alpine
|
||||
commands:
|
||||
- npm install
|
||||
- npm ci
|
||||
- npm run lint
|
||||
|
|
|
|||
|
|
@ -1,36 +1,37 @@
|
|||
when:
|
||||
- event: push
|
||||
- event: pull_request
|
||||
- event: manual
|
||||
|
||||
services:
|
||||
- name: postgres
|
||||
image: postgres:15
|
||||
environment:
|
||||
POSTGRES_NAME: newsreader
|
||||
POSTGRES_USER: newsreader
|
||||
POSTGRES_PASSWORD: sekrit
|
||||
POSTGRES_NAME: &db-name newsreader
|
||||
POSTGRES_USER: &db-user newsreader
|
||||
POSTGRES_PASSWORD: &db-password sekrit
|
||||
- name: memcached
|
||||
image: memcached:1.5.22
|
||||
|
||||
steps:
|
||||
- name: python tests
|
||||
image: python:3.11
|
||||
image: ghcr.io/astral-sh/uv:python3.11-alpine
|
||||
environment:
|
||||
DJANGO_SETTINGS_MODULE: "newsreader.conf.ci"
|
||||
DJANGO_SECRET_KEY: sekrit
|
||||
POSTGRES_HOST: postgres
|
||||
POSTGRES_PORT: 5432
|
||||
POSTGRES_DB: newsreader
|
||||
POSTGRES_NAME: newsreader
|
||||
POSTGRES_USER: newsreader
|
||||
POSTGRES_PASSWORD: sekrit
|
||||
POSTGRES_DB: *db-name
|
||||
POSTGRES_USER: *db-user
|
||||
POSTGRES_PASSWORD: *db-password
|
||||
commands:
|
||||
- pip install uv
|
||||
- uv sync --group ci
|
||||
- ./.venv/bin/coverage run ./src/manage.py test newsreader
|
||||
- ./.venv/bin/coverage report --show-missing
|
||||
- uv run --no-sync -- coverage run ./src/manage.py test newsreader
|
||||
- uv run --no-sync -- coverage report --show-missing
|
||||
|
||||
- name: javascript tests
|
||||
image: node:lts
|
||||
image: node:lts-alpine
|
||||
commands:
|
||||
- npm install
|
||||
- npm ci
|
||||
- npm test
|
||||
|
|
|
|||
84
Dockerfile
Normal file
84
Dockerfile
Normal file
|
|
@ -0,0 +1,84 @@
|
|||
# stage 1
|
||||
FROM python:3.11-alpine AS backend
|
||||
|
||||
ARG USER_ID=1000
|
||||
ARG GROUP_ID=1000
|
||||
ARG UV_LINK_MODE=copy
|
||||
|
||||
RUN apk update \
|
||||
&& apk add --no-cache \
|
||||
vim \
|
||||
curl \
|
||||
gettext
|
||||
|
||||
RUN addgroup -g $USER_ID newsreader && adduser -Du $GROUP_ID -G newsreader newsreader
|
||||
|
||||
RUN mkdir --parents /app/src /app/logs /app/media /app/bin /app/static \
|
||||
&& chown -R newsreader:newsreader /app
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
USER newsreader
|
||||
|
||||
COPY --chown=newsreader:newsreader uv.lock pyproject.toml /app/
|
||||
|
||||
COPY --from=ghcr.io/astral-sh/uv:python3.11-alpine /usr/local/bin/uv /bin/uv
|
||||
|
||||
RUN --mount=type=cache,uid=$USER_ID,gid=$GROUP_ID,target=/home/newsreader/.cache/uv \
|
||||
uv sync --frozen --no-default-groups --no-install-project
|
||||
|
||||
COPY --chown=newsreader:newsreader ./bin/docker-entrypoint.sh /app/bin/docker-entrypoint.sh
|
||||
|
||||
VOLUME ["/app/logs", "/app/media", "/app/static"]
|
||||
|
||||
|
||||
|
||||
# stage 2
|
||||
FROM node:lts-alpine AS frontend-build
|
||||
|
||||
ARG BUILD_ARG=prod
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
RUN chown node:node /app
|
||||
|
||||
USER node
|
||||
|
||||
COPY --chown=node:node ./package*.json ./webpack.*.js ./babel.config.js /app/
|
||||
|
||||
RUN --mount=type=cache,uid=1000,gid=1000,target=/home/node/.npm \
|
||||
npm ci
|
||||
|
||||
COPY --chown=node:node ./src /app/src
|
||||
|
||||
RUN npm run build:$BUILD_ARG
|
||||
|
||||
|
||||
|
||||
# stage 3
|
||||
FROM backend AS production
|
||||
|
||||
COPY --from=frontend-build --chown=newsreader:newsreader \
|
||||
/app/src/newsreader/static /app/src/newsreader/static
|
||||
|
||||
RUN --mount=type=cache,uid=$USER_ID,gid=$GROUP_ID,target=/home/newsreader/.cache/uv \
|
||||
uv sync --frozen --only-group production --extra sentry
|
||||
|
||||
COPY --chown=newsreader:newsreader ./src /app/src
|
||||
|
||||
ENV DJANGO_SETTINGS_MODULE=newsreader.conf.production
|
||||
|
||||
# Note that the static volume will have to be recreated to be pre-populated
|
||||
# correctly with the latest static files. See
|
||||
# https://docs.docker.com/storage/volumes/#populate-a-volume-using-a-container
|
||||
RUN uv run --no-sync -- src/manage.py collectstatic --noinput
|
||||
|
||||
|
||||
|
||||
# (optional) stage 4
|
||||
FROM backend AS development
|
||||
|
||||
RUN --mount=type=cache,uid=$USER_ID,gid=$GROUP_ID,target=/home/newsreader/.cache/uv \
|
||||
uv sync --frozen --group development
|
||||
|
||||
ENV DJANGO_SETTINGS_MODULE=newsreader.conf.docker
|
||||
|
|
@ -1,23 +1,21 @@
|
|||
module.exports = api => {
|
||||
const isTest = api.env('test');
|
||||
|
||||
const preset = [
|
||||
"@babel/preset-env" , { targets: 'defaults' }
|
||||
const preset = [
|
||||
"@babel/preset-env", { targets: 'defaults' }
|
||||
];
|
||||
const testPreset = [
|
||||
"@babel/preset-env", { targets: { node: process.versions.node } }
|
||||
];
|
||||
const testPreset = [
|
||||
"@babel/preset-env", { targets: { node: process.versions.node } }
|
||||
];
|
||||
|
||||
const plugins = [
|
||||
"@babel/plugin-syntax-dynamic-import",
|
||||
"@babel/plugin-transform-react-jsx",
|
||||
"@babel/plugin-syntax-function-bind",
|
||||
"@babel/plugin-proposal-function-bind",
|
||||
"@babel/plugin-proposal-class-properties"
|
||||
]
|
||||
const plugins = [
|
||||
"@babel/plugin-syntax-dynamic-import",
|
||||
"@babel/plugin-transform-react-jsx",
|
||||
"@babel/plugin-proposal-class-properties"
|
||||
]
|
||||
|
||||
return {
|
||||
"presets": [isTest ? testPreset: preset],
|
||||
"plugins": plugins
|
||||
}
|
||||
return {
|
||||
"presets": [isTest ? testPreset : preset],
|
||||
"plugins": plugins
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
#!/bin/bash
|
||||
|
||||
/app/.venv/bin/python /app/src/manage.py migrate
|
||||
uv run --no-sync -- /app/src/manage.py migrate
|
||||
|
||||
exec "$@"
|
||||
|
|
|
|||
|
|
@ -1,18 +1,13 @@
|
|||
volumes:
|
||||
static-files:
|
||||
node-modules:
|
||||
|
||||
services:
|
||||
celery:
|
||||
build:
|
||||
target: development
|
||||
volumes:
|
||||
- ./src/:/app/src
|
||||
|
||||
django:
|
||||
build:
|
||||
build: &app-development-build
|
||||
target: development
|
||||
command: /app/.venv/bin/python /app/src/manage.py runserver 0.0.0.0:8000
|
||||
command: uv run --no-sync -- /app/src/manage.py runserver 0.0.0.0:8000
|
||||
environment: &django-env
|
||||
DJANGO_SETTINGS_MODULE: ${DJANGO_SETTINGS_MODULE:-newsreader.conf.docker}
|
||||
ports:
|
||||
- "${DJANGO_PORT:-8000}:8000"
|
||||
volumes:
|
||||
|
|
@ -21,12 +16,21 @@ services:
|
|||
stdin_open: true
|
||||
tty: true
|
||||
|
||||
celery:
|
||||
build:
|
||||
<<: *app-development-build
|
||||
environment:
|
||||
<<: *django-env
|
||||
volumes:
|
||||
- ./src/:/app/src
|
||||
|
||||
webpack:
|
||||
build:
|
||||
target: frontend-build
|
||||
context: .
|
||||
dockerfile: ./docker/webpack
|
||||
args:
|
||||
BUILD_ARG: "dev"
|
||||
command: npm run build:watch
|
||||
volumes:
|
||||
- ./src/:/app/src
|
||||
- static-files:/app/src/newsreader/static
|
||||
- node-modules:/app/node_modules
|
||||
|
|
|
|||
|
|
@ -9,7 +9,6 @@ services:
|
|||
django:
|
||||
condition: service_healthy
|
||||
ports:
|
||||
# Note that --env-file should be used to set these correctly
|
||||
- "${NGINX_HTTP_PORT:-80}:80"
|
||||
volumes:
|
||||
- ./config/nginx/conf.d:/etc/nginx/conf.d
|
||||
|
|
|
|||
|
|
@ -4,38 +4,43 @@ volumes:
|
|||
postgres-data:
|
||||
static-files:
|
||||
|
||||
x-db-env: &db-env
|
||||
POSTGRES_HOST:
|
||||
POSTGRES_PORT:
|
||||
POSTGRES_DB:
|
||||
POSTGRES_USER:
|
||||
POSTGRES_PASSWORD:
|
||||
x-db-connection-env: &db-connection-env
|
||||
POSTGRES_HOST: ${POSTGRES_HOST:-db}
|
||||
POSTGRES_PORT: ${POSTGRES_PORT:-5432}
|
||||
POSTGRES_DB: &pg-database ${POSTGRES_DB:-newsreader}
|
||||
POSTGRES_USER: &pg-user ${POSTGRES_USER:-newsreader}
|
||||
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-newsreader}
|
||||
|
||||
x-django-build-env: &django-build-env
|
||||
<<: *db-env
|
||||
DJANGO_SECRET_KEY:
|
||||
DJANGO_SETTINGS_MODULE:
|
||||
x-db-env: &db-env
|
||||
<<: *db-connection-env
|
||||
PGUSER: *pg-user
|
||||
PGDATABASE: *pg-database
|
||||
|
||||
x-django-env: &django-env
|
||||
<<: *django-build-env
|
||||
VERSION:
|
||||
<<: *db-connection-env
|
||||
|
||||
ALLOWED_HOSTS: ${ALLOWED_HOSTS:-localhost,127.0.0.1,django}
|
||||
INTERNAL_IPS: ${INTERNAL_IPS:-localhost,127.0.0.1,django}
|
||||
|
||||
# see token_urlsafe from python's secret module to generate one
|
||||
DJANGO_SECRET_KEY: ${DJANGO_SECRET_KEY:-Ojg68lYsP3kq2r5JgozUzKVSRFywm17BTMS5iwpLM44}
|
||||
DJANGO_SETTINGS_MODULE: ${DJANGO_SETTINGS_MODULE:-newsreader.conf.production}
|
||||
|
||||
ADMINS: ${ADMINS:-""}
|
||||
|
||||
VERSION: ${VERSION:-""}
|
||||
|
||||
# Email
|
||||
EMAIL_HOST:
|
||||
EMAIL_PORT:
|
||||
EMAIL_HOST_USER:
|
||||
EMAIL_HOST_PASSWORD:
|
||||
EMAIL_USE_TLS:
|
||||
EMAIL_USE_SSL:
|
||||
EMAIL_DEFAULT_FROM:
|
||||
|
||||
# Reddit
|
||||
REDDIT_CLIENT_ID:
|
||||
REDDIT_CLIENT_SECRET:
|
||||
REDDIT_CALLBACK_URL:
|
||||
EMAIL_HOST: ${EMAIL_HOST:-localhost}
|
||||
EMAIL_PORT: ${EMAIL_PORT:-25}
|
||||
EMAIL_HOST_USER: ${EMAIL_HOST_USER:-""}
|
||||
EMAIL_HOST_PASSWORD: ${EMAIL_HOST_PASSWORD:-""}
|
||||
EMAIL_USE_TLS: ${EMAIL_USE_TLS:-no}
|
||||
EMAIL_USE_SSL: ${EMAIL_USE_SSL:-no}
|
||||
EMAIL_DEFAULT_FROM: ${EMAIL_DEFAULT_FROM:-webmaster@localhost}
|
||||
|
||||
# Sentry
|
||||
SENTRY_DSN:
|
||||
SENTRY_DSN: ${SENTRY_DSN:-""}
|
||||
|
||||
services:
|
||||
db:
|
||||
|
|
@ -43,8 +48,8 @@ services:
|
|||
<<: *db-env
|
||||
image: postgres:15
|
||||
healthcheck:
|
||||
# Note that --env-file should be used to set these correctly
|
||||
test: /usr/bin/pg_isready --username="${POSTGRES_USER}" --dbname="${POSTGRES_DB}"
|
||||
test: /usr/bin/pg_isready
|
||||
start_period: 10s
|
||||
interval: 5s
|
||||
timeout: 10s
|
||||
retries: 10
|
||||
|
|
@ -60,58 +65,23 @@ services:
|
|||
- memcached
|
||||
- -m 64
|
||||
|
||||
celery:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: ./docker/django
|
||||
target: production
|
||||
args:
|
||||
<<: *django-build-env
|
||||
environment:
|
||||
<<: *django-env
|
||||
command: |
|
||||
/app/.venv/bin/celery --app newsreader
|
||||
--workdir /app/src/
|
||||
worker --loglevel INFO
|
||||
--concurrency 2
|
||||
--beat
|
||||
--scheduler django
|
||||
-n worker1@%h
|
||||
-n worker2@%h
|
||||
healthcheck:
|
||||
test: celery --app newsreader status || exit 1
|
||||
interval: 10s
|
||||
timeout: 10s
|
||||
retries: 5
|
||||
depends_on:
|
||||
rabbitmq:
|
||||
condition: service_started
|
||||
memcached:
|
||||
condition: service_started
|
||||
db:
|
||||
condition: service_healthy
|
||||
django:
|
||||
condition: service_healthy
|
||||
volumes:
|
||||
- logs:/app/logs
|
||||
|
||||
django:
|
||||
build:
|
||||
build: &app-build
|
||||
context: .
|
||||
dockerfile: ./docker/django
|
||||
target: production
|
||||
args:
|
||||
<<: *django-build-env
|
||||
environment:
|
||||
<<: *django-env
|
||||
entrypoint: /app/bin/docker-entrypoint.sh
|
||||
entrypoint: ["/bin/sh", "/app/bin/docker-entrypoint.sh"]
|
||||
command: |
|
||||
/app/.venv/bin/gunicorn --bind 0.0.0.0:8000
|
||||
uv run --no-sync --
|
||||
gunicorn
|
||||
--bind 0.0.0.0:8000
|
||||
--workers 3
|
||||
--chdir /app/src/
|
||||
newsreader.wsgi:application
|
||||
healthcheck:
|
||||
test: /usr/bin/curl --fail http://django:8000 || exit 1
|
||||
start_period: 10s
|
||||
interval: 10s
|
||||
timeout: 10s
|
||||
retries: 5
|
||||
|
|
@ -124,3 +94,33 @@ services:
|
|||
- logs:/app/logs
|
||||
- media:/app/media
|
||||
- static-files:/app/static
|
||||
|
||||
celery:
|
||||
build:
|
||||
<<: *app-build
|
||||
environment:
|
||||
<<: *django-env
|
||||
command: |
|
||||
uv run --no-sync --
|
||||
celery
|
||||
--app newsreader
|
||||
--workdir /app/src/
|
||||
worker --loglevel INFO
|
||||
--concurrency 2
|
||||
--beat
|
||||
--scheduler django
|
||||
-n worker1@%h
|
||||
-n worker2@%h
|
||||
healthcheck:
|
||||
test: uv run --no-sync -- celery --app newsreader status || exit 1
|
||||
start_period: 10s
|
||||
interval: 10s
|
||||
timeout: 10s
|
||||
retries: 5
|
||||
depends_on:
|
||||
rabbitmq:
|
||||
condition: service_started
|
||||
django:
|
||||
condition: service_healthy
|
||||
volumes:
|
||||
- logs:/app/logs
|
||||
|
|
|
|||
102
docker/django
102
docker/django
|
|
@ -1,102 +0,0 @@
|
|||
# stage 1
|
||||
FROM python:3.11-bookworm AS backend
|
||||
|
||||
RUN apt-get update && apt-get install --yes --no-install-recommends \
|
||||
vim \
|
||||
curl \
|
||||
gettext \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
WORKDIR /app
|
||||
RUN mkdir /app/src
|
||||
RUN mkdir /app/logs
|
||||
RUN mkdir /app/media
|
||||
|
||||
COPY uv.lock pyproject.toml /app/
|
||||
|
||||
COPY --from=ghcr.io/astral-sh/uv:latest /uv /bin/uv
|
||||
RUN uv sync --frozen --no-default-groups --no-install-project
|
||||
|
||||
|
||||
# stage 2
|
||||
FROM node:lts AS frontend-build
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY ./*.json ./*.js ./babel.config.js /app/
|
||||
|
||||
RUN npm ci
|
||||
|
||||
COPY ./src /app/src
|
||||
|
||||
RUN npm run build:prod
|
||||
|
||||
|
||||
# stage 3
|
||||
FROM python:3.11-bookworm AS production
|
||||
|
||||
RUN apt-get update && apt-get install --yes --no-install-recommends \
|
||||
postgresql-client \
|
||||
vim \
|
||||
curl \
|
||||
gettext \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
RUN mkdir /app/logs
|
||||
RUN mkdir /app/media
|
||||
RUN mkdir /app/bin
|
||||
|
||||
COPY --from=backend /app/.venv /app/.venv
|
||||
COPY --from=backend /app/uv.lock /app/pyproject.toml /app/
|
||||
COPY --from=backend /bin/uv /bin/uv
|
||||
|
||||
COPY ./bin/docker-entrypoint.sh /app/bin/docker-entrypoint.sh
|
||||
|
||||
COPY --from=frontend-build /app/src/newsreader/static /app/src/newsreader/static
|
||||
|
||||
COPY ./src /app/src
|
||||
|
||||
RUN uv sync --frozen --only-group production --extra sentry
|
||||
|
||||
RUN useradd --no-create-home --uid 1000 newsreader
|
||||
RUN chown --recursive newsreader:newsreader /app
|
||||
|
||||
USER newsreader
|
||||
|
||||
ARG POSTGRES_HOST
|
||||
ARG POSTGRES_PORT
|
||||
ARG POSTGRES_DB
|
||||
ARG POSTGRES_USER
|
||||
ARG POSTGRES_PASSWORD
|
||||
ARG DJANGO_SECRET_KEY
|
||||
ARG DJANGO_SETTINGS_MODULE
|
||||
|
||||
# Note that the static volume will have to be recreated to be pre-populated
|
||||
# correctly with the latest static files. See
|
||||
# https://docs.docker.com/storage/volumes/#populate-a-volume-using-a-container
|
||||
RUN /app/.venv/bin/python src/manage.py collectstatic --noinput
|
||||
|
||||
|
||||
# (optional) stage 4
|
||||
FROM python:3.11-bookworm AS development
|
||||
|
||||
RUN apt-get update && apt-get install --yes --no-install-recommends \
|
||||
vim \
|
||||
curl \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
RUN mkdir /app/logs
|
||||
RUN mkdir /app/media
|
||||
RUN mkdir /app/bin
|
||||
|
||||
COPY --from=backend /app/.venv /app/.venv
|
||||
COPY --from=backend /app/uv.lock /app/pyproject.toml /app/
|
||||
COPY ./bin/docker-entrypoint.sh /app/bin/docker-entrypoint.sh
|
||||
COPY --from=backend /app/src/ /app/src/
|
||||
|
||||
COPY --from=backend /bin/uv /bin/uv
|
||||
RUN uv sync --frozen --group development
|
||||
|
|
@ -1,10 +0,0 @@
|
|||
FROM node:lts
|
||||
|
||||
WORKDIR /app
|
||||
RUN mkdir /app/src
|
||||
|
||||
COPY package*.json webpack.*.js babel.config.js /app/
|
||||
|
||||
RUN npm install
|
||||
|
||||
COPY ./src /app/src
|
||||
|
|
@ -1,6 +0,0 @@
|
|||
module.exports = {
|
||||
roots: ['src/newsreader/js/tests/'],
|
||||
|
||||
clearMocks: true,
|
||||
coverageDirectory: 'coverage',
|
||||
};
|
||||
4
package-lock.json
generated
4
package-lock.json
generated
|
|
@ -1,12 +1,12 @@
|
|||
{
|
||||
"name": "newsreader",
|
||||
"version": "0.4.4",
|
||||
"version": "0.5.3",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "newsreader",
|
||||
"version": "0.4.4",
|
||||
"version": "0.5.3",
|
||||
"license": "GPL-3.0-or-later",
|
||||
"dependencies": {
|
||||
"@fortawesome/fontawesome-free": "^5.15.2",
|
||||
|
|
|
|||
22
package.json
22
package.json
|
|
@ -2,19 +2,18 @@
|
|||
"name": "newsreader",
|
||||
"version": "0.5.3",
|
||||
"description": "Application for viewing RSS feeds",
|
||||
"main": "index.js",
|
||||
"scripts": {
|
||||
"lint": "npx prettier \"src/newsreader/js/**/*.js\" --check",
|
||||
"format": "npx prettier \"src/newsreader/js/**/*.js\" --write",
|
||||
"build": "npx webpack --config webpack.dev.babel.js",
|
||||
"build:watch": "npx webpack --config webpack.dev.babel.js --watch",
|
||||
"build:dev": "npx webpack --config webpack.dev.babel.js",
|
||||
"build:prod": "npx webpack --config webpack.prod.babel.js",
|
||||
"test": "npx jest",
|
||||
"test:watch": "npm test -- --watch"
|
||||
},
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "[git@git.fudiggity.nl:5000]:sonny/newsreader.git"
|
||||
"url": "forgejo.fudiggity.nl:sonny/newsreader"
|
||||
},
|
||||
"author": "Sonny",
|
||||
"license": "GPL-3.0-or-later",
|
||||
|
|
@ -55,5 +54,22 @@
|
|||
"webpack": "^5.94.0",
|
||||
"webpack-cli": "^5.1.4",
|
||||
"webpack-merge": "^4.2.2"
|
||||
},
|
||||
"prettier": {
|
||||
"semi": true,
|
||||
"trailingComma": "es5",
|
||||
"singleQuote": true,
|
||||
"printWidth": 90,
|
||||
"tabWidth": 2,
|
||||
"useTabs": false,
|
||||
"bracketSpacing": true,
|
||||
"arrowParens": "avoid"
|
||||
},
|
||||
"jest": {
|
||||
"roots": [
|
||||
"src/newsreader/js/tests/"
|
||||
],
|
||||
"clearMocks": true,
|
||||
"coverageDirectory": "coverage"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,66 +1,81 @@
|
|||
[project]
|
||||
name = 'newsreader'
|
||||
version = '0.5.3'
|
||||
authors = [{ name = 'Sonny', email= 'sonny871@hotmail.com' }]
|
||||
license = {text = 'GPL-3.0'}
|
||||
requires-python = '>=3.11'
|
||||
name = "newsreader"
|
||||
version = "0.5.3"
|
||||
authors = [{ name = "Sonny" }]
|
||||
license = { text = "GPL-3.0" }
|
||||
requires-python = ">=3.11"
|
||||
dependencies = [
|
||||
'django~=4.2',
|
||||
'celery~=5.4',
|
||||
'psycopg',
|
||||
'django-axes',
|
||||
'django-celery-beat~=2.7.0',
|
||||
'django-rest-framework',
|
||||
'djangorestframework-camel-case',
|
||||
'pymemcache',
|
||||
'python-dotenv~=1.0.1',
|
||||
'ftfy~=6.2',
|
||||
'requests',
|
||||
'feedparser',
|
||||
'bleach',
|
||||
'beautifulsoup4',
|
||||
'lxml',
|
||||
"django~=4.2",
|
||||
"celery~=5.4",
|
||||
"psycopg[binary]",
|
||||
"django-axes",
|
||||
"django-celery-beat~=2.7.0",
|
||||
"django-rest-framework",
|
||||
"djangorestframework-camel-case",
|
||||
"pymemcache",
|
||||
"python-dotenv~=1.0.1",
|
||||
"ftfy~=6.2",
|
||||
"requests",
|
||||
"feedparser",
|
||||
"bleach",
|
||||
"beautifulsoup4",
|
||||
"lxml",
|
||||
]
|
||||
|
||||
[dependency-groups]
|
||||
test-tools = ['ruff', 'factory_boy', 'freezegun']
|
||||
test-tools = ["ruff", "factory_boy", "freezegun"]
|
||||
development = [
|
||||
'django-debug-toolbar',
|
||||
'django-stubs',
|
||||
'django-extensions',
|
||||
"django-debug-toolbar",
|
||||
"django-stubs",
|
||||
"django-extensions",
|
||||
]
|
||||
ci = ['coverage~=7.6.1']
|
||||
production = ['gunicorn~=23.0']
|
||||
ci = ["coverage~=7.6.1"]
|
||||
production = ["gunicorn~=23.0"]
|
||||
|
||||
[project.optional-dependencies]
|
||||
sentry = ['sentry-sdk~=2.0']
|
||||
sentry = ["sentry-sdk~=2.0"]
|
||||
|
||||
[tool.uv]
|
||||
environments = ["sys_platform == 'linux'"]
|
||||
default-groups = ['test-tools']
|
||||
default-groups = ["test-tools"]
|
||||
|
||||
[tool.ruff]
|
||||
include = ['pyproject.toml', 'src/**/*.py']
|
||||
include = ["pyproject.toml", "src/**/*.py"]
|
||||
|
||||
line-length = 88
|
||||
|
||||
[tool.ruff.lint]
|
||||
select = ['E4', 'E7', 'E9', 'F', 'I']
|
||||
select = ["E4", "E7", "E9", "F", "I"]
|
||||
|
||||
[tool.ruff.lint.isort]
|
||||
lines-between-types=1
|
||||
lines-after-imports=2
|
||||
|
||||
default-section = 'third-party'
|
||||
known-first-party = ['transip_client']
|
||||
default-section = "third-party"
|
||||
known-first-party = ["newsreader"]
|
||||
section-order = [
|
||||
'future',
|
||||
'standard-library',
|
||||
'django',
|
||||
'third-party',
|
||||
'first-party',
|
||||
'local-folder',
|
||||
"future",
|
||||
"standard-library",
|
||||
"django",
|
||||
"third-party",
|
||||
"first-party",
|
||||
"local-folder",
|
||||
]
|
||||
|
||||
[tool.ruff.lint.isort.sections]
|
||||
django = ['django']
|
||||
django = ["django"]
|
||||
|
||||
[tool.coverage.run]
|
||||
source = ["./src/newsreader/"]
|
||||
omit = [
|
||||
"**/tests/**",
|
||||
"**/migrations/**",
|
||||
"**/conf/**",
|
||||
"**/apps.py",
|
||||
"**/admin.py",
|
||||
"**/tests.py",
|
||||
"**/urls.py",
|
||||
"**/wsgi.py",
|
||||
"**/celery.py",
|
||||
"**/__init__.py"
|
||||
]
|
||||
|
|
|
|||
|
|
@ -11,12 +11,6 @@ class UserAdminForm(UserChangeForm):
|
|||
class Meta:
|
||||
widgets = {
|
||||
"email": forms.EmailInput(attrs={"size": "50"}),
|
||||
"reddit_access_token": forms.PasswordInput(
|
||||
attrs={"size": "90"}, render_value=True
|
||||
),
|
||||
"reddit_refresh_token": forms.PasswordInput(
|
||||
attrs={"size": "90"}, render_value=True
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
|
|
@ -34,10 +28,6 @@ class UserAdmin(DjangoUserAdmin):
|
|||
_("User settings"),
|
||||
{"fields": ("email", "password", "first_name", "last_name", "is_active")},
|
||||
),
|
||||
(
|
||||
_("Reddit settings"),
|
||||
{"fields": ("reddit_access_token", "reddit_refresh_token")},
|
||||
),
|
||||
(
|
||||
_("Permission settings"),
|
||||
{"classes": ("collapse",), "fields": ("is_staff", "is_superuser")},
|
||||
|
|
|
|||
|
|
@ -0,0 +1,20 @@
|
|||
# Generated by Django 4.2.16 on 2025-03-26 08:46
|
||||
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
("accounts", "0017_auto_20240906_0914"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RemoveField(
|
||||
model_name="user",
|
||||
name="reddit_access_token",
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name="user",
|
||||
name="reddit_refresh_token",
|
||||
),
|
||||
]
|
||||
|
|
@ -39,10 +39,6 @@ class UserManager(DjangoUserManager):
|
|||
class User(AbstractUser):
|
||||
email = models.EmailField(_("email address"), unique=True)
|
||||
|
||||
# reddit settings
|
||||
reddit_refresh_token = models.CharField(max_length=255, blank=True, null=True)
|
||||
reddit_access_token = models.CharField(max_length=255, blank=True, null=True)
|
||||
|
||||
# settings
|
||||
auto_mark_read = models.BooleanField(
|
||||
_("Auto read marking"),
|
||||
|
|
|
|||
|
|
@ -2,27 +2,23 @@
|
|||
{% load i18n %}
|
||||
|
||||
{% block actions %}
|
||||
<section class="section form__section--last">
|
||||
<fieldset class="fieldset form__fieldset">
|
||||
{% include "components/form/confirm-button.html" %}
|
||||
<section class="section form__section--last">
|
||||
<fieldset class="fieldset form__fieldset">
|
||||
{% include "components/form/confirm-button.html" %}
|
||||
|
||||
<a class="link button button--primary" href="{% url 'accounts:password-change' %}">
|
||||
{% trans "Change password" %}
|
||||
</a>
|
||||
<a class="link button button--primary" href="{% url 'accounts:password-change' %}">
|
||||
{% trans "Change password" %}
|
||||
</a>
|
||||
|
||||
{% if favicon_task_allowed %}
|
||||
<a class="link button button--primary" href="{% url 'accounts:settings:favicon' %}">
|
||||
{% trans "Fetch favicons" %}
|
||||
</a>
|
||||
{% else %}
|
||||
<button class="button button--primary button--disabled" disabled>
|
||||
{% trans "Fetch favicons" %}
|
||||
</button>
|
||||
{% endif %}
|
||||
|
||||
<a class="link button button--primary" href="{% url 'accounts:settings:integrations' %}">
|
||||
{% trans "Third party integrations" %}
|
||||
</a>
|
||||
</fieldset>
|
||||
</section>
|
||||
{% if favicon_task_allowed %}
|
||||
<a class="link button button--primary" href="{% url 'accounts:settings:favicon' %}">
|
||||
{% trans "Fetch favicons" %}
|
||||
</a>
|
||||
{% else %}
|
||||
<button class="button button--primary button--disabled" disabled>
|
||||
{% trans "Fetch favicons" %}
|
||||
</button>
|
||||
{% endif %}
|
||||
</fieldset>
|
||||
</section>
|
||||
{% endblock actions %}
|
||||
|
|
|
|||
|
|
@ -1,47 +0,0 @@
|
|||
{% extends "sidebar.html" %}
|
||||
{% load i18n %}
|
||||
|
||||
{% block content %}
|
||||
<main id="integrations--page" class="main" data-render-sidebar=true>
|
||||
<div class="main__container">
|
||||
<section class="section">
|
||||
{% include "components/header/header.html" with title="Integrations" only %}
|
||||
|
||||
<div class="integrations">
|
||||
<h3 class="integrations__title">Reddit</h3>
|
||||
<div class="integrations__controls">
|
||||
{% if reddit_authorization_url %}
|
||||
<a class="link button button--reddit" href="{{ reddit_authorization_url }}">
|
||||
{% trans "Authorize account" %}
|
||||
</a>
|
||||
{% else %}
|
||||
<button class="button button--reddit button--disabled" disabled>
|
||||
{% trans "Authorize account" %}
|
||||
</button>
|
||||
{% endif %}
|
||||
|
||||
{% if reddit_refresh_url %}
|
||||
<a class="link button button--reddit" href="{{ reddit_refresh_url }}">
|
||||
{% trans "Refresh token" %}
|
||||
</a>
|
||||
{% else %}
|
||||
<button class="button button--reddit button--disabled" disabled>
|
||||
{% trans "Refresh token" %}
|
||||
</button>
|
||||
{% endif %}
|
||||
|
||||
{% if reddit_revoke_url %}
|
||||
<a class="link button button--reddit" href="{{ reddit_revoke_url }}">
|
||||
{% trans "Deauthorize account" %}
|
||||
</a>
|
||||
{% else %}
|
||||
<button class="button button--reddit button--disabled" disabled>
|
||||
{% trans "Deauthorize account" %}
|
||||
</button>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</main>
|
||||
{% endblock %}
|
||||
|
|
@ -1,22 +0,0 @@
|
|||
{% extends "sidebar.html" %}
|
||||
{% load i18n %}
|
||||
|
||||
{% block content %}
|
||||
<main id="reddit--page" class="main" data-render-sidebar=true>
|
||||
<div class="main__container">
|
||||
<section class="section text-section">
|
||||
{% if error %}
|
||||
<h1 class="h1">{% trans "Reddit authorization failed" %}</h1>
|
||||
<p>{{ error }}</p>
|
||||
{% elif access_token and refresh_token %}
|
||||
<h1 class="h1">{% trans "Reddit account is linked" %}</h1>
|
||||
<p>{% trans "Your reddit account was successfully linked." %}</p>
|
||||
{% endif %}
|
||||
|
||||
<p>
|
||||
<a class="link" href="{% url 'accounts:settings:integrations' %}">{% trans "Return to integrations page" %}</a>
|
||||
</p>
|
||||
</section>
|
||||
</div>
|
||||
</main>
|
||||
{% endblock %}
|
||||
|
|
@ -1,275 +0,0 @@
|
|||
from unittest.mock import patch
|
||||
from urllib.parse import urlencode
|
||||
from uuid import uuid4
|
||||
|
||||
from django.core.cache import cache
|
||||
from django.test import TestCase
|
||||
from django.urls import reverse
|
||||
|
||||
from bs4 import BeautifulSoup
|
||||
|
||||
from newsreader.accounts.tests.factories import UserFactory
|
||||
from newsreader.news.collection.exceptions import (
|
||||
StreamException,
|
||||
StreamTooManyException,
|
||||
)
|
||||
|
||||
|
||||
class IntegrationsViewTestCase(TestCase):
|
||||
def setUp(self):
|
||||
self.user = UserFactory(email="test@test.nl", password="test")
|
||||
self.client.force_login(self.user)
|
||||
|
||||
self.url = reverse("accounts:settings:integrations")
|
||||
|
||||
|
||||
class RedditIntegrationsTestCase(IntegrationsViewTestCase):
|
||||
def test_reddit_authorization(self):
|
||||
self.user.reddit_refresh_token = None
|
||||
self.user.save()
|
||||
|
||||
response = self.client.get(self.url)
|
||||
|
||||
soup = BeautifulSoup(response.content, features="lxml")
|
||||
button = soup.find("a", class_="link button button--reddit")
|
||||
|
||||
self.assertEquals(button.text.strip(), "Authorize account")
|
||||
|
||||
def test_reddit_refresh_token(self):
|
||||
self.user.reddit_refresh_token = "jadajadajada"
|
||||
self.user.reddit_access_token = None
|
||||
self.user.save()
|
||||
|
||||
response = self.client.get(self.url)
|
||||
|
||||
soup = BeautifulSoup(response.content, features="lxml")
|
||||
button = soup.find("a", class_="link button button--reddit")
|
||||
|
||||
self.assertEquals(button.text.strip(), "Refresh token")
|
||||
|
||||
def test_reddit_revoke(self):
|
||||
self.user.reddit_refresh_token = "jadajadajada"
|
||||
self.user.reddit_access_token = None
|
||||
self.user.save()
|
||||
|
||||
response = self.client.get(self.url)
|
||||
|
||||
soup = BeautifulSoup(response.content, features="lxml")
|
||||
buttons = soup.find_all("a", class_="link button button--reddit")
|
||||
|
||||
self.assertIn(
|
||||
"Deauthorize account", [button.text.strip() for button in buttons]
|
||||
)
|
||||
|
||||
|
||||
class RedditTemplateViewTestCase(TestCase):
|
||||
def setUp(self):
|
||||
self.user = UserFactory(email="test@test.nl", password="test")
|
||||
self.client.force_login(self.user)
|
||||
|
||||
self.base_url = reverse("accounts:settings:reddit-template")
|
||||
self.state = str(uuid4())
|
||||
|
||||
self.patch = patch("newsreader.news.collection.reddit.post")
|
||||
self.mocked_post = self.patch.start()
|
||||
|
||||
def tearDown(self):
|
||||
patch.stopall()
|
||||
|
||||
def test_simple(self):
|
||||
response = self.client.get(self.base_url)
|
||||
|
||||
self.assertEquals(response.status_code, 200)
|
||||
self.assertContains(response, "Return to integrations page")
|
||||
|
||||
def test_successful_authorization(self):
|
||||
self.mocked_post.return_value.json.return_value = {
|
||||
"access_token": "1001010412",
|
||||
"refresh_token": "134510143",
|
||||
}
|
||||
|
||||
cache.set(f"{self.user.email}-reddit-auth", self.state)
|
||||
|
||||
params = {"state": self.state, "code": "Valid code"}
|
||||
url = f"{self.base_url}?{urlencode(params)}"
|
||||
|
||||
response = self.client.get(url)
|
||||
|
||||
self.mocked_post.assert_called_once()
|
||||
|
||||
self.assertEquals(response.status_code, 200)
|
||||
self.assertContains(response, "Your reddit account was successfully linked.")
|
||||
|
||||
self.user.refresh_from_db()
|
||||
|
||||
self.assertEquals(self.user.reddit_access_token, "1001010412")
|
||||
self.assertEquals(self.user.reddit_refresh_token, "134510143")
|
||||
|
||||
self.assertEquals(cache.get(f"{self.user.email}-reddit-auth"), None)
|
||||
|
||||
def test_error(self):
|
||||
params = {"error": "Denied authorization"}
|
||||
|
||||
url = f"{self.base_url}?{urlencode(params)}"
|
||||
|
||||
response = self.client.get(url)
|
||||
|
||||
self.assertEquals(response.status_code, 200)
|
||||
self.assertContains(response, "Denied authorization")
|
||||
|
||||
def test_invalid_state(self):
|
||||
cache.set(f"{self.user.email}-reddit-auth", str(uuid4()))
|
||||
|
||||
params = {"code": "Valid code", "state": "Invalid state"}
|
||||
|
||||
url = f"{self.base_url}?{urlencode(params)}"
|
||||
|
||||
response = self.client.get(url)
|
||||
|
||||
self.assertEquals(response.status_code, 200)
|
||||
self.assertContains(
|
||||
response, "The saved state for Reddit authorization did not match"
|
||||
)
|
||||
|
||||
def test_stream_error(self):
|
||||
self.mocked_post.side_effect = StreamTooManyException
|
||||
|
||||
cache.set(f"{self.user.email}-reddit-auth", self.state)
|
||||
|
||||
params = {"state": self.state, "code": "Valid code"}
|
||||
url = f"{self.base_url}?{urlencode(params)}"
|
||||
|
||||
response = self.client.get(url)
|
||||
|
||||
self.mocked_post.assert_called_once()
|
||||
|
||||
self.assertEquals(response.status_code, 200)
|
||||
self.assertContains(response, "Too many requests")
|
||||
|
||||
self.user.refresh_from_db()
|
||||
|
||||
self.assertEquals(self.user.reddit_access_token, None)
|
||||
self.assertEquals(self.user.reddit_refresh_token, None)
|
||||
|
||||
self.assertEquals(cache.get(f"{self.user.email}-reddit-auth"), self.state)
|
||||
|
||||
def test_unexpected_json(self):
|
||||
self.mocked_post.return_value.json.return_value = {"message": "Happy eastern"}
|
||||
|
||||
cache.set(f"{self.user.email}-reddit-auth", self.state)
|
||||
|
||||
params = {"state": self.state, "code": "Valid code"}
|
||||
url = f"{self.base_url}?{urlencode(params)}"
|
||||
|
||||
response = self.client.get(url)
|
||||
|
||||
self.mocked_post.assert_called_once()
|
||||
|
||||
self.assertEquals(response.status_code, 200)
|
||||
self.assertContains(response, "Access and refresh token not found in response")
|
||||
|
||||
self.user.refresh_from_db()
|
||||
|
||||
self.assertEquals(self.user.reddit_access_token, None)
|
||||
self.assertEquals(self.user.reddit_refresh_token, None)
|
||||
|
||||
self.assertEquals(cache.get(f"{self.user.email}-reddit-auth"), self.state)
|
||||
|
||||
|
||||
class RedditTokenRedirectViewTestCase(TestCase):
|
||||
def setUp(self):
|
||||
self.user = UserFactory(email="test@test.nl", password="test")
|
||||
self.client.force_login(self.user)
|
||||
|
||||
self.patch = patch("newsreader.accounts.views.integrations.RedditTokenTask")
|
||||
self.mocked_task = self.patch.start()
|
||||
|
||||
def tearDown(self):
|
||||
cache.clear()
|
||||
|
||||
def test_simple(self):
|
||||
response = self.client.get(reverse("accounts:settings:reddit-refresh"))
|
||||
|
||||
self.assertRedirects(response, reverse("accounts:settings:integrations"))
|
||||
|
||||
self.mocked_task.delay.assert_called_once_with(self.user.pk)
|
||||
|
||||
self.assertEquals(1, cache.get(f"{self.user.email}-reddit-refresh"))
|
||||
|
||||
def test_not_active(self):
|
||||
cache.set(f"{self.user.email}-reddit-refresh", 1)
|
||||
|
||||
response = self.client.get(reverse("accounts:settings:reddit-refresh"))
|
||||
|
||||
self.assertRedirects(response, reverse("accounts:settings:integrations"))
|
||||
|
||||
self.mocked_task.delay.assert_not_called()
|
||||
|
||||
|
||||
class RedditRevokeRedirectViewTestCase(TestCase):
|
||||
def setUp(self):
|
||||
self.user = UserFactory(email="test@test.nl", password="test")
|
||||
self.client.force_login(self.user)
|
||||
|
||||
self.patch = patch("newsreader.accounts.views.integrations.revoke_reddit_token")
|
||||
self.mocked_revoke = self.patch.start()
|
||||
|
||||
def test_simple(self):
|
||||
self.user.reddit_access_token = "jadajadajada"
|
||||
self.user.reddit_refresh_token = "jadajadajada"
|
||||
self.user.save()
|
||||
|
||||
self.mocked_revoke.return_value = True
|
||||
|
||||
response = self.client.get(reverse("accounts:settings:reddit-revoke"))
|
||||
|
||||
self.assertRedirects(response, reverse("accounts:settings:integrations"))
|
||||
|
||||
self.mocked_revoke.assert_called_once_with(self.user)
|
||||
|
||||
self.user.refresh_from_db()
|
||||
|
||||
self.assertEquals(self.user.reddit_access_token, None)
|
||||
self.assertEquals(self.user.reddit_refresh_token, None)
|
||||
|
||||
def test_no_refresh_token(self):
|
||||
self.user.reddit_refresh_token = None
|
||||
self.user.save()
|
||||
|
||||
response = self.client.get(reverse("accounts:settings:reddit-revoke"))
|
||||
|
||||
self.assertRedirects(response, reverse("accounts:settings:integrations"))
|
||||
|
||||
self.mocked_revoke.assert_not_called()
|
||||
|
||||
def test_unsuccessful_response(self):
|
||||
self.user.reddit_access_token = "jadajadajada"
|
||||
self.user.reddit_refresh_token = "jadajadajada"
|
||||
self.user.save()
|
||||
|
||||
self.mocked_revoke.return_value = False
|
||||
|
||||
response = self.client.get(reverse("accounts:settings:reddit-revoke"))
|
||||
|
||||
self.assertRedirects(response, reverse("accounts:settings:integrations"))
|
||||
|
||||
self.user.refresh_from_db()
|
||||
|
||||
self.assertEquals(self.user.reddit_access_token, "jadajadajada")
|
||||
self.assertEquals(self.user.reddit_refresh_token, "jadajadajada")
|
||||
|
||||
def test_stream_exception(self):
|
||||
self.user.reddit_access_token = "jadajadajada"
|
||||
self.user.reddit_refresh_token = "jadajadajada"
|
||||
self.user.save()
|
||||
|
||||
self.mocked_revoke.side_effect = StreamException
|
||||
|
||||
response = self.client.get(reverse("accounts:settings:reddit-revoke"))
|
||||
|
||||
self.assertRedirects(response, reverse("accounts:settings:integrations"))
|
||||
|
||||
self.user.refresh_from_db()
|
||||
|
||||
self.assertEquals(self.user.reddit_access_token, "jadajadajada")
|
||||
self.assertEquals(self.user.reddit_refresh_token, "jadajadajada")
|
||||
|
|
@ -3,7 +3,6 @@ from django.urls import include, path
|
|||
|
||||
from newsreader.accounts.views import (
|
||||
FaviconRedirectView,
|
||||
IntegrationsView,
|
||||
LoginView,
|
||||
LogoutView,
|
||||
PasswordChangeView,
|
||||
|
|
@ -11,33 +10,11 @@ from newsreader.accounts.views import (
|
|||
PasswordResetConfirmView,
|
||||
PasswordResetDoneView,
|
||||
PasswordResetView,
|
||||
RedditRevokeRedirectView,
|
||||
RedditTemplateView,
|
||||
RedditTokenRedirectView,
|
||||
SettingsView,
|
||||
)
|
||||
|
||||
|
||||
settings_patterns = [
|
||||
# Integrations
|
||||
path(
|
||||
"integrations/reddit/callback/",
|
||||
login_required(RedditTemplateView.as_view()),
|
||||
name="reddit-template",
|
||||
),
|
||||
path(
|
||||
"integrations/reddit/refresh/",
|
||||
login_required(RedditTokenRedirectView.as_view()),
|
||||
name="reddit-refresh",
|
||||
),
|
||||
path(
|
||||
"integrations/reddit/revoke/",
|
||||
login_required(RedditRevokeRedirectView.as_view()),
|
||||
name="reddit-revoke",
|
||||
),
|
||||
path(
|
||||
"integrations/", login_required(IntegrationsView.as_view()), name="integrations"
|
||||
),
|
||||
# Misc
|
||||
path("favicon/", login_required(FaviconRedirectView.as_view()), name="favicon"),
|
||||
path("", login_required(SettingsView.as_view()), name="home"),
|
||||
|
|
|
|||
|
|
@ -1,11 +1,5 @@
|
|||
from newsreader.accounts.views.auth import LoginView, LogoutView
|
||||
from newsreader.accounts.views.favicon import FaviconRedirectView
|
||||
from newsreader.accounts.views.integrations import (
|
||||
IntegrationsView,
|
||||
RedditRevokeRedirectView,
|
||||
RedditTemplateView,
|
||||
RedditTokenRedirectView,
|
||||
)
|
||||
from newsreader.accounts.views.password import (
|
||||
PasswordChangeView,
|
||||
PasswordResetCompleteView,
|
||||
|
|
@ -13,17 +7,13 @@ from newsreader.accounts.views.password import (
|
|||
PasswordResetDoneView,
|
||||
PasswordResetView,
|
||||
)
|
||||
|
||||
from newsreader.accounts.views.settings import SettingsView
|
||||
|
||||
|
||||
__all__ = [
|
||||
"LoginView",
|
||||
"LogoutView",
|
||||
"FaviconRedirectView",
|
||||
"IntegrationsView",
|
||||
"RedditRevokeRedirectView",
|
||||
"RedditTemplateView",
|
||||
"RedditTokenRedirectView",
|
||||
"PasswordChangeView",
|
||||
"PasswordResetCompleteView",
|
||||
"PasswordResetConfirmView",
|
||||
|
|
|
|||
|
|
@ -1,156 +0,0 @@
|
|||
import logging
|
||||
|
||||
from django.contrib import messages
|
||||
from django.core.cache import cache
|
||||
from django.urls import reverse_lazy
|
||||
from django.utils.translation import gettext as _
|
||||
from django.views.generic import RedirectView, TemplateView
|
||||
|
||||
from newsreader.news.collection.exceptions import StreamException
|
||||
from newsreader.news.collection.reddit import (
|
||||
get_reddit_access_token,
|
||||
get_reddit_authorization_url,
|
||||
revoke_reddit_token,
|
||||
)
|
||||
from newsreader.news.collection.tasks import RedditTokenTask
|
||||
from newsreader.utils.views import NavListMixin
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class IntegrationsView(NavListMixin, TemplateView):
|
||||
template_name = "accounts/views/integrations.html"
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
return {
|
||||
**super().get_context_data(**kwargs),
|
||||
**self.get_reddit_context(**kwargs),
|
||||
}
|
||||
|
||||
def get_reddit_context(self, **kwargs):
|
||||
user = self.request.user
|
||||
reddit_authorization_url = None
|
||||
reddit_refresh_url = None
|
||||
|
||||
reddit_task_active = cache.get(f"{user.email}-reddit-refresh")
|
||||
|
||||
if (
|
||||
user.reddit_refresh_token
|
||||
and not user.reddit_access_token
|
||||
and not reddit_task_active
|
||||
):
|
||||
reddit_refresh_url = reverse_lazy("accounts:settings:reddit-refresh")
|
||||
|
||||
if not user.reddit_refresh_token:
|
||||
reddit_authorization_url = get_reddit_authorization_url(user)
|
||||
|
||||
return {
|
||||
"reddit_authorization_url": reddit_authorization_url,
|
||||
"reddit_refresh_url": reddit_refresh_url,
|
||||
"reddit_revoke_url": (
|
||||
reverse_lazy("accounts:settings:reddit-revoke")
|
||||
if not reddit_authorization_url
|
||||
else None
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
class RedditTemplateView(NavListMixin, TemplateView):
|
||||
template_name = "accounts/views/reddit.html"
|
||||
|
||||
def get(self, request, *args, **kwargs):
|
||||
context = self.get_context_data(**kwargs)
|
||||
|
||||
error = request.GET.get("error", None)
|
||||
state = request.GET.get("state", None)
|
||||
code = request.GET.get("code", None)
|
||||
|
||||
if error:
|
||||
return self.render_to_response({**context, "error": error})
|
||||
|
||||
if not code or not state:
|
||||
return self.render_to_response(context)
|
||||
|
||||
cached_state = cache.get(f"{request.user.email}-reddit-auth")
|
||||
|
||||
if state != cached_state:
|
||||
return self.render_to_response(
|
||||
{
|
||||
**context,
|
||||
"error": _(
|
||||
"The saved state for Reddit authorization did not match"
|
||||
),
|
||||
}
|
||||
)
|
||||
|
||||
try:
|
||||
access_token, refresh_token = get_reddit_access_token(code, request.user)
|
||||
|
||||
return self.render_to_response(
|
||||
{
|
||||
**context,
|
||||
"access_token": access_token,
|
||||
"refresh_token": refresh_token,
|
||||
}
|
||||
)
|
||||
except StreamException as e:
|
||||
return self.render_to_response({**context, "error": str(e)})
|
||||
except KeyError:
|
||||
return self.render_to_response(
|
||||
{
|
||||
**context,
|
||||
"error": _("Access and refresh token not found in response"),
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
class RedditTokenRedirectView(RedirectView):
|
||||
url = reverse_lazy("accounts:settings:integrations")
|
||||
|
||||
def get(self, request, *args, **kwargs):
|
||||
response = super().get(request, *args, **kwargs)
|
||||
|
||||
user = request.user
|
||||
task_active = cache.get(f"{user.email}-reddit-refresh")
|
||||
|
||||
if not task_active:
|
||||
RedditTokenTask.delay(user.pk)
|
||||
messages.success(request, _("Access token is being retrieved"))
|
||||
cache.set(f"{user.email}-reddit-refresh", 1, 300)
|
||||
return response
|
||||
|
||||
messages.error(request, _("Unable to retrieve token"))
|
||||
return response
|
||||
|
||||
|
||||
class RedditRevokeRedirectView(RedirectView):
|
||||
url = reverse_lazy("accounts:settings:integrations")
|
||||
|
||||
def get(self, request, *args, **kwargs):
|
||||
response = super().get(request, *args, **kwargs)
|
||||
|
||||
user = request.user
|
||||
|
||||
if not user.reddit_refresh_token:
|
||||
messages.error(request, _("No reddit account is linked to this account"))
|
||||
return response
|
||||
|
||||
try:
|
||||
is_revoked = revoke_reddit_token(user)
|
||||
except StreamException:
|
||||
logger.exception(f"Unable to revoke reddit token for {user.pk}")
|
||||
|
||||
messages.error(request, _("Unable to revoke reddit token"))
|
||||
return response
|
||||
|
||||
if not is_revoked:
|
||||
messages.error(request, _("Unable to revoke reddit token"))
|
||||
return response
|
||||
|
||||
user.reddit_access_token = None
|
||||
user.reddit_refresh_token = None
|
||||
user.save()
|
||||
|
||||
messages.success(request, _("Reddit account deathorized"))
|
||||
return response
|
||||
|
|
@ -1,9 +1,7 @@
|
|||
import os
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from dotenv import load_dotenv
|
||||
|
||||
from newsreader.conf.utils import get_env, get_root_dir
|
||||
|
||||
|
||||
load_dotenv()
|
||||
|
||||
|
|
@ -15,16 +13,13 @@ except ImportError:
|
|||
DjangoIntegration = None
|
||||
|
||||
|
||||
BASE_DIR = Path(__file__).resolve().parent.parent.parent.parent
|
||||
BASE_DIR = get_root_dir()
|
||||
DJANGO_PROJECT_DIR = BASE_DIR / "src" / "newsreader"
|
||||
|
||||
# Quick-start development settings - unsuitable for production
|
||||
# See https://docs.djangoproject.com/en/2.2/howto/deployment/checklist/
|
||||
# SECURITY WARNING: don"t run with debug turned on in production!
|
||||
DEBUG = False
|
||||
|
||||
ALLOWED_HOSTS = ["127.0.0.1", "localhost"]
|
||||
INTERNAL_IPS = ["127.0.0.1", "localhost"]
|
||||
ALLOWED_HOSTS = get_env("ALLOWED_HOSTS", split=",", default=["127.0.0.1", "localhost"])
|
||||
INTERNAL_IPS = get_env("INTERNAL_IPS", split=",", default=["127.0.0.1", "localhost"])
|
||||
|
||||
# Application definition
|
||||
INSTALLED_APPS = [
|
||||
|
|
@ -48,7 +43,7 @@ INSTALLED_APPS = [
|
|||
"newsreader.news.collection",
|
||||
]
|
||||
|
||||
SECRET_KEY = os.environ["DJANGO_SECRET_KEY"]
|
||||
SECRET_KEY = get_env("DJANGO_SECRET_KEY", default="")
|
||||
|
||||
AUTHENTICATION_BACKENDS = [
|
||||
"axes.backends.AxesBackend",
|
||||
|
|
@ -73,11 +68,10 @@ FORM_RENDERER = "django.forms.renderers.TemplatesSetting"
|
|||
TEMPLATES = [
|
||||
{
|
||||
"BACKEND": "django.template.backends.django.DjangoTemplates",
|
||||
"DIRS": [os.path.join(DJANGO_PROJECT_DIR, "templates")],
|
||||
"DIRS": [DJANGO_PROJECT_DIR / "templates"],
|
||||
"APP_DIRS": True,
|
||||
"OPTIONS": {
|
||||
"context_processors": [
|
||||
"django.template.context_processors.debug",
|
||||
"django.template.context_processors.request",
|
||||
"django.contrib.auth.context_processors.auth",
|
||||
"django.contrib.messages.context_processors.messages",
|
||||
|
|
@ -88,16 +82,14 @@ TEMPLATES = [
|
|||
|
||||
WSGI_APPLICATION = "newsreader.wsgi.application"
|
||||
|
||||
# Database
|
||||
# https://docs.djangoproject.com/en/2.2/ref/settings/#databases
|
||||
DATABASES = {
|
||||
"default": {
|
||||
"ENGINE": "django.db.backends.postgresql",
|
||||
"HOST": os.environ["POSTGRES_HOST"],
|
||||
"PORT": os.environ["POSTGRES_PORT"],
|
||||
"NAME": os.environ["POSTGRES_DB"],
|
||||
"USER": os.environ["POSTGRES_USER"],
|
||||
"PASSWORD": os.environ["POSTGRES_PASSWORD"],
|
||||
"HOST": get_env("POSTGRES_HOST", default=""),
|
||||
"PORT": get_env("POSTGRES_PORT", default=""),
|
||||
"NAME": get_env("POSTGRES_DB", default=""),
|
||||
"USER": get_env("POSTGRES_USER", default=""),
|
||||
"PASSWORD": get_env("POSTGRES_PASSWORD", default=""),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -114,8 +106,6 @@ CACHES = {
|
|||
},
|
||||
}
|
||||
|
||||
# Logging
|
||||
# https://docs.djangoproject.com/en/2.2/topics/logging/#configuring-logging
|
||||
LOGGING = {
|
||||
"version": 1,
|
||||
"disable_existing_loggers": False,
|
||||
|
|
@ -169,8 +159,6 @@ LOGGING = {
|
|||
},
|
||||
}
|
||||
|
||||
# Password validation
|
||||
# https://docs.djangoproject.com/en/2.2/ref/settings/#auth-password-validators
|
||||
AUTH_PASSWORD_VALIDATORS = [
|
||||
{
|
||||
"NAME": "django.contrib.auth.password_validation.UserAttributeSimilarityValidator"
|
||||
|
|
@ -185,8 +173,6 @@ AUTH_USER_MODEL = "accounts.User"
|
|||
|
||||
LOGIN_REDIRECT_URL = "/"
|
||||
|
||||
# Internationalization
|
||||
# https://docs.djangoproject.com/en/2.2/topics/i18n/
|
||||
LANGUAGE_CODE = "en-us"
|
||||
|
||||
TIME_ZONE = "Europe/Amsterdam"
|
||||
|
|
@ -194,30 +180,31 @@ USE_I18N = True
|
|||
USE_L10N = True
|
||||
USE_TZ = True
|
||||
|
||||
# Static files (CSS, JavaScript, Images)
|
||||
# https://docs.djangoproject.com/en/2.2/howto/static-files/
|
||||
STATIC_URL = "/static/"
|
||||
STATIC_ROOT = os.path.join(BASE_DIR, "static")
|
||||
STATICFILES_DIRS = [os.path.join(DJANGO_PROJECT_DIR, "static")]
|
||||
STATIC_ROOT = BASE_DIR / "static"
|
||||
STATICFILES_DIRS = (DJANGO_PROJECT_DIR / "static",)
|
||||
|
||||
# https://docs.djangoproject.com/en/2.2/ref/settings/#std:setting-STATICFILES_FINDERS
|
||||
STATICFILES_FINDERS = [
|
||||
"django.contrib.staticfiles.finders.FileSystemFinder",
|
||||
"django.contrib.staticfiles.finders.AppDirectoriesFinder",
|
||||
]
|
||||
|
||||
# Email
|
||||
EMAIL_BACKEND = "django.core.mail.backends.console.EmailBackend"
|
||||
EMAIL_BACKEND = "django.core.mail.backends.smtp.EmailBackend"
|
||||
|
||||
# Reddit integration
|
||||
REDDIT_CLIENT_ID = "CLIENT_ID"
|
||||
REDDIT_CLIENT_SECRET = "CLIENT_SECRET"
|
||||
REDDIT_REDIRECT_URL = (
|
||||
"http://127.0.0.1:8000/accounts/settings/integrations/reddit/callback/"
|
||||
DEFAULT_FROM_EMAIL = get_env(
|
||||
"EMAIL_DEFAULT_FROM", required=False, default="webmaster@localhost"
|
||||
)
|
||||
|
||||
# Twitter integration
|
||||
TWITTER_URL = "https://twitter.com"
|
||||
EMAIL_HOST = get_env("EMAIL_HOST", required=False, default="localhost")
|
||||
EMAIL_PORT = get_env("EMAIL_PORT", cast=int, required=False, default=25)
|
||||
|
||||
EMAIL_HOST_USER = get_env("EMAIL_HOST_USER", required=False, default="")
|
||||
EMAIL_HOST_PASSWORD = get_env("EMAIL_HOST_PASSWORD", required=False, default="")
|
||||
|
||||
EMAIL_USE_TLS = get_env("EMAIL_USE_TLS", required=False, default=False)
|
||||
EMAIL_USE_SSL = get_env("EMAIL_USE_SSL", required=False, default=False)
|
||||
|
||||
|
||||
# Third party settings
|
||||
AXES_HANDLER = "axes.handlers.cache.AxesCacheHandler"
|
||||
|
|
@ -226,7 +213,6 @@ AXES_FAILURE_LIMIT = 5
|
|||
AXES_COOLOFF_TIME = 3 # in hours
|
||||
AXES_RESET_ON_SUCCESS = True
|
||||
|
||||
# TODO: verify parser works correctly
|
||||
REST_FRAMEWORK = {
|
||||
"DEFAULT_AUTHENTICATION_CLASSES": (
|
||||
"rest_framework.authentication.SessionAuthentication",
|
||||
|
|
@ -257,7 +243,7 @@ CELERY_BROKER_URL = "amqp://guest@rabbitmq:5672"
|
|||
|
||||
# Sentry
|
||||
SENTRY_CONFIG = {
|
||||
"dsn": os.environ.get("SENTRY_DSN"),
|
||||
"dsn": get_env("SENTRY_DSN", default="", required=False),
|
||||
"send_default_pii": False,
|
||||
"integrations": [DjangoIntegration(), CeleryIntegration()]
|
||||
if DjangoIntegration and CeleryIntegration
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
from .base import * # noqa: F403
|
||||
from .version import get_current_version
|
||||
from .utils import get_current_version
|
||||
|
||||
|
||||
DEBUG = True
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
from .base import * # noqa: F403
|
||||
from .version import get_current_version
|
||||
from .utils import get_current_version
|
||||
|
||||
|
||||
SECRET_KEY = "mv4&5#+)-=abz3^&1r^nk_ca6y54--p(4n4cg%z*g&rb64j%wl"
|
||||
|
|
@ -10,6 +10,11 @@ EMAIL_BACKEND = "django.core.mail.backends.console.EmailBackend"
|
|||
|
||||
INSTALLED_APPS += ["debug_toolbar", "django_extensions"] # noqa: F405
|
||||
|
||||
|
||||
TEMPLATES[0]["OPTIONS"]["context_processors"].append( # noqa: F405
|
||||
"django.template.context_processors.debug",
|
||||
)
|
||||
|
||||
# Project settings
|
||||
VERSION = get_current_version()
|
||||
|
||||
|
|
|
|||
|
|
@ -1,8 +1,8 @@
|
|||
from .base import * # noqa: F403
|
||||
from .version import get_current_version
|
||||
from .utils import get_current_version
|
||||
|
||||
|
||||
ALLOWED_HOSTS = ["django", "127.0.0.1"]
|
||||
DEBUG = True
|
||||
|
||||
INSTALLED_APPS += ["debug_toolbar", "django_extensions"] # noqa: F405
|
||||
|
||||
|
|
@ -16,7 +16,10 @@ LOGGING["loggers"].update( # noqa: F405
|
|||
|
||||
EMAIL_BACKEND = "django.core.mail.backends.console.EmailBackend"
|
||||
|
||||
DEBUG = True
|
||||
|
||||
TEMPLATES[0]["OPTIONS"]["context_processors"].append( # noqa: F405
|
||||
"django.template.context_processors.debug",
|
||||
)
|
||||
|
||||
# Project settings
|
||||
VERSION = get_current_version()
|
||||
|
|
|
|||
|
|
@ -1,59 +1,21 @@
|
|||
import os
|
||||
|
||||
from .version import get_current_version
|
||||
|
||||
from newsreader.conf.utils import get_env
|
||||
|
||||
from .base import * # noqa: F403
|
||||
from .utils import get_current_version
|
||||
|
||||
|
||||
DEBUG = False
|
||||
|
||||
SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https")
|
||||
|
||||
ALLOWED_HOSTS = ["127.0.0.1", "localhost", "rss.fudiggity.nl", "django"]
|
||||
|
||||
ADMINS = [
|
||||
("", email)
|
||||
for email in os.getenv("ADMINS", "").split(",")
|
||||
if os.environ.get("ADMINS")
|
||||
("", email) for email in get_env("ADMINS", split=",", required=False, default=[])
|
||||
]
|
||||
|
||||
TEMPLATES = [
|
||||
{
|
||||
"BACKEND": "django.template.backends.django.DjangoTemplates",
|
||||
"DIRS": [os.path.join(DJANGO_PROJECT_DIR, "templates")], # noqa: F405
|
||||
"APP_DIRS": True,
|
||||
"OPTIONS": {
|
||||
"context_processors": [
|
||||
"django.template.context_processors.request",
|
||||
"django.contrib.auth.context_processors.auth",
|
||||
"django.contrib.messages.context_processors.messages",
|
||||
]
|
||||
},
|
||||
}
|
||||
]
|
||||
|
||||
# Email
|
||||
EMAIL_BACKEND = "django.core.mail.backends.smtp.EmailBackend"
|
||||
DEFAULT_FROM_EMAIL = os.environ.get("EMAIL_DEFAULT_FROM", "webmaster@localhost")
|
||||
|
||||
EMAIL_HOST = os.environ.get("EMAIL_HOST", "localhost")
|
||||
EMAIL_PORT = os.environ.get("EMAIL_PORT", 25)
|
||||
|
||||
EMAIL_HOST_USER = os.environ.get("EMAIL_HOST_USER", "")
|
||||
EMAIL_HOST_PASSWORD = os.environ.get("EMAIL_HOST_PASSWORD", "")
|
||||
|
||||
EMAIL_USE_TLS = bool(os.environ.get("EMAIL_USE_TLS"))
|
||||
EMAIL_USE_SSL = bool(os.environ.get("EMAIL_USE_SSL"))
|
||||
|
||||
# Project settings
|
||||
VERSION = get_current_version(debug=False)
|
||||
ENVIRONMENT = "production"
|
||||
|
||||
# Reddit integration
|
||||
REDDIT_CLIENT_ID = os.environ.get("REDDIT_CLIENT_ID", "")
|
||||
REDDIT_CLIENT_SECRET = os.environ.get("REDDIT_CLIENT_SECRET", "")
|
||||
REDDIT_REDIRECT_URL = os.environ.get("REDDIT_CALLBACK_URL", "")
|
||||
|
||||
# Third party settings
|
||||
AXES_HANDLER = "axes.handlers.database.AxesDatabaseHandler"
|
||||
|
||||
|
|
|
|||
85
src/newsreader/conf/utils.py
Normal file
85
src/newsreader/conf/utils.py
Normal file
|
|
@ -0,0 +1,85 @@
|
|||
import logging
|
||||
import os
|
||||
import subprocess
|
||||
|
||||
from pathlib import Path
|
||||
from typing import Any, Iterable, Type
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def get_env(
|
||||
name: str,
|
||||
cast: Type = str,
|
||||
required: bool = True,
|
||||
default: Any = None,
|
||||
split: str = "",
|
||||
) -> Any:
|
||||
if cast is not str and split:
|
||||
raise TypeError(f"Split is not possible with {cast}")
|
||||
|
||||
value = os.getenv(name)
|
||||
|
||||
if not value:
|
||||
if required:
|
||||
logger.warning(f"Missing environment variable: {name}")
|
||||
|
||||
return default
|
||||
|
||||
bool_mapping = {"yes": True, "true": True, "false": False, "no": False}
|
||||
|
||||
if cast is bool:
|
||||
_value = bool_mapping.get(value.lower())
|
||||
|
||||
if not value:
|
||||
raise ValueError(f"Unknown boolean value: {_value}")
|
||||
|
||||
return _value
|
||||
|
||||
value = value if not cast else cast(value)
|
||||
return value if not split else value.split(split)
|
||||
|
||||
|
||||
def get_current_version(debug: bool = True) -> str:
|
||||
version = get_env("VERSION", required=False)
|
||||
|
||||
if version:
|
||||
return version
|
||||
|
||||
if debug:
|
||||
try:
|
||||
output = subprocess.check_output(
|
||||
["git", "show", "--no-patch", "--format=%H"], universal_newlines=True
|
||||
)
|
||||
return output.strip()
|
||||
except (subprocess.CalledProcessError, OSError):
|
||||
return ""
|
||||
|
||||
try:
|
||||
output = subprocess.check_output(
|
||||
["git", "describe", "--tags"], universal_newlines=True
|
||||
)
|
||||
return output.strip()
|
||||
except (subprocess.CalledProcessError, OSError):
|
||||
return ""
|
||||
|
||||
|
||||
ROOT_MARKERS = ("pyproject.toml", "package.json", "README.md", "CHANGELOG.md")
|
||||
|
||||
|
||||
def get_root_dir() -> Path:
|
||||
file = Path(__file__)
|
||||
return _traverse_dirs(file.parent, ROOT_MARKERS)
|
||||
|
||||
|
||||
def _traverse_dirs(path: Path, root_markers: Iterable[str]) -> Path:
|
||||
if path.parent == path:
|
||||
raise OSError("Root directory detected")
|
||||
|
||||
files = (file.name for file in path.iterdir())
|
||||
|
||||
if not any((marker for marker in root_markers if marker in files)):
|
||||
return _traverse_dirs(path.parent, root_markers)
|
||||
|
||||
return path
|
||||
|
|
@ -1,26 +0,0 @@
|
|||
import os
|
||||
import subprocess
|
||||
|
||||
|
||||
def get_current_version(debug=True):
|
||||
version = os.environ.get("VERSION")
|
||||
|
||||
if version:
|
||||
return version
|
||||
|
||||
if debug:
|
||||
try:
|
||||
output = subprocess.check_output(
|
||||
["git", "show", "--no-patch", "--format=%H"], universal_newlines=True
|
||||
)
|
||||
return output.strip()
|
||||
except (subprocess.CalledProcessError, OSError):
|
||||
return ""
|
||||
|
||||
try:
|
||||
output = subprocess.check_output(
|
||||
["git", "describe", "--tags"], universal_newlines=True
|
||||
)
|
||||
return output.strip()
|
||||
except (subprocess.CalledProcessError, OSError):
|
||||
return ""
|
||||
|
|
@ -3,15 +3,13 @@ import React from 'react';
|
|||
class Messages extends React.Component {
|
||||
state = { messages: this.props.messages };
|
||||
|
||||
close = ::this.close;
|
||||
|
||||
close(index) {
|
||||
close = index => {
|
||||
const newMessages = this.state.messages.filter((message, currentIndex) => {
|
||||
return currentIndex != index;
|
||||
});
|
||||
|
||||
this.setState({ messages: newMessages });
|
||||
}
|
||||
};
|
||||
|
||||
render() {
|
||||
const messages = this.state.messages.map((message, index) => {
|
||||
|
|
|
|||
|
|
@ -1,6 +1,4 @@
|
|||
class Selector {
|
||||
onClick = ::this.onClick;
|
||||
|
||||
inputs = [];
|
||||
|
||||
constructor() {
|
||||
|
|
@ -11,13 +9,13 @@ class Selector {
|
|||
selectAllInput.onchange = this.onClick;
|
||||
}
|
||||
|
||||
onClick(e) {
|
||||
onClick = e => {
|
||||
const targetValue = e.target.checked;
|
||||
|
||||
this.inputs.forEach(input => {
|
||||
input.checked = targetValue;
|
||||
});
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export default Selector;
|
||||
|
|
|
|||
|
|
@ -9,10 +9,6 @@ import Messages from '../../components/Messages.js';
|
|||
import Sidebar from '../../components/Sidebar.js';
|
||||
|
||||
class App extends React.Component {
|
||||
selectCategory = ::this.selectCategory;
|
||||
deselectCategory = ::this.deselectCategory;
|
||||
deleteCategory = ::this.deleteCategory;
|
||||
|
||||
constructor(props) {
|
||||
super(props);
|
||||
|
||||
|
|
@ -24,15 +20,15 @@ class App extends React.Component {
|
|||
};
|
||||
}
|
||||
|
||||
selectCategory(categoryId) {
|
||||
selectCategory = categoryId => {
|
||||
this.setState({ selectedCategoryId: categoryId });
|
||||
}
|
||||
};
|
||||
|
||||
deselectCategory() {
|
||||
deselectCategory = () => {
|
||||
this.setState({ selectedCategoryId: null });
|
||||
}
|
||||
};
|
||||
|
||||
deleteCategory(categoryId) {
|
||||
deleteCategory = categoryId => {
|
||||
const url = `/api/categories/${categoryId}/`;
|
||||
const options = {
|
||||
method: 'DELETE',
|
||||
|
|
@ -60,7 +56,7 @@ class App extends React.Component {
|
|||
text: 'Unable to remove category, try again later',
|
||||
};
|
||||
return this.setState({ selectedCategoryId: null, message: message });
|
||||
}
|
||||
};
|
||||
|
||||
render() {
|
||||
const { categories } = this.state;
|
||||
|
|
|
|||
|
|
@ -33,7 +33,6 @@ class App extends React.Component {
|
|||
<HomepageSidebar navLinks={this.props.navLinks} />
|
||||
<PostList
|
||||
feedUrl={this.props.feedUrl}
|
||||
subredditUrl={this.props.subredditUrl}
|
||||
timezone={this.props.timezone}
|
||||
forwardedRef={this.postListRef}
|
||||
postsByType={this.props.postsByType}
|
||||
|
|
@ -46,7 +45,6 @@ class App extends React.Component {
|
|||
category={this.props.category}
|
||||
selectedType={this.props.selectedType}
|
||||
feedUrl={this.props.feedUrl}
|
||||
subredditUrl={this.props.subredditUrl}
|
||||
categoriesUrl={this.props.categoriesUrl}
|
||||
timezone={this.props.timezone}
|
||||
autoMarking={this.props.autoMarking}
|
||||
|
|
|
|||
|
|
@ -3,11 +3,10 @@ import { connect } from 'react-redux';
|
|||
import Cookies from 'js-cookie';
|
||||
|
||||
import { unSelectPost, markPostRead, toggleSaved } from '../actions/posts.js';
|
||||
import { SAVED_TYPE, SUBREDDIT } from '../constants.js';
|
||||
import { SAVED_TYPE } from '../constants.js';
|
||||
import { formatDatetime } from '../../../utils.js';
|
||||
|
||||
class PostModal extends React.Component {
|
||||
modalListener = ::this.modalListener;
|
||||
readTimer = null;
|
||||
|
||||
componentDidMount() {
|
||||
|
|
@ -32,13 +31,13 @@ class PostModal extends React.Component {
|
|||
window.removeEventListener('click', this.modalListener);
|
||||
}
|
||||
|
||||
modalListener(e) {
|
||||
modalListener = e => {
|
||||
const targetClassName = e.target.className;
|
||||
|
||||
if (this.props.post && targetClassName == 'modal post-modal') {
|
||||
this.props.unSelectPost();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
render() {
|
||||
const post = this.props.post;
|
||||
|
|
@ -54,9 +53,6 @@ class PostModal extends React.Component {
|
|||
let ruleUrl = '';
|
||||
|
||||
switch (this.props.rule.type) {
|
||||
case SUBREDDIT:
|
||||
ruleUrl = `${this.props.subredditUrl}/${this.props.rule.id}/`;
|
||||
break;
|
||||
default:
|
||||
ruleUrl = `${this.props.feedUrl}/${this.props.rule.id}/`;
|
||||
break;
|
||||
|
|
|
|||
|
|
@ -1,8 +1,6 @@
|
|||
import React from 'react';
|
||||
|
||||
export default class ScrollTop extends React.Component {
|
||||
scrollListener = ::this.scrollListener;
|
||||
|
||||
state = {
|
||||
listenerAttached: false,
|
||||
showTop: false,
|
||||
|
|
@ -17,7 +15,7 @@ export default class ScrollTop extends React.Component {
|
|||
}
|
||||
}
|
||||
|
||||
scrollListener() {
|
||||
scrollListener = () => {
|
||||
const postList = this.props.postListNode;
|
||||
const elementEnd =
|
||||
postList.scrollTop + postList.offsetHeight >= postList.scrollHeight;
|
||||
|
|
@ -26,7 +24,7 @@ export default class ScrollTop extends React.Component {
|
|||
showTop: postList.scrollTop > window.innerHeight,
|
||||
showBottom: !elementEnd,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
render() {
|
||||
const postList = this.props.postListNode;
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ import React from 'react';
|
|||
import { connect } from 'react-redux';
|
||||
import Cookies from 'js-cookie';
|
||||
|
||||
import { CATEGORY_TYPE, SAVED_TYPE, SUBREDDIT } from '../../constants.js';
|
||||
import { CATEGORY_TYPE, SAVED_TYPE } from '../../constants.js';
|
||||
import { selectPost, toggleSaved } from '../../actions/posts.js';
|
||||
import { formatDatetime } from '../../../../utils.js';
|
||||
|
||||
|
|
@ -18,12 +18,7 @@ class PostItem extends React.Component {
|
|||
: 'posts__header';
|
||||
const savedIconClass = post.saved ? 'saved-icon saved-icon--saved' : 'saved-icon';
|
||||
|
||||
let ruleUrl = '';
|
||||
if (rule.type === SUBREDDIT) {
|
||||
ruleUrl = `${this.props.subredditUrl}/${rule.id}/`;
|
||||
} else {
|
||||
ruleUrl = `${this.props.feedUrl}/${rule.id}/`;
|
||||
}
|
||||
const ruleUrl = `${this.props.feedUrl}/${rule.id}/`;
|
||||
|
||||
return (
|
||||
<li className="posts__item" ref={this.props.forwardedRef}>
|
||||
|
|
|
|||
|
|
@ -4,13 +4,11 @@ import { isEqual } from 'lodash';
|
|||
|
||||
import { fetchPostsBySection, fetchSavedPosts } from '../../actions/posts.js';
|
||||
import { SAVED_TYPE } from '../../constants.js';
|
||||
import { filterPosts } from './filters.js';
|
||||
|
||||
import LoadingIndicator from '../../../../components/LoadingIndicator.js';
|
||||
import PostItem from './PostItem.js';
|
||||
|
||||
class PostList extends React.Component {
|
||||
handleIntersect = ::this.handleIntersect;
|
||||
lastPostRef = null;
|
||||
observer = null;
|
||||
|
||||
|
|
@ -33,7 +31,7 @@ class PostList extends React.Component {
|
|||
this.observer.disconnect();
|
||||
}
|
||||
|
||||
handleIntersect(entries) {
|
||||
handleIntersect = entries => {
|
||||
entries.every(entry => {
|
||||
if (entry.isIntersecting) {
|
||||
this.observer.unobserve(entry.target);
|
||||
|
|
@ -45,7 +43,7 @@ class PostList extends React.Component {
|
|||
return false;
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
paginate() {
|
||||
if (this.props.selected.type === SAVED_TYPE) {
|
||||
|
|
@ -64,7 +62,6 @@ class PostList extends React.Component {
|
|||
post: item,
|
||||
selected: this.props.selected,
|
||||
feedUrl: this.props.feedUrl,
|
||||
subredditUrl: this.props.subredditUrl,
|
||||
};
|
||||
|
||||
if (isLastItem?.id === item.id) {
|
||||
|
|
|
|||
|
|
@ -5,15 +5,13 @@ import Cookies from 'js-cookie';
|
|||
import { markRead } from '../../actions/selected.js';
|
||||
|
||||
class ReadButton extends React.Component {
|
||||
markSelectedRead = ::this.markSelectedRead;
|
||||
|
||||
markSelectedRead() {
|
||||
markSelectedRead = () => {
|
||||
const token = Cookies.get('csrftoken');
|
||||
|
||||
if (this.props.selected.unread > 0) {
|
||||
this.props.markRead({ ...this.props.selected }, token);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
render() {
|
||||
return (
|
||||
|
|
|
|||
|
|
@ -2,5 +2,4 @@ export const RULE_TYPE = 'RULE';
|
|||
export const CATEGORY_TYPE = 'CATEGORY';
|
||||
export const SAVED_TYPE = 'SAVED';
|
||||
|
||||
export const SUBREDDIT = 'subreddit';
|
||||
export const FEED = 'feed';
|
||||
|
|
|
|||
|
|
@ -12,7 +12,7 @@ if (page) {
|
|||
const store = configureStore();
|
||||
|
||||
const settings = JSON.parse(document.getElementById('homepageSettings').textContent);
|
||||
const { feedUrl, subredditUrl, categoriesUrl } = settings;
|
||||
const { feedUrl, categoriesUrl } = settings;
|
||||
|
||||
const navLinks = JSON.parse(document.getElementById('Links').textContent);
|
||||
|
||||
|
|
@ -20,7 +20,6 @@ if (page) {
|
|||
<Provider store={store}>
|
||||
<App
|
||||
feedUrl={feedUrl.substring(1, feedUrl.length - 3)}
|
||||
subredditUrl={subredditUrl.substring(1, subredditUrl.length - 3)}
|
||||
categoriesUrl={categoriesUrl.substring(1, categoriesUrl.length - 3)}
|
||||
timezone={settings.timezone}
|
||||
autoMarking={settings.autoMarking}
|
||||
|
|
|
|||
|
|
@ -4,11 +4,3 @@ from django.utils.translation import gettext as _
|
|||
|
||||
class RuleTypeChoices(TextChoices):
|
||||
feed = "feed", _("Feed")
|
||||
subreddit = "subreddit", _("Subreddit")
|
||||
twitter_timeline = "twitter_timeline", _("Twitter timeline")
|
||||
|
||||
|
||||
class TwitterPostTypeChoices(TextChoices):
|
||||
photo = "photo", _("Photo")
|
||||
video = "video", _("Video")
|
||||
animated_gif = "animated_gif", _("GIF")
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
from django.db.models import Prefetch
|
||||
|
||||
from rest_framework import status
|
||||
from rest_framework.generics import (
|
||||
GenericAPIView,
|
||||
|
|
|
|||
|
|
@ -1,10 +1,9 @@
|
|||
from newsreader.news.collection.forms.feed import FeedForm, OPMLImportForm
|
||||
from newsreader.news.collection.forms.reddit import SubRedditForm
|
||||
from newsreader.news.collection.forms.rules import CollectionRuleBulkForm
|
||||
|
||||
|
||||
__all__ = [
|
||||
"FeedForm",
|
||||
"OPMLImportForm",
|
||||
"SubRedditForm",
|
||||
"CollectionRuleBulkForm",
|
||||
]
|
||||
|
|
|
|||
|
|
@ -1,57 +0,0 @@
|
|||
from django import forms
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.utils.safestring import mark_safe
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
from newsreader.news.collection.choices import RuleTypeChoices
|
||||
from newsreader.news.collection.forms.base import CollectionRuleForm
|
||||
from newsreader.news.collection.models import CollectionRule
|
||||
from newsreader.news.collection.reddit import REDDIT_API_URL
|
||||
|
||||
|
||||
def get_reddit_help_text():
|
||||
return mark_safe(
|
||||
"Only subreddits are supported"
|
||||
" see the 'listings' section in <a className='link' target='_blank' rel='noopener noreferrer'"
|
||||
" href='https://www.reddit.com/dev/api#section_listings'>the reddit API docs</a>."
|
||||
" For example: <a className='link' target='_blank' rel='noopener noreferrer'"
|
||||
" href='https://oauth.reddit.com/r/aww'>https://oauth.reddit.com/r/aww</a>"
|
||||
)
|
||||
|
||||
|
||||
class SubRedditForm(CollectionRuleForm):
|
||||
url = forms.URLField(max_length=1024, help_text=get_reddit_help_text)
|
||||
|
||||
def clean_url(self):
|
||||
url = self.cleaned_data["url"]
|
||||
|
||||
if not url.startswith(REDDIT_API_URL):
|
||||
raise ValidationError(_("This does not look like an Reddit API URL"))
|
||||
|
||||
return url
|
||||
|
||||
def save(self, commit=True):
|
||||
instance = super().save(commit=False)
|
||||
|
||||
instance.type = RuleTypeChoices.subreddit
|
||||
|
||||
if commit:
|
||||
instance.save()
|
||||
self.save_m2m()
|
||||
|
||||
return instance
|
||||
|
||||
class Meta:
|
||||
model = CollectionRule
|
||||
fields = (
|
||||
"name",
|
||||
"url",
|
||||
"favicon",
|
||||
"category",
|
||||
"reddit_allow_nfsw",
|
||||
"reddit_allow_spoiler",
|
||||
"reddit_allow_viewed",
|
||||
"reddit_upvotes_min",
|
||||
"reddit_downvotes_max",
|
||||
"reddit_comments_min",
|
||||
)
|
||||
|
|
@ -0,0 +1,47 @@
|
|||
# Generated by Django 4.2.16 on 2025-03-26 08:46
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
("collection", "0017_remove_collectionrule_timezone"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RemoveField(
|
||||
model_name="collectionrule",
|
||||
name="reddit_allow_nfsw",
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name="collectionrule",
|
||||
name="reddit_allow_spoiler",
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name="collectionrule",
|
||||
name="reddit_allow_viewed",
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name="collectionrule",
|
||||
name="reddit_comments_min",
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name="collectionrule",
|
||||
name="reddit_downvotes_max",
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name="collectionrule",
|
||||
name="reddit_upvotes_min",
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name="collectionrule",
|
||||
name="screen_name",
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="collectionrule",
|
||||
name="type",
|
||||
field=models.CharField(
|
||||
choices=[("feed", "Feed")], default="feed", max_length=20
|
||||
),
|
||||
),
|
||||
]
|
||||
|
|
@ -1,5 +1,4 @@
|
|||
from django.db import models
|
||||
from django.conf import settings
|
||||
from django.urls import reverse
|
||||
from django.utils.translation import gettext as _
|
||||
|
||||
|
|
@ -49,25 +48,6 @@ class CollectionRule(TimeStampedModel):
|
|||
on_delete=models.CASCADE,
|
||||
)
|
||||
|
||||
# Reddit
|
||||
reddit_allow_nfsw = models.BooleanField(_("Allow NSFW posts"), default=False)
|
||||
reddit_allow_spoiler = models.BooleanField(_("Allow spoilers"), default=False)
|
||||
reddit_allow_viewed = models.BooleanField(
|
||||
_("Allow already seen posts"), default=True
|
||||
)
|
||||
reddit_upvotes_min = models.PositiveIntegerField(
|
||||
_("Minimum amount of upvotes"), default=0
|
||||
)
|
||||
reddit_downvotes_max = models.PositiveIntegerField(
|
||||
_("Maximum amount of downvotes"), blank=True, null=True
|
||||
)
|
||||
reddit_comments_min = models.PositiveIntegerField(
|
||||
_("Minimum amount of comments"), default=0
|
||||
)
|
||||
|
||||
# Twitter (legacy)
|
||||
screen_name = models.CharField(max_length=255, blank=True, null=True)
|
||||
|
||||
objects = CollectionRuleQuerySet.as_manager()
|
||||
|
||||
def __str__(self):
|
||||
|
|
@ -75,22 +55,10 @@ class CollectionRule(TimeStampedModel):
|
|||
|
||||
@property
|
||||
def update_url(self):
|
||||
if self.type == RuleTypeChoices.subreddit:
|
||||
return reverse("news:collection:subreddit-update", kwargs={"pk": self.pk})
|
||||
elif self.type == RuleTypeChoices.twitter_timeline:
|
||||
return "#not-supported"
|
||||
|
||||
return reverse("news:collection:feed-update", kwargs={"pk": self.pk})
|
||||
|
||||
@property
|
||||
def source_url(self):
|
||||
if self.type == RuleTypeChoices.subreddit:
|
||||
from newsreader.news.collection.reddit import REDDIT_API_URL, REDDIT_URL
|
||||
|
||||
return self.url.replace(REDDIT_API_URL, REDDIT_URL)
|
||||
elif self.type == RuleTypeChoices.twitter_timeline:
|
||||
return f"{settings.TWITTER_URL}/{self.screen_name}"
|
||||
|
||||
return self.url
|
||||
|
||||
@property
|
||||
|
|
|
|||
|
|
@ -1,419 +0,0 @@
|
|||
import logging
|
||||
|
||||
from concurrent.futures import ThreadPoolExecutor, as_completed
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from html import unescape
|
||||
from json.decoder import JSONDecodeError
|
||||
from urllib.parse import urlencode
|
||||
from uuid import uuid4
|
||||
|
||||
from django.conf import settings
|
||||
from django.core.cache import cache
|
||||
from django.utils.html import format_html
|
||||
|
||||
import requests
|
||||
|
||||
from newsreader.news.collection.base import (
|
||||
PostBuilder,
|
||||
PostClient,
|
||||
PostCollector,
|
||||
PostStream,
|
||||
Scheduler,
|
||||
)
|
||||
from newsreader.news.collection.choices import RuleTypeChoices
|
||||
from newsreader.news.collection.exceptions import (
|
||||
BuilderDuplicateException,
|
||||
BuilderException,
|
||||
BuilderMissingDataException,
|
||||
BuilderParseException,
|
||||
BuilderSkippedException,
|
||||
StreamDeniedException,
|
||||
StreamException,
|
||||
StreamParseException,
|
||||
StreamTooManyException,
|
||||
)
|
||||
from newsreader.news.collection.models import CollectionRule
|
||||
from newsreader.news.collection.tasks import RedditTokenTask
|
||||
from newsreader.news.collection.utils import fetch, post, truncate_text
|
||||
from newsreader.news.core.models import Post
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
REDDIT_URL = "https://www.reddit.com"
|
||||
REDDIT_API_URL = "https://oauth.reddit.com"
|
||||
|
||||
RATE_LIMIT = 60
|
||||
RATE_LIMIT_DURATION = timedelta(seconds=60)
|
||||
|
||||
REDDIT_IMAGE_EXTENSIONS = (".jpg", ".png", ".gif")
|
||||
REDDIT_VIDEO_EXTENSIONS = (".mp4", ".gifv", ".webm")
|
||||
|
||||
# see type prefixes on https://www.reddit.com/dev/api/
|
||||
REDDIT_POST = "t3"
|
||||
|
||||
|
||||
def get_reddit_authorization_url(user):
|
||||
state = str(uuid4())
|
||||
cache.set(f"{user.email}-reddit-auth", state)
|
||||
|
||||
params = {
|
||||
"client_id": settings.REDDIT_CLIENT_ID,
|
||||
"redirect_uri": settings.REDDIT_REDIRECT_URL,
|
||||
"state": state,
|
||||
"response_type": "code",
|
||||
"duration": "permanent",
|
||||
"scope": "identity,mysubreddits,save,read",
|
||||
}
|
||||
|
||||
authorization_url = f"{REDDIT_URL}/api/v1/authorize"
|
||||
return f"{authorization_url}?{urlencode(params)}"
|
||||
|
||||
|
||||
def get_reddit_access_token(code, user):
|
||||
client_auth = requests.auth.HTTPBasicAuth(
|
||||
settings.REDDIT_CLIENT_ID, settings.REDDIT_CLIENT_SECRET
|
||||
)
|
||||
|
||||
response = post(
|
||||
f"{REDDIT_URL}/api/v1/access_token",
|
||||
data={
|
||||
"redirect_uri": settings.REDDIT_REDIRECT_URL,
|
||||
"grant_type": "authorization_code",
|
||||
"code": code,
|
||||
},
|
||||
auth=client_auth,
|
||||
)
|
||||
|
||||
response_data = response.json()
|
||||
|
||||
user.reddit_access_token = response_data["access_token"]
|
||||
user.reddit_refresh_token = response_data["refresh_token"]
|
||||
user.save()
|
||||
|
||||
cache.delete(f"{user.email}-reddit-auth")
|
||||
|
||||
return response_data["access_token"], response_data["refresh_token"]
|
||||
|
||||
|
||||
# Note that the API always returns 204's with correct basic auth headers
|
||||
def revoke_reddit_token(user):
|
||||
client_auth = requests.auth.HTTPBasicAuth(
|
||||
settings.REDDIT_CLIENT_ID, settings.REDDIT_CLIENT_SECRET
|
||||
)
|
||||
|
||||
response = post(
|
||||
f"{REDDIT_URL}/api/v1/revoke_token",
|
||||
data={"token": user.reddit_refresh_token, "token_type_hint": "refresh_token"},
|
||||
auth=client_auth,
|
||||
)
|
||||
|
||||
return response.status_code == 204
|
||||
|
||||
|
||||
class RedditBuilder(PostBuilder):
|
||||
rule_type = RuleTypeChoices.subreddit
|
||||
|
||||
def build(self):
|
||||
results = {}
|
||||
|
||||
if "data" not in self.payload or "children" not in self.payload["data"]:
|
||||
return
|
||||
|
||||
entries = self.payload["data"]["children"]
|
||||
|
||||
for entry in entries:
|
||||
try:
|
||||
post = self.build_post(entry)
|
||||
except BuilderDuplicateException:
|
||||
logger.warning("Skipping duplicate post")
|
||||
continue
|
||||
except BuilderSkippedException as e:
|
||||
logger.warning(e.message)
|
||||
continue
|
||||
except BuilderException:
|
||||
logger.exception("Failed building post")
|
||||
continue
|
||||
|
||||
identifier = post.remote_identifier
|
||||
results[identifier] = post
|
||||
|
||||
self.instances = results.values()
|
||||
|
||||
def build_post(self, entry):
|
||||
rule = self.stream.rule
|
||||
entry_data = entry.get("data", {})
|
||||
remote_identifier = entry_data.get("id", "")
|
||||
kind = entry.get("kind")
|
||||
|
||||
if remote_identifier in self.existing_posts:
|
||||
raise BuilderDuplicateException(payload=entry)
|
||||
elif kind != REDDIT_POST:
|
||||
raise BuilderParseException(
|
||||
message=f"Payload is not an reddit post, its of kind {kind}",
|
||||
payload=entry,
|
||||
)
|
||||
elif not entry_data:
|
||||
raise BuilderMissingDataException(
|
||||
message=f"Post {remote_identifier} did not contain any data",
|
||||
payload=entry,
|
||||
)
|
||||
|
||||
try:
|
||||
title = entry_data["title"]
|
||||
author = entry_data["author"]
|
||||
|
||||
post_url_fragment = entry_data["permalink"]
|
||||
direct_url = entry_data["url"]
|
||||
|
||||
is_text = entry_data["is_self"]
|
||||
is_video = entry_data["is_video"]
|
||||
|
||||
is_nsfw = entry_data["over_18"]
|
||||
is_spoiler = entry_data["spoiler"]
|
||||
is_viewed = entry_data["clicked"]
|
||||
upvotes = entry_data["ups"]
|
||||
downvotes = entry_data["downs"]
|
||||
comments = entry_data["num_comments"]
|
||||
except KeyError as e:
|
||||
raise BuilderMissingDataException(payload=entry) from e
|
||||
|
||||
if not rule.reddit_allow_nfsw and is_nsfw:
|
||||
raise BuilderSkippedException("Rule does not allow NSFW posts")
|
||||
elif not rule.reddit_allow_spoiler and is_spoiler:
|
||||
raise BuilderSkippedException("Rule does not allow spoilers")
|
||||
elif not rule.reddit_allow_viewed and is_viewed:
|
||||
raise BuilderSkippedException("Post was already seen by user")
|
||||
elif not upvotes >= rule.reddit_upvotes_min:
|
||||
raise BuilderSkippedException(
|
||||
"Post does not meet minimum amount of upvotes"
|
||||
)
|
||||
elif (
|
||||
rule.reddit_downvotes_max is not None
|
||||
and downvotes > rule.reddit_downvotes_max
|
||||
):
|
||||
raise BuilderSkippedException("Post has more downvotes than allowed")
|
||||
elif not comments >= rule.reddit_comments_min:
|
||||
raise BuilderSkippedException("Post does not have enough comments")
|
||||
|
||||
title = truncate_text(Post, "title", title)
|
||||
author = truncate_text(Post, "author", author)
|
||||
|
||||
if is_text:
|
||||
body = self.get_text_post(entry_data)
|
||||
elif direct_url.endswith(REDDIT_IMAGE_EXTENSIONS):
|
||||
body = self.get_image_post(title, direct_url)
|
||||
elif is_video:
|
||||
body = self.get_native_video_post(entry_data)
|
||||
elif direct_url.endswith(REDDIT_VIDEO_EXTENSIONS):
|
||||
body = self.get_video_post(direct_url)
|
||||
else:
|
||||
body = self.get_url_post(title, direct_url)
|
||||
|
||||
try:
|
||||
_created_date = datetime.fromtimestamp(entry_data["created_utc"])
|
||||
created_date = _created_date.replace(tzinfo=timezone.utc)
|
||||
except (OverflowError, OSError) as e:
|
||||
raise BuilderParseException(payload=entry) from e
|
||||
except KeyError as e:
|
||||
raise BuilderMissingDataException(payload=entry) from e
|
||||
|
||||
post_entry = {
|
||||
"remote_identifier": remote_identifier,
|
||||
"title": title,
|
||||
"body": body,
|
||||
"author": author,
|
||||
"url": f"{REDDIT_URL}{post_url_fragment}",
|
||||
"publication_date": created_date,
|
||||
"rule": rule,
|
||||
}
|
||||
|
||||
return Post(**post_entry)
|
||||
|
||||
def get_text_post(self, entry):
|
||||
try:
|
||||
uncleaned_body = entry["selftext_html"]
|
||||
except KeyError as e:
|
||||
raise BuilderMissingDataException(payload=entry) from e
|
||||
|
||||
unescaped_body = unescape(uncleaned_body) if uncleaned_body else ""
|
||||
return self.sanitize_fragment(unescaped_body) if unescaped_body else ""
|
||||
|
||||
def get_image_post(self, title, url):
|
||||
return format_html(
|
||||
"<div><img alt='{title}' src='{url}' loading='lazy' /></div>",
|
||||
url=url,
|
||||
title=title,
|
||||
)
|
||||
|
||||
def get_native_video_post(self, entry):
|
||||
try:
|
||||
video_info = entry["secure_media"]["reddit_video"]
|
||||
except KeyError as e:
|
||||
raise BuilderMissingDataException(payload=entry) from e
|
||||
|
||||
return format_html(
|
||||
"<div><video controls muted><source src='{url}' type='video/mp4' /></video></div>",
|
||||
url=video_info["fallback_url"],
|
||||
)
|
||||
|
||||
def get_video_post(self, url):
|
||||
extension = next(
|
||||
extension.replace(".", "")
|
||||
for extension in REDDIT_VIDEO_EXTENSIONS
|
||||
if url.endswith(extension)
|
||||
)
|
||||
|
||||
if extension == "gifv":
|
||||
return format_html(
|
||||
"<div><video controls muted><source src='{url}' type='video/mp4' /></video></div>",
|
||||
url=url.replace(extension, "mp4"),
|
||||
)
|
||||
|
||||
return format_html(
|
||||
"<div><video controls muted><source src='{url}' type='video/{extension}' /></video></div>",
|
||||
url=url,
|
||||
extension=extension,
|
||||
)
|
||||
|
||||
def get_url_post(self, title, url):
|
||||
return format_html(
|
||||
"<div><a target='_blank' rel='noopener noreferrer' alt='{title}' href='{url}' class='link'>Direct url</a></div>",
|
||||
url=url,
|
||||
title=title,
|
||||
)
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.stream.rule.pk}: RedditBuilder"
|
||||
|
||||
|
||||
class RedditStream(PostStream):
|
||||
rule_type = RuleTypeChoices.subreddit
|
||||
headers = {}
|
||||
|
||||
def __init__(self, rule):
|
||||
super().__init__(rule)
|
||||
|
||||
self.headers = {"Authorization": f"bearer {self.rule.user.reddit_access_token}"}
|
||||
|
||||
def read(self):
|
||||
response = fetch(self.rule.url, headers=self.headers)
|
||||
|
||||
return self.parse(response), self
|
||||
|
||||
def parse(self, response):
|
||||
try:
|
||||
return response.json()
|
||||
except JSONDecodeError as e:
|
||||
raise StreamParseException(
|
||||
response=response, message="Failed parsing json"
|
||||
) from e
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.rule.pk}: RedditStream"
|
||||
|
||||
|
||||
class RedditClient(PostClient):
|
||||
stream = RedditStream
|
||||
|
||||
def __enter__(self):
|
||||
streams = [[self.stream(rule) for rule in batch] for batch in self.rules]
|
||||
rate_limitted = False
|
||||
|
||||
with ThreadPoolExecutor(max_workers=10) as executor:
|
||||
for batch in streams:
|
||||
futures = {executor.submit(stream.read): stream for stream in batch}
|
||||
|
||||
if rate_limitted:
|
||||
logger.warning("Aborting requests, ratelimit hit")
|
||||
break
|
||||
|
||||
for future in as_completed(futures):
|
||||
stream = futures[future]
|
||||
|
||||
try:
|
||||
response_data = future.result()
|
||||
|
||||
stream.rule.error = None
|
||||
stream.rule.succeeded = True
|
||||
|
||||
yield response_data
|
||||
except StreamDeniedException as e:
|
||||
logger.warning(
|
||||
f"Access token expired for user {stream.rule.user.pk}"
|
||||
)
|
||||
|
||||
stream.rule.user.reddit_access_token = None
|
||||
stream.rule.user.save()
|
||||
|
||||
self.set_rule_error(stream.rule, e)
|
||||
|
||||
RedditTokenTask.delay(stream.rule.user.pk)
|
||||
|
||||
break
|
||||
except StreamTooManyException as e:
|
||||
logger.exception("Ratelimit hit, aborting batched subreddits")
|
||||
|
||||
self.set_rule_error(stream.rule, e)
|
||||
|
||||
rate_limitted = True
|
||||
break
|
||||
except StreamException as e:
|
||||
logger.exception(
|
||||
f"Stream failed reading content from {stream.rule.url}"
|
||||
)
|
||||
|
||||
self.set_rule_error(stream.rule, e)
|
||||
|
||||
continue
|
||||
finally:
|
||||
stream.rule.last_run = datetime.now(tz=timezone.utc)
|
||||
stream.rule.save()
|
||||
|
||||
|
||||
class RedditCollector(PostCollector):
|
||||
builder = RedditBuilder
|
||||
client = RedditClient
|
||||
|
||||
|
||||
class RedditScheduler(Scheduler):
|
||||
max_amount = RATE_LIMIT
|
||||
max_user_amount = RATE_LIMIT / 4
|
||||
|
||||
def __init__(self, subreddits=[]):
|
||||
if not subreddits:
|
||||
self.subreddits = CollectionRule.objects.filter(
|
||||
type=RuleTypeChoices.subreddit,
|
||||
user__reddit_access_token__isnull=False,
|
||||
user__reddit_refresh_token__isnull=False,
|
||||
enabled=True,
|
||||
).order_by("last_run")[:200]
|
||||
else:
|
||||
self.subreddits = subreddits
|
||||
|
||||
def get_scheduled_rules(self):
|
||||
rule_mapping = {}
|
||||
current_amount = 0
|
||||
|
||||
for subreddit in self.subreddits:
|
||||
user_pk = subreddit.user.pk
|
||||
|
||||
if current_amount == self.max_amount:
|
||||
break
|
||||
|
||||
if user_pk in rule_mapping:
|
||||
max_amount_reached = len(rule_mapping[user_pk]) == self.max_user_amount
|
||||
|
||||
if max_amount_reached:
|
||||
continue
|
||||
|
||||
rule_mapping[user_pk].append(subreddit)
|
||||
current_amount += 1
|
||||
|
||||
continue
|
||||
|
||||
rule_mapping[user_pk] = [subreddit]
|
||||
current_amount += 1
|
||||
|
||||
return list(rule_mapping.values())
|
||||
|
|
@ -1,9 +1,4 @@
|
|||
from django.conf import settings
|
||||
from django.core.exceptions import ObjectDoesNotExist
|
||||
from django.core.mail import send_mail
|
||||
from django.utils.translation import gettext as _
|
||||
|
||||
import requests
|
||||
|
||||
from celery.exceptions import Reject
|
||||
from celery.utils.log import get_task_logger
|
||||
|
|
@ -11,9 +6,7 @@ from celery.utils.log import get_task_logger
|
|||
from newsreader.accounts.models import User
|
||||
from newsreader.celery import app
|
||||
from newsreader.news.collection.choices import RuleTypeChoices
|
||||
from newsreader.news.collection.exceptions.stream import StreamException
|
||||
from newsreader.news.collection.feed import FeedCollector
|
||||
from newsreader.news.collection.utils import post
|
||||
from newsreader.utils.celery import MemCacheLock
|
||||
|
||||
|
||||
|
|
@ -49,84 +42,6 @@ class FeedTask(app.Task):
|
|||
raise Reject(reason="Task already running", requeue=False)
|
||||
|
||||
|
||||
class RedditTask(app.Task):
|
||||
name = "RedditTask"
|
||||
ignore_result = True
|
||||
|
||||
def run(self):
|
||||
from newsreader.news.collection.reddit import RedditCollector, RedditScheduler
|
||||
|
||||
with MemCacheLock("reddit-task", self.app.oid) as acquired:
|
||||
if acquired:
|
||||
logger.info("Running reddit task")
|
||||
|
||||
scheduler = RedditScheduler()
|
||||
subreddits = scheduler.get_scheduled_rules()
|
||||
|
||||
collector = RedditCollector()
|
||||
collector.collect(rules=subreddits)
|
||||
else:
|
||||
logger.warning("Cancelling task due to existing lock")
|
||||
|
||||
raise Reject(reason="Task already running", requeue=False)
|
||||
|
||||
|
||||
class RedditTokenTask(app.Task):
|
||||
name = "RedditTokenTask"
|
||||
ignore_result = True
|
||||
|
||||
def run(self, user_pk):
|
||||
from newsreader.news.collection.reddit import REDDIT_URL
|
||||
|
||||
try:
|
||||
user = User.objects.get(pk=user_pk)
|
||||
except ObjectDoesNotExist:
|
||||
message = f"User {user_pk} does not exist"
|
||||
logger.exception(message)
|
||||
|
||||
raise Reject(reason=message, requeue=False)
|
||||
|
||||
if not user.reddit_refresh_token:
|
||||
raise Reject(reason=f"User {user_pk} has no refresh token", requeue=False)
|
||||
|
||||
client_auth = requests.auth.HTTPBasicAuth(
|
||||
settings.REDDIT_CLIENT_ID, settings.REDDIT_CLIENT_SECRET
|
||||
)
|
||||
|
||||
try:
|
||||
response = post(
|
||||
f"{REDDIT_URL}/api/v1/access_token",
|
||||
data={
|
||||
"grant_type": "refresh_token",
|
||||
"refresh_token": user.reddit_refresh_token,
|
||||
},
|
||||
auth=client_auth,
|
||||
)
|
||||
except StreamException:
|
||||
logger.exception(
|
||||
f"Failed refreshing reddit access token for user {user_pk}"
|
||||
)
|
||||
|
||||
user.reddit_refresh_token = None
|
||||
user.save()
|
||||
|
||||
message = _(
|
||||
"Your Reddit account credentials have expired. Re-authenticate in"
|
||||
" the settings page to keep retrieving Reddit specific information"
|
||||
" from your account."
|
||||
)
|
||||
|
||||
send_mail(
|
||||
"Reddit account needs re-authentication", message, None, [user.email]
|
||||
)
|
||||
return
|
||||
|
||||
response_data = response.json()
|
||||
|
||||
user.reddit_access_token = response_data["access_token"]
|
||||
user.save()
|
||||
|
||||
|
||||
class FaviconTask(app.Task):
|
||||
name = "FaviconTask"
|
||||
ignore_result = True
|
||||
|
|
@ -150,15 +65,6 @@ class FaviconTask(app.Task):
|
|||
|
||||
collector = FaviconCollector()
|
||||
collector.collect(rules=rules)
|
||||
|
||||
third_party_rules = user.rules.enabled().exclude(
|
||||
type=RuleTypeChoices.feed
|
||||
)
|
||||
|
||||
for rule in third_party_rules:
|
||||
if rule.type == RuleTypeChoices.subreddit:
|
||||
rule.favicon = "https://www.reddit.com/favicon.ico"
|
||||
rule.save()
|
||||
else:
|
||||
logger.warning("Cancelling task due to existing lock")
|
||||
|
||||
|
|
@ -167,5 +73,3 @@ class FaviconTask(app.Task):
|
|||
|
||||
FeedTask = app.register_task(FeedTask())
|
||||
FaviconTask = app.register_task(FaviconTask())
|
||||
RedditTask = app.register_task(RedditTask())
|
||||
RedditTokenTask = app.register_task(RedditTokenTask())
|
||||
|
|
|
|||
|
|
@ -2,139 +2,137 @@
|
|||
{% load i18n static filters %}
|
||||
|
||||
{% block content %}
|
||||
<main id="rules--page" class="main" data-render-sidebar=true>
|
||||
<div class="main__container">
|
||||
<form class="form rules-form">
|
||||
{% csrf_token %}
|
||||
<main id="rules--page" class="main" data-render-sidebar=true>
|
||||
<div class="main__container">
|
||||
<form class="form rules-form">
|
||||
{% csrf_token %}
|
||||
|
||||
<section class="section form__section form__section--actions">
|
||||
<div class="form__actions">
|
||||
<a class="link button button--confirm" href="{% url "news:collection:feed-create" %}">{% trans "Add a feed" %}</a>
|
||||
<a class="link button button--confirm" href="{% url "news:collection:import" %}">{% trans "Import feeds" %}</a>
|
||||
<a class="link button button--reddit" href="{% url "news:collection:subreddit-create" %}">{% trans "Add a subreddit" %}</a>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="section form__section form__section--actions">
|
||||
<fieldset class="fieldset form__fieldset">
|
||||
<input type="submit" class="button button--primary" formaction="{% url "news:collection:rules-enable" %}" formmethod="post" value="{% trans "Enable" %}" />
|
||||
<input type="submit" class="button button--primary" formaction="{% url "news:collection:rules-disable" %}" formmethod="post" value="{% trans "Disable" %}" />
|
||||
<input type="submit" class="button button--error" formaction="{% url "news:collection:rules-delete" %}" formmethod="post" value="{% trans "Delete" %}"/>
|
||||
</fieldset>
|
||||
</section>
|
||||
|
||||
<section class="section form__section">
|
||||
<table class="table rules-table" border="0" cellspacing="0">
|
||||
<thead class="table__header rules-table__header">
|
||||
<tr class="table__row rules-table__row">
|
||||
<th class="table__heading rules-table__heading--select">
|
||||
{% include "components/form/checkbox.html" with id="select-all" data_input="rules" id_for_label="select-all" %}
|
||||
</th>
|
||||
<th class="table__heading rules-table__heading rules-table__heading--name">{% trans "Name" %}</th>
|
||||
<th class="table__heading rules-table__heading rules-table__heading--category">{% trans "Category" %}</th>
|
||||
<th class="table__heading rules-table__heading rules-table__heading--url">{% trans "URL" %}</th>
|
||||
<th class="table__heading rules-table__heading rules-table__heading--succeeded">{% trans "Successfuly ran" %}</th>
|
||||
<th class="table__heading rules-table__heading rules-table__heading--enabled">{% trans "Enabled" %}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="table__body rules-table__body">
|
||||
{% for rule in rules %}
|
||||
<tr class="table__row {% if rule.failed %}table__row--error{% endif %} rules-table__row">
|
||||
<td class="table__item rules-table__item--select">
|
||||
{% with rule|id_for_label:"rules" as id_for_label %}
|
||||
{% include "components/form/checkbox.html" with name="rules" value=rule.pk id=id_for_label id_for_label=id_for_label %}
|
||||
{% endwith %}
|
||||
</td>
|
||||
|
||||
<td
|
||||
class="table__item rules-table__item rules-table__item--name"
|
||||
title="{{ rule.name }}"
|
||||
>
|
||||
<a class="link" href="{{ rule.update_url }}">
|
||||
{{ rule.name }}
|
||||
</a>
|
||||
</td>
|
||||
|
||||
<td
|
||||
class="table__item rules-table__item rules-table__item--category"
|
||||
title="{{ rule.category.name }}"
|
||||
>
|
||||
{% if rule.category %}
|
||||
<a
|
||||
class="link"
|
||||
href="{% url 'news:core:category-update' pk=rule.category.pk %}"
|
||||
>
|
||||
{{ rule.category.name }}
|
||||
</a>
|
||||
{% endif %}
|
||||
</td>
|
||||
|
||||
<td
|
||||
class="table__item rules-table__item rules-table__item--url"
|
||||
title="{{ rule.source_url }}"
|
||||
>
|
||||
<a
|
||||
class="link"
|
||||
href="{{ rule.source_url }}"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
{{ rule.source_url }}
|
||||
</a>
|
||||
</td>
|
||||
|
||||
<td class="table__item rules-table__item rules-table__item--failed">
|
||||
{% if rule.failed %}
|
||||
<i class="fas fa-exclamation-triangle"></i>
|
||||
{% else %}
|
||||
<i class="fas fa-check"></i>
|
||||
{% endif %}
|
||||
</td>
|
||||
|
||||
<td class="table__item rules-table__item rules-table__item--enabled">
|
||||
{% if rule.enabled %}
|
||||
<i class="fas fa-check"></i>
|
||||
{% else %}
|
||||
<i class="fas fa-pause"></i>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</section>
|
||||
</form>
|
||||
|
||||
<div class="table__footer">
|
||||
<div class="pagination">
|
||||
<span class="pagination__previous">
|
||||
{% if page_obj.has_previous %}
|
||||
<a class="link button" href="?page=1">{% trans "first" %}</a>
|
||||
<a class="link button" href="?page={{ page_obj.previous_page_number }}">
|
||||
{% trans "previous" %}
|
||||
</a>
|
||||
{% endif %}
|
||||
</span>
|
||||
|
||||
<span class="pagination__current">
|
||||
{% blocktrans with current_number=page_obj.number total_count=page_obj.paginator.num_pages %}
|
||||
Page {{ current_number }} of {{ total_count }}
|
||||
{% endblocktrans %}
|
||||
</span>
|
||||
|
||||
<span class="pagination__next">
|
||||
{% if page_obj.has_next %}
|
||||
<a class="link button" href="?page={{ page_obj.next_page_number }}">
|
||||
{% trans "next" %}
|
||||
</a>
|
||||
|
||||
<a class="link button" href="?page={{ page_obj.paginator.num_pages }}">
|
||||
{% trans "last" %}
|
||||
</a>
|
||||
{% endif %}
|
||||
</span>
|
||||
<section class="section form__section form__section--actions">
|
||||
<div class="form__actions">
|
||||
<a class="link button button--confirm"
|
||||
href="{% url 'news:collection:feed-create' %}">{% trans "Add a feed" %}</a>
|
||||
<a class="link button button--confirm"
|
||||
href="{% url 'news:collection:import' %}">{% trans "Import feeds" %}</a>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="section form__section form__section--actions">
|
||||
<fieldset class="fieldset form__fieldset">
|
||||
<input type="submit" class="button button--primary" formaction="{% url 'news:collection:rules-enable' %}"
|
||||
formmethod="post" value="{% trans " Enable" %}" />
|
||||
<input type="submit" class="button button--primary" formaction="{% url 'news:collection:rules-disable' %}"
|
||||
formmethod="post" value="{% trans " Disable" %}" />
|
||||
<input type="submit" class="button button--error" formaction="{% url 'news:collection:rules-delete' %}"
|
||||
formmethod="post" value="{% trans " Delete" %}" />
|
||||
</fieldset>
|
||||
</section>
|
||||
|
||||
<section class="section form__section">
|
||||
<table class="table rules-table" border="0" cellspacing="0">
|
||||
<thead class="table__header rules-table__header">
|
||||
<tr class="table__row rules-table__row">
|
||||
<th class="table__heading rules-table__heading--select">
|
||||
{% include "components/form/checkbox.html" with id="select-all" data_input="rules" id_for_label="select-all" %}
|
||||
</th>
|
||||
<th class="table__heading rules-table__heading rules-table__heading--name">
|
||||
{% trans "Name" %}
|
||||
</th>
|
||||
<th class="table__heading rules-table__heading rules-table__heading--category">
|
||||
{% trans "Category" %}
|
||||
</th>
|
||||
<th class="table__heading rules-table__heading rules-table__heading--url">
|
||||
{% trans "URL" %}
|
||||
</th>
|
||||
<th class="table__heading rules-table__heading rules-table__heading--succeeded">
|
||||
{% trans "Successfuly ran" %}
|
||||
</th>
|
||||
<th class="table__heading rules-table__heading rules-table__heading--enabled">
|
||||
{% trans "Enabled" %}
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="table__body rules-table__body">
|
||||
{% for rule in rules %}
|
||||
<tr class="table__row {% if rule.failed %}table__row--error{% endif %} rules-table__row">
|
||||
<td class="table__item rules-table__item--select">
|
||||
{% with rule|id_for_label:"rules" as id_for_label %}
|
||||
{% include "components/form/checkbox.html" with name="rules" value=rule.pk id=id_for_label
|
||||
id_for_label=id_for_label %}
|
||||
{% endwith %}
|
||||
</td>
|
||||
|
||||
<td class="table__item rules-table__item rules-table__item--name" title="{{ rule.name }}">
|
||||
<a class="link" href="{{ rule.update_url }}">
|
||||
{{ rule.name }}
|
||||
</a>
|
||||
</td>
|
||||
|
||||
<td class="table__item rules-table__item rules-table__item--category" title="{{ rule.category.name }}">
|
||||
{% if rule.category %}
|
||||
<a class="link" href="{% url 'news:core:category-update' pk=rule.category.pk %}">
|
||||
{{ rule.category.name }}
|
||||
</a>
|
||||
{% endif %}
|
||||
</td>
|
||||
|
||||
<td class="table__item rules-table__item rules-table__item--url" title="{{ rule.source_url }}">
|
||||
<a class="link" href="{{ rule.source_url }}" target="_blank" rel="noopener noreferrer">
|
||||
{{ rule.source_url }}
|
||||
</a>
|
||||
</td>
|
||||
|
||||
<td class="table__item rules-table__item rules-table__item--failed">
|
||||
{% if rule.failed %}
|
||||
<i class="fas fa-exclamation-triangle"></i>
|
||||
{% else %}
|
||||
<i class="fas fa-check"></i>
|
||||
{% endif %}
|
||||
</td>
|
||||
|
||||
<td class="table__item rules-table__item rules-table__item--enabled">
|
||||
{% if rule.enabled %}
|
||||
<i class="fas fa-check"></i>
|
||||
{% else %}
|
||||
<i class="fas fa-pause"></i>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</section>
|
||||
</form>
|
||||
|
||||
<div class="table__footer">
|
||||
<div class="pagination">
|
||||
<span class="pagination__previous">
|
||||
{% if page_obj.has_previous %}
|
||||
<a class="link button" href="?page=1">{% trans "first" %}</a>
|
||||
<a class="link button" href="?page={{ page_obj.previous_page_number }}">
|
||||
{% trans "previous" %}
|
||||
</a>
|
||||
{% endif %}
|
||||
</span>
|
||||
|
||||
<span class="pagination__current">
|
||||
{% blocktrans with current_number=page_obj.number total_count=page_obj.paginator.num_pages %}
|
||||
Page {{ current_number }} of {{ total_count }}
|
||||
{% endblocktrans %}
|
||||
</span>
|
||||
|
||||
<span class="pagination__next">
|
||||
{% if page_obj.has_next %}
|
||||
<a class="link button" href="?page={{ page_obj.next_page_number }}">
|
||||
{% trans "next" %}
|
||||
</a>
|
||||
|
||||
<a class="link button" href="?page={{ page_obj.paginator.num_pages }}">
|
||||
{% trans "last" %}
|
||||
</a>
|
||||
{% endif %}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
</main>
|
||||
{% endblock %}
|
||||
|
|
|
|||
|
|
@ -1,13 +0,0 @@
|
|||
{% extends "sidebar.html" %}
|
||||
{% load static %}
|
||||
|
||||
|
||||
{% block content %}
|
||||
{% url "news:collection:rules" as cancel_url %}
|
||||
|
||||
<main id="subreddit--page" class="main" data-render-sidebar=true>
|
||||
<div class="main__container">
|
||||
{% include "components/form/form.html" with form=form title="Add a subreddit" cancel_url=cancel_url confirm_text="Add subrredit" %}
|
||||
</div>
|
||||
</main>
|
||||
{% endblock %}
|
||||
|
|
@ -1,14 +0,0 @@
|
|||
{% extends "base.html" %}
|
||||
{% load static i18n %}
|
||||
|
||||
{% block content %}
|
||||
<main id="subreddit--page" class="main">
|
||||
{% if subreddit.error %}
|
||||
{% trans "Failed to retrieve posts" as title %}
|
||||
{% include "components/textbox/textbox.html" with title=title body=subreddit.error class="text-section--error" only %}
|
||||
{% endif %}
|
||||
|
||||
{% url "news:collection:rules" as cancel_url %}
|
||||
{% include "components/form/form.html" with form=form title="Update subreddit" cancel_url=cancel_url confirm_text="Save subreddit" %}
|
||||
</main>
|
||||
{% endblock %}
|
||||
|
|
@ -3,7 +3,6 @@ import factory
|
|||
from newsreader.accounts.tests.factories import UserFactory
|
||||
from newsreader.news.collection.choices import RuleTypeChoices
|
||||
from newsreader.news.collection.models import CollectionRule
|
||||
from newsreader.news.collection.reddit import REDDIT_URL
|
||||
|
||||
|
||||
class CollectionRuleFactory(factory.django.DjangoModelFactory):
|
||||
|
|
@ -23,8 +22,3 @@ class CollectionRuleFactory(factory.django.DjangoModelFactory):
|
|||
|
||||
class FeedFactory(CollectionRuleFactory):
|
||||
type = RuleTypeChoices.feed
|
||||
|
||||
|
||||
class SubredditFactory(CollectionRuleFactory):
|
||||
type = RuleTypeChoices.subreddit
|
||||
website_url = REDDIT_URL
|
||||
|
|
|
|||
|
|
@ -5,12 +5,12 @@ from django.test import TestCase
|
|||
from newsreader.news.collection.favicon import FaviconBuilder
|
||||
from newsreader.news.collection.tests.factories import CollectionRuleFactory
|
||||
from newsreader.news.collection.tests.favicon.builder.mocks import (
|
||||
simple_mock,
|
||||
mock_without_url,
|
||||
mock_without_header,
|
||||
mock_with_weird_path,
|
||||
mock_with_other_url,
|
||||
mock_with_multiple_icons,
|
||||
mock_with_other_url,
|
||||
mock_with_weird_path,
|
||||
mock_without_header,
|
||||
mock_without_url,
|
||||
simple_mock,
|
||||
)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -44,7 +44,7 @@ feed_mock = {
|
|||
"base": "http://feeds.bbci.co.uk/news/rss.xml",
|
||||
"language": None,
|
||||
"type": "text/plain",
|
||||
"value": "Trump's genocidal taunts will not " "end Iran - Zarif",
|
||||
"value": "Trump's genocidal taunts will not end Iran - Zarif",
|
||||
},
|
||||
},
|
||||
{
|
||||
|
|
@ -83,7 +83,7 @@ feed_mock = {
|
|||
"base": "http://feeds.bbci.co.uk/news/rss.xml",
|
||||
"language": None,
|
||||
"type": "text/plain",
|
||||
"value": "Huawei's Android loss: How it " "affects you",
|
||||
"value": "Huawei's Android loss: How it affects you",
|
||||
},
|
||||
},
|
||||
{
|
||||
|
|
@ -124,7 +124,7 @@ feed_mock = {
|
|||
"base": "http://feeds.bbci.co.uk/news/rss.xml",
|
||||
"language": None,
|
||||
"type": "text/plain",
|
||||
"value": "Birmingham head teacher threatened " "over LGBT lessons",
|
||||
"value": "Birmingham head teacher threatened over LGBT lessons",
|
||||
},
|
||||
},
|
||||
],
|
||||
|
|
|
|||
|
|
@ -11,21 +11,21 @@ from newsreader.news.core.models import Post
|
|||
from newsreader.news.core.tests.factories import FeedPostFactory
|
||||
|
||||
from .mocks import (
|
||||
multiple_mock,
|
||||
mock_with_html,
|
||||
mock_with_long_author,
|
||||
mock_with_long_exotic_title,
|
||||
mock_with_long_title,
|
||||
mock_with_longer_content_detail,
|
||||
mock_with_multiple_content_detail,
|
||||
mock_with_shorter_content_detail,
|
||||
mock_with_update_entries,
|
||||
mock_without_author,
|
||||
mock_without_body,
|
||||
mock_without_entries,
|
||||
mock_without_identifier,
|
||||
mock_without_publish_date,
|
||||
mock_without_url,
|
||||
mock_without_body,
|
||||
mock_without_author,
|
||||
mock_without_entries,
|
||||
mock_with_update_entries,
|
||||
mock_with_html,
|
||||
mock_with_long_author,
|
||||
mock_with_long_title,
|
||||
mock_with_long_exotic_title,
|
||||
mock_with_longer_content_detail,
|
||||
mock_with_shorter_content_detail,
|
||||
mock_with_multiple_content_detail,
|
||||
multiple_mock,
|
||||
)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -42,7 +42,7 @@ simple_mock = {
|
|||
"base": "http://feeds.bbci.co.uk/news/rss.xml",
|
||||
"language": None,
|
||||
"type": "text/plain",
|
||||
"value": "Trump's 'genocidal taunts' will not " "end Iran - Zarif",
|
||||
"value": "Trump's 'genocidal taunts' will not end Iran - Zarif",
|
||||
},
|
||||
}
|
||||
],
|
||||
|
|
|
|||
|
|
@ -42,7 +42,7 @@ multiple_mock = {
|
|||
"base": "http://feeds.bbci.co.uk/news/rss.xml",
|
||||
"language": None,
|
||||
"type": "text/plain",
|
||||
"value": "Trump's 'genocidal taunts' will not " "end Iran - Zarif",
|
||||
"value": "Trump's 'genocidal taunts' will not end Iran - Zarif",
|
||||
},
|
||||
},
|
||||
{
|
||||
|
|
@ -81,7 +81,7 @@ multiple_mock = {
|
|||
"base": "http://feeds.bbci.co.uk/news/rss.xml",
|
||||
"language": None,
|
||||
"type": "text/plain",
|
||||
"value": "Huawei's Android loss: How it " "affects you",
|
||||
"value": "Huawei's Android loss: How it affects you",
|
||||
},
|
||||
},
|
||||
{
|
||||
|
|
@ -122,7 +122,7 @@ multiple_mock = {
|
|||
"base": "http://feeds.bbci.co.uk/news/rss.xml",
|
||||
"language": None,
|
||||
"type": "text/plain",
|
||||
"value": "Birmingham head teacher threatened " "over LGBT lessons",
|
||||
"value": "Birmingham head teacher threatened over LGBT lessons",
|
||||
},
|
||||
},
|
||||
],
|
||||
|
|
@ -212,7 +212,7 @@ duplicate_mock = {
|
|||
"base": "http://feeds.bbci.co.uk/news/rss.xml",
|
||||
"language": None,
|
||||
"type": "text/plain",
|
||||
"value": "Trump's 'genocidal taunts' will not " "end Iran - Zarif",
|
||||
"value": "Trump's 'genocidal taunts' will not end Iran - Zarif",
|
||||
},
|
||||
},
|
||||
{
|
||||
|
|
@ -250,7 +250,7 @@ duplicate_mock = {
|
|||
"base": "http://feeds.bbci.co.uk/news/rss.xml",
|
||||
"language": None,
|
||||
"type": "text/plain",
|
||||
"value": "Huawei's Android loss: How it " "affects you",
|
||||
"value": "Huawei's Android loss: How it affects you",
|
||||
},
|
||||
},
|
||||
{
|
||||
|
|
@ -290,7 +290,7 @@ duplicate_mock = {
|
|||
"base": "http://feeds.bbci.co.uk/news/rss.xml",
|
||||
"language": None,
|
||||
"type": "text/plain",
|
||||
"value": "Birmingham head teacher threatened " "over LGBT lessons",
|
||||
"value": "Birmingham head teacher threatened over LGBT lessons",
|
||||
},
|
||||
},
|
||||
],
|
||||
|
|
@ -356,7 +356,7 @@ multiple_update_mock = {
|
|||
"base": "http://feeds.bbci.co.uk/news/rss.xml",
|
||||
"language": None,
|
||||
"type": "text/plain",
|
||||
"value": "Trump's 'genocidal taunts' will not " "end Iran - Zarif",
|
||||
"value": "Trump's 'genocidal taunts' will not end Iran - Zarif",
|
||||
},
|
||||
},
|
||||
{
|
||||
|
|
@ -395,7 +395,7 @@ multiple_update_mock = {
|
|||
"base": "http://feeds.bbci.co.uk/news/rss.xml",
|
||||
"language": None,
|
||||
"type": "text/plain",
|
||||
"value": "Huawei's Android loss: How it " "affects you",
|
||||
"value": "Huawei's Android loss: How it affects you",
|
||||
},
|
||||
},
|
||||
{
|
||||
|
|
@ -436,7 +436,7 @@ multiple_update_mock = {
|
|||
"base": "http://feeds.bbci.co.uk/news/rss.xml",
|
||||
"language": None,
|
||||
"type": "text/plain",
|
||||
"value": "Birmingham head teacher threatened " "over LGBT lessons",
|
||||
"value": "Birmingham head teacher threatened over LGBT lessons",
|
||||
},
|
||||
},
|
||||
],
|
||||
|
|
|
|||
|
|
@ -73,12 +73,12 @@ simple_mock_parsed = {
|
|||
"not think face coverings should be "
|
||||
"mandatory in shops in England.",
|
||||
},
|
||||
"title": "Coronavirus: I trust people's sense on face masks - " "Gove",
|
||||
"title": "Coronavirus: I trust people's sense on face masks - Gove",
|
||||
"title_detail": {
|
||||
"base": "",
|
||||
"language": None,
|
||||
"type": "text/plain",
|
||||
"value": "Coronavirus: I trust people's sense " "on face masks - Gove",
|
||||
"value": "Coronavirus: I trust people's sense on face masks - Gove",
|
||||
},
|
||||
},
|
||||
{
|
||||
|
|
@ -109,7 +109,7 @@ simple_mock_parsed = {
|
|||
"base": "",
|
||||
"language": None,
|
||||
"type": "text/plain",
|
||||
"value": "Farm outbreak leads 200 to self " "isolate",
|
||||
"value": "Farm outbreak leads 200 to self isolate",
|
||||
},
|
||||
},
|
||||
{
|
||||
|
|
@ -137,12 +137,12 @@ simple_mock_parsed = {
|
|||
"talks on tackling people "
|
||||
"smuggling.",
|
||||
},
|
||||
"title": "English Channel search operation after migrant " "crossings",
|
||||
"title": "English Channel search operation after migrant crossings",
|
||||
"title_detail": {
|
||||
"base": "",
|
||||
"language": None,
|
||||
"type": "text/plain",
|
||||
"value": "English Channel search operation " "after migrant crossings",
|
||||
"value": "English Channel search operation after migrant crossings",
|
||||
},
|
||||
},
|
||||
],
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -1,472 +0,0 @@
|
|||
from datetime import datetime, timezone
|
||||
from unittest.mock import Mock
|
||||
|
||||
from django.test import TestCase
|
||||
|
||||
from newsreader.news.collection.reddit import RedditBuilder
|
||||
from newsreader.news.collection.tests.factories import SubredditFactory
|
||||
from newsreader.news.collection.tests.reddit.builder.mocks import (
|
||||
simple_mock,
|
||||
empty_mock,
|
||||
unknown_mock,
|
||||
unsanitized_mock,
|
||||
author_mock,
|
||||
title_mock,
|
||||
duplicate_mock,
|
||||
image_mock,
|
||||
external_image_mock,
|
||||
video_mock,
|
||||
external_video_mock,
|
||||
external_gifv_mock,
|
||||
nsfw_mock,
|
||||
spoiler_mock,
|
||||
seen_mock,
|
||||
upvote_mock,
|
||||
comment_mock,
|
||||
downvote_mock,
|
||||
)
|
||||
from newsreader.news.core.models import Post
|
||||
from newsreader.news.core.tests.factories import RedditPostFactory
|
||||
|
||||
|
||||
class RedditBuilderTestCase(TestCase):
|
||||
def setUp(self):
|
||||
self.maxDiff = None
|
||||
|
||||
def test_simple_mock(self):
|
||||
builder = RedditBuilder
|
||||
|
||||
subreddit = SubredditFactory()
|
||||
mock_stream = Mock(rule=subreddit)
|
||||
|
||||
with builder(simple_mock, mock_stream) as builder:
|
||||
builder.build()
|
||||
builder.save()
|
||||
|
||||
posts = {post.remote_identifier: post for post in Post.objects.all()}
|
||||
|
||||
self.assertCountEqual(
|
||||
("hm0qct", "hna75r", "hngs71", "hngsj8", "hnd7cy"), posts.keys()
|
||||
)
|
||||
|
||||
post = posts["hm0qct"]
|
||||
|
||||
self.assertEqual(post.rule, subreddit)
|
||||
self.assertEqual(
|
||||
post.title,
|
||||
"Linux Experiences/Rants or Education/Certifications thread - July 06, 2020",
|
||||
)
|
||||
self.assertIn(
|
||||
" This megathread is also to hear opinions from anyone just starting out"
|
||||
" with Linux or those that have used Linux (GNU or otherwise) for a long",
|
||||
post.body,
|
||||
)
|
||||
|
||||
self.assertIn(
|
||||
"<p>For those looking for certifications please use this megathread to ask about how"
|
||||
" to get certified whether it's for the business world or for your own satisfaction."
|
||||
' Be sure to check out <a href="/r/linuxadmin">r/linuxadmin</a> for more discussion in the'
|
||||
" SysAdmin world!</p>",
|
||||
post.body,
|
||||
)
|
||||
|
||||
self.assertEqual(post.author, "AutoModerator")
|
||||
self.assertEqual(
|
||||
post.url,
|
||||
"https://www.reddit.com/r/linux/comments/hm0qct/linux_experiencesrants_or_educationcertifications/",
|
||||
)
|
||||
self.assertEqual(
|
||||
post.publication_date, datetime(2020, 7, 6, 6, 11, 22, tzinfo=timezone.utc)
|
||||
)
|
||||
|
||||
def test_empty_data(self):
|
||||
builder = RedditBuilder
|
||||
|
||||
subreddit = SubredditFactory()
|
||||
mock_stream = Mock(rule=subreddit)
|
||||
|
||||
with builder(empty_mock, mock_stream) as builder:
|
||||
builder.build()
|
||||
builder.save()
|
||||
|
||||
self.assertEqual(Post.objects.count(), 0)
|
||||
|
||||
def test_unknown_mock(self):
|
||||
builder = RedditBuilder
|
||||
|
||||
subreddit = SubredditFactory()
|
||||
mock_stream = Mock(rule=subreddit)
|
||||
|
||||
with builder(unknown_mock, mock_stream) as builder:
|
||||
builder.build()
|
||||
builder.save()
|
||||
|
||||
self.assertEqual(Post.objects.count(), 0)
|
||||
|
||||
def test_html_sanitizing(self):
|
||||
builder = RedditBuilder
|
||||
|
||||
subreddit = SubredditFactory()
|
||||
mock_stream = Mock(rule=subreddit)
|
||||
|
||||
with builder(unsanitized_mock, mock_stream) as builder:
|
||||
builder.build()
|
||||
builder.save()
|
||||
|
||||
posts = {post.remote_identifier: post for post in Post.objects.all()}
|
||||
|
||||
self.assertCountEqual(("hnd7cy",), posts.keys())
|
||||
|
||||
post = posts["hnd7cy"]
|
||||
|
||||
self.assertEqual(post.body, "<article></article>")
|
||||
|
||||
def test_long_author_text_is_truncated(self):
|
||||
builder = RedditBuilder
|
||||
|
||||
subreddit = SubredditFactory()
|
||||
mock_stream = Mock(rule=subreddit)
|
||||
|
||||
with builder(author_mock, mock_stream) as builder:
|
||||
builder.build()
|
||||
builder.save()
|
||||
|
||||
posts = {post.remote_identifier: post for post in Post.objects.all()}
|
||||
|
||||
self.assertCountEqual(("hnd7cy",), posts.keys())
|
||||
|
||||
post = posts["hnd7cy"]
|
||||
|
||||
self.assertEqual(post.author, "TheQuantumZeroTheQuantumZeroTheQuantumZ…")
|
||||
|
||||
def test_long_title_text_is_truncated(self):
|
||||
builder = RedditBuilder
|
||||
|
||||
subreddit = SubredditFactory()
|
||||
mock_stream = Mock(rule=subreddit)
|
||||
|
||||
with builder(title_mock, mock_stream) as builder:
|
||||
builder.build()
|
||||
builder.save()
|
||||
|
||||
posts = {post.remote_identifier: post for post in Post.objects.all()}
|
||||
|
||||
self.assertCountEqual(("hnd7cy",), posts.keys())
|
||||
|
||||
post = posts["hnd7cy"]
|
||||
|
||||
self.assertEqual(
|
||||
post.title,
|
||||
'Board statement on the LibreOffice 7.0 RC "Personal EditionBoard statement on the LibreOffice 7.0 RC "Personal Edition" label" labelBoard statement on the LibreOffice 7.0 RC "PersBoard statement on t…',
|
||||
)
|
||||
|
||||
def test_duplicate_in_response(self):
|
||||
builder = RedditBuilder
|
||||
|
||||
subreddit = SubredditFactory()
|
||||
mock_stream = Mock(rule=subreddit)
|
||||
|
||||
with builder(duplicate_mock, mock_stream) as builder:
|
||||
builder.build()
|
||||
builder.save()
|
||||
|
||||
posts = {post.remote_identifier: post for post in Post.objects.all()}
|
||||
|
||||
self.assertEqual(Post.objects.count(), 2)
|
||||
self.assertCountEqual(("hm0qct", "hna75r"), posts.keys())
|
||||
|
||||
def test_duplicate_in_database(self):
|
||||
builder = RedditBuilder
|
||||
|
||||
subreddit = SubredditFactory()
|
||||
mock_stream = Mock(rule=subreddit)
|
||||
|
||||
RedditPostFactory(remote_identifier="hm0qct", rule=subreddit, title="foo")
|
||||
|
||||
with builder(simple_mock, mock_stream) as builder:
|
||||
builder.build()
|
||||
builder.save()
|
||||
|
||||
posts = {post.remote_identifier: post for post in Post.objects.all()}
|
||||
|
||||
self.assertEqual(Post.objects.count(), 5)
|
||||
self.assertCountEqual(
|
||||
("hm0qct", "hna75r", "hngs71", "hngsj8", "hnd7cy"), posts.keys()
|
||||
)
|
||||
|
||||
def test_image_post(self):
|
||||
builder = RedditBuilder
|
||||
|
||||
subreddit = SubredditFactory()
|
||||
mock_stream = Mock(rule=subreddit)
|
||||
|
||||
with builder(image_mock, mock_stream) as builder:
|
||||
builder.build()
|
||||
builder.save()
|
||||
|
||||
posts = {post.remote_identifier: post for post in Post.objects.all()}
|
||||
|
||||
self.assertCountEqual(("hr64xh", "hr4bxo", "hr14y5", "hr2fv0"), posts.keys())
|
||||
|
||||
post = posts["hr64xh"]
|
||||
|
||||
title = (
|
||||
"Ya’ll, I just can’t... this is my "
|
||||
"son, Judah. My wife and I have no "
|
||||
"idea how we created such a "
|
||||
"beautiful child."
|
||||
)
|
||||
url = "https://i.redd.it/cm2qybia1va51.jpg"
|
||||
|
||||
self.assertEqual(
|
||||
"https://www.reddit.com/r/aww/comments/hr64xh/yall_i_just_cant_this_is_my_son_judah_my_wife_and/",
|
||||
post.url,
|
||||
)
|
||||
self.assertEqual(
|
||||
f"<div><img alt='{title}' src='{url}' loading='lazy' /></div>", post.body
|
||||
)
|
||||
|
||||
def test_external_image_post(self):
|
||||
builder = RedditBuilder
|
||||
|
||||
subreddit = SubredditFactory()
|
||||
mock_stream = Mock(rule=subreddit)
|
||||
|
||||
with builder(external_image_mock, mock_stream) as builder:
|
||||
builder.build()
|
||||
builder.save()
|
||||
|
||||
posts = {post.remote_identifier: post for post in Post.objects.all()}
|
||||
|
||||
self.assertCountEqual(("hr41am", "huoldn"), posts.keys())
|
||||
|
||||
post = posts["hr41am"]
|
||||
|
||||
url = "http://gfycat.com/thatalivedogwoodclubgall"
|
||||
title = "Excited cows have a new brush!"
|
||||
|
||||
self.assertEqual(
|
||||
f"<div><a target='_blank' rel='noopener noreferrer' alt='{title}' href='{url}' class='link'>Direct url</a></div>",
|
||||
post.body,
|
||||
)
|
||||
self.assertEqual(
|
||||
"https://www.reddit.com/r/aww/comments/hr41am/excited_cows_have_a_new_brush/",
|
||||
post.url,
|
||||
)
|
||||
|
||||
post = posts["huoldn"]
|
||||
|
||||
url = "https://i.imgur.com/usfMVUJ.jpg"
|
||||
title = "Novosibirsk Zoo welcomes 16 cobalt-eyed Pallas’s cat kittens"
|
||||
|
||||
self.assertEqual(
|
||||
f"<div><img alt='{title}' src='{url}' loading='lazy' /></div>", post.body
|
||||
)
|
||||
self.assertEqual(
|
||||
"https://www.reddit.com/r/aww/comments/huoldn/novosibirsk_zoo_welcomes_16_cobalteyed_pallass/",
|
||||
post.url,
|
||||
)
|
||||
|
||||
def test_video_post(self):
|
||||
builder = RedditBuilder
|
||||
|
||||
subreddit = SubredditFactory()
|
||||
mock_stream = Mock(rule=subreddit)
|
||||
|
||||
with builder(video_mock, mock_stream) as builder:
|
||||
builder.build()
|
||||
builder.save()
|
||||
|
||||
posts = {post.remote_identifier: post for post in Post.objects.all()}
|
||||
|
||||
self.assertCountEqual(("hr32jf", "hr1r00", "hqy0ny", "hr0uzh"), posts.keys())
|
||||
|
||||
post = posts["hr1r00"]
|
||||
|
||||
url = "https://v.redd.it/eyvbxaeqtta51/DASH_480.mp4?source=fallback"
|
||||
|
||||
self.assertEqual(
|
||||
post.url,
|
||||
"https://www.reddit.com/r/aww/comments/hr1r00/cool_catt_and_his_clingy_girlfriend/",
|
||||
)
|
||||
self.assertEqual(
|
||||
f"<div><video controls muted><source src='{url}' type='video/mp4' /></video></div>",
|
||||
post.body,
|
||||
)
|
||||
|
||||
def test_external_video_post(self):
|
||||
builder = RedditBuilder
|
||||
|
||||
subreddit = SubredditFactory()
|
||||
mock_stream = Mock(rule=subreddit)
|
||||
|
||||
with builder(external_video_mock, mock_stream) as builder:
|
||||
builder.build()
|
||||
builder.save()
|
||||
|
||||
post = Post.objects.get()
|
||||
|
||||
self.assertEqual(post.remote_identifier, "hulh8k")
|
||||
|
||||
self.assertEqual(
|
||||
post.url,
|
||||
"https://www.reddit.com/r/aww/comments/hulh8k/dog_splashing_in_water/",
|
||||
)
|
||||
|
||||
title = "Dog splashing in water"
|
||||
url = "https://gfycat.com/excellentinfantileamericanwigeon"
|
||||
|
||||
self.assertEqual(
|
||||
f"<div><a target='_blank' rel='noopener noreferrer' alt='{title}' href='{url}' class='link'>Direct url</a></div>",
|
||||
post.body,
|
||||
)
|
||||
|
||||
def test_external_gifv_video_post(self):
|
||||
builder = RedditBuilder
|
||||
|
||||
subreddit = SubredditFactory()
|
||||
mock_stream = Mock(rule=subreddit)
|
||||
|
||||
with builder(external_gifv_mock, mock_stream) as builder:
|
||||
builder.build()
|
||||
builder.save()
|
||||
|
||||
post = Post.objects.get()
|
||||
|
||||
self.assertEqual(post.remote_identifier, "humdlf")
|
||||
|
||||
self.assertEqual(
|
||||
post.url, "https://www.reddit.com/r/aww/comments/humdlf/if_i_fits_i_sits/"
|
||||
)
|
||||
|
||||
self.assertEqual(
|
||||
"<div><video controls muted><source src='https://i.imgur.com/grVh2AG.mp4' type='video/mp4' /></video></div>",
|
||||
post.body,
|
||||
)
|
||||
|
||||
def test_link_only_post(self):
|
||||
builder = RedditBuilder
|
||||
|
||||
subreddit = SubredditFactory()
|
||||
mock_stream = Mock(rule=subreddit)
|
||||
|
||||
with builder(simple_mock, mock_stream) as builder:
|
||||
builder.build()
|
||||
builder.save()
|
||||
|
||||
post = Post.objects.get(remote_identifier="hngsj8")
|
||||
|
||||
title = "KeePassXC 2.6.0 released"
|
||||
url = "https://keepassxc.org/blog/2020-07-07-2.6.0-released/"
|
||||
|
||||
self.assertIn(
|
||||
f"<div><a target='_blank' rel='noopener noreferrer' alt='{title}' href='{url}' class='link'>Direct url</a></div>",
|
||||
post.body,
|
||||
)
|
||||
|
||||
self.assertEqual(
|
||||
post.url,
|
||||
"https://www.reddit.com/r/linux/comments/hngsj8/keepassxc_260_released/",
|
||||
)
|
||||
|
||||
def test_skip_not_known_post_type(self):
|
||||
builder = RedditBuilder
|
||||
|
||||
subreddit = SubredditFactory()
|
||||
mock_stream = Mock(rule=subreddit)
|
||||
|
||||
with builder(unknown_mock, mock_stream) as builder:
|
||||
builder.build()
|
||||
builder.save()
|
||||
|
||||
self.assertEqual(Post.objects.count(), 0)
|
||||
|
||||
def test_nsfw_not_allowed(self):
|
||||
builder = RedditBuilder
|
||||
|
||||
subreddit = SubredditFactory(reddit_allow_nfsw=False)
|
||||
mock_stream = Mock(rule=subreddit)
|
||||
|
||||
with builder(nsfw_mock, mock_stream) as builder:
|
||||
builder.build()
|
||||
builder.save()
|
||||
|
||||
posts = {post.remote_identifier: post for post in Post.objects.all()}
|
||||
|
||||
self.assertEqual(Post.objects.count(), 1)
|
||||
self.assertCountEqual(("hna75r",), posts.keys())
|
||||
|
||||
def test_spoiler_not_allowed(self):
|
||||
builder = RedditBuilder
|
||||
|
||||
subreddit = SubredditFactory(reddit_allow_spoiler=False)
|
||||
mock_stream = Mock(rule=subreddit)
|
||||
|
||||
with builder(spoiler_mock, mock_stream) as builder:
|
||||
builder.build()
|
||||
builder.save()
|
||||
|
||||
posts = {post.remote_identifier: post for post in Post.objects.all()}
|
||||
|
||||
self.assertEqual(Post.objects.count(), 1)
|
||||
self.assertCountEqual(("hm0qct",), posts.keys())
|
||||
|
||||
def test_already_seen_not_allowed(self):
|
||||
builder = RedditBuilder
|
||||
|
||||
subreddit = SubredditFactory(reddit_allow_viewed=False)
|
||||
mock_stream = Mock(rule=subreddit)
|
||||
|
||||
with builder(seen_mock, mock_stream) as builder:
|
||||
builder.build()
|
||||
builder.save()
|
||||
|
||||
posts = {post.remote_identifier: post for post in Post.objects.all()}
|
||||
|
||||
self.assertEqual(Post.objects.count(), 1)
|
||||
self.assertCountEqual(("hna75r",), posts.keys())
|
||||
|
||||
def test_upvote_minimum(self):
|
||||
builder = RedditBuilder
|
||||
|
||||
subreddit = SubredditFactory(reddit_upvotes_min=100)
|
||||
mock_stream = Mock(rule=subreddit)
|
||||
|
||||
with builder(upvote_mock, mock_stream) as builder:
|
||||
builder.build()
|
||||
builder.save()
|
||||
|
||||
posts = {post.remote_identifier: post for post in Post.objects.all()}
|
||||
|
||||
self.assertEqual(Post.objects.count(), 1)
|
||||
self.assertCountEqual(("hna75r",), posts.keys())
|
||||
|
||||
def test_comments_minimum(self):
|
||||
builder = RedditBuilder
|
||||
|
||||
subreddit = SubredditFactory(reddit_comments_min=100)
|
||||
mock_stream = Mock(rule=subreddit)
|
||||
|
||||
with builder(comment_mock, mock_stream) as builder:
|
||||
builder.build()
|
||||
builder.save()
|
||||
|
||||
posts = {post.remote_identifier: post for post in Post.objects.all()}
|
||||
|
||||
self.assertEqual(Post.objects.count(), 1)
|
||||
self.assertCountEqual(("hm0qct",), posts.keys())
|
||||
|
||||
def test_downvote_maximum(self):
|
||||
builder = RedditBuilder
|
||||
|
||||
subreddit = SubredditFactory(reddit_downvotes_max=20)
|
||||
mock_stream = Mock(rule=subreddit)
|
||||
|
||||
with builder(downvote_mock, mock_stream) as builder:
|
||||
builder.build()
|
||||
builder.save()
|
||||
|
||||
posts = {post.remote_identifier: post for post in Post.objects.all()}
|
||||
|
||||
self.assertEqual(Post.objects.count(), 1)
|
||||
self.assertCountEqual(("hm0qct",), posts.keys())
|
||||
|
|
@ -1,160 +0,0 @@
|
|||
# Note that some response data is truncated
|
||||
|
||||
simple_mock = {
|
||||
"data": {
|
||||
"after": "t3_hjywyf",
|
||||
"before": None,
|
||||
"children": [
|
||||
{
|
||||
"data": {
|
||||
"approved_at_utc": None,
|
||||
"approved_by": None,
|
||||
"archived": False,
|
||||
"author": "AutoModerator",
|
||||
"banned_at_utc": None,
|
||||
"banned_by": None,
|
||||
"category": None,
|
||||
"content_categories": None,
|
||||
"created": 1593605471.0,
|
||||
"created_utc": 1593576671.0,
|
||||
"discussion_type": None,
|
||||
"distinguished": "moderator",
|
||||
"domain": "self.linux",
|
||||
"edited": False,
|
||||
"hidden": False,
|
||||
"id": "hj34ck",
|
||||
"locked": False,
|
||||
"name": "t3_hj34ck",
|
||||
"permalink": "/r/linux/comments/hj34ck/weekly_questions_and_hardware_thread_july_01_2020/",
|
||||
"pinned": False,
|
||||
"selftext": "Welcome to r/linux! If you're "
|
||||
"new to Linux or trying to get "
|
||||
"started this thread is for you. "
|
||||
"Get help here or as always, "
|
||||
"check out r/linuxquestions or "
|
||||
"r/linux4noobs\n"
|
||||
"\n"
|
||||
"This megathread is for all your "
|
||||
"question needs. As we don't "
|
||||
"allow questions on r/linux "
|
||||
"outside of this megathread, "
|
||||
"please consider using "
|
||||
"r/linuxquestions or "
|
||||
"r/linux4noobs for the best "
|
||||
"solution to your problem.\n"
|
||||
"\n"
|
||||
"Ask your hardware requests here "
|
||||
"too or try r/linuxhardware!",
|
||||
"selftext_html": "<!-- SC_OFF "
|
||||
"--><div "
|
||||
'class="md"><p>Welcome '
|
||||
"to <a "
|
||||
'href="/r/linux">r/linux</a>! '
|
||||
"If you&#39;re new to "
|
||||
"Linux or trying to get "
|
||||
"started this thread is for "
|
||||
"you. Get help here or as "
|
||||
"always, check out <a "
|
||||
'href="/r/linuxquestions">r/linuxquestions</a> '
|
||||
"or <a "
|
||||
'href="/r/linux4noobs">r/linux4noobs</a></p>\n'
|
||||
"\n"
|
||||
"<p>This megathread is "
|
||||
"for all your question "
|
||||
"needs. As we don&#39;t "
|
||||
"allow questions on <a "
|
||||
'href="/r/linux">r/linux</a> '
|
||||
"outside of this megathread, "
|
||||
"please consider using <a "
|
||||
'href="/r/linuxquestions">r/linuxquestions</a> '
|
||||
"or <a "
|
||||
'href="/r/linux4noobs">r/linux4noobs</a> '
|
||||
"for the best solution to "
|
||||
"your problem.</p>\n"
|
||||
"\n"
|
||||
"<p>Ask your hardware "
|
||||
"requests here too or try "
|
||||
"<a "
|
||||
'href="/r/linuxhardware">r/linuxhardware</a>!</p>\n'
|
||||
"</div><!-- SC_ON "
|
||||
"-->",
|
||||
"spoiler": False,
|
||||
"stickied": True,
|
||||
"subreddit": "linux",
|
||||
"subreddit_id": "t5_2qh1a",
|
||||
"subreddit_name_prefixed": "r/linux",
|
||||
"title": "Weekly Questions and Hardware " "Thread - July 01, 2020",
|
||||
"url": "https://www.reddit.com/r/linux/comments/hj34ck/weekly_questions_and_hardware_thread_july_01_2020/",
|
||||
"visited": False,
|
||||
},
|
||||
"kind": "t3",
|
||||
},
|
||||
{
|
||||
"data": {
|
||||
"archived": False,
|
||||
"author": "AutoModerator",
|
||||
"banned_at_utc": None,
|
||||
"banned_by": None,
|
||||
"category": None,
|
||||
"created": 1593824903.0,
|
||||
"created_utc": 1593796103.0,
|
||||
"discussion_type": None,
|
||||
"domain": "self.linux",
|
||||
"edited": False,
|
||||
"hidden": False,
|
||||
"id": "hkmu0t",
|
||||
"name": "t3_hkmu0t",
|
||||
"permalink": "/r/linux/comments/hkmu0t/weekend_fluff_linux_in_the_wild_thread_july_03/",
|
||||
"pinned": False,
|
||||
"saved": False,
|
||||
"selftext": "Welcome to the weekend! This "
|
||||
"stickied thread is for you to "
|
||||
"post pictures of your ubuntu "
|
||||
"2006 install disk, slackware "
|
||||
"floppies, on-topic memes or "
|
||||
"more.\n"
|
||||
"\n"
|
||||
"When it's not the weekend, be "
|
||||
"sure to check out "
|
||||
"r/WildLinuxAppears or "
|
||||
"r/linuxmemes!",
|
||||
"selftext_html": "<!-- SC_OFF "
|
||||
"--><div "
|
||||
'class="md"><p>Welcome '
|
||||
"to the weekend! This "
|
||||
"stickied thread is for you "
|
||||
"to post pictures of your "
|
||||
"ubuntu 2006 install disk, "
|
||||
"slackware floppies, "
|
||||
"on-topic memes or "
|
||||
"more.</p>\n"
|
||||
"\n"
|
||||
"<p>When it&#39;s "
|
||||
"not the weekend, be sure to "
|
||||
"check out <a "
|
||||
'href="/r/WildLinuxAppears">r/WildLinuxAppears</a> '
|
||||
"or <a "
|
||||
'href="/r/linuxmemes">r/linuxmemes</a>!</p>\n'
|
||||
"</div><!-- SC_ON "
|
||||
"-->",
|
||||
"spoiler": False,
|
||||
"stickied": True,
|
||||
"subreddit": "linux",
|
||||
"subreddit_id": "t5_2qh1a",
|
||||
"subreddit_name_prefixed": "r/linux",
|
||||
"subreddit_subscribers": 542073,
|
||||
"subreddit_type": "public",
|
||||
"thumbnail": "",
|
||||
"title": "Weekend Fluff / Linux in the Wild "
|
||||
"Thread - July 03, 2020",
|
||||
"url": "https://www.reddit.com/r/linux/comments/hkmu0t/weekend_fluff_linux_in_the_wild_thread_july_03/",
|
||||
"visited": False,
|
||||
},
|
||||
"kind": "t3",
|
||||
},
|
||||
],
|
||||
"dist": 27,
|
||||
"modhash": None,
|
||||
},
|
||||
"kind": "Listing",
|
||||
}
|
||||
|
|
@ -1,163 +0,0 @@
|
|||
from unittest.mock import Mock, patch
|
||||
from uuid import uuid4
|
||||
|
||||
from django.test import TestCase
|
||||
from django.utils.lorem_ipsum import words
|
||||
|
||||
from newsreader.accounts.tests.factories import UserFactory
|
||||
from newsreader.news.collection.exceptions import (
|
||||
StreamDeniedException,
|
||||
StreamException,
|
||||
StreamNotFoundException,
|
||||
StreamParseException,
|
||||
StreamTimeOutException,
|
||||
StreamTooManyException,
|
||||
)
|
||||
from newsreader.news.collection.reddit import RedditClient
|
||||
from newsreader.news.collection.tests.factories import SubredditFactory
|
||||
|
||||
from .mocks import simple_mock
|
||||
|
||||
|
||||
class RedditClientTestCase(TestCase):
|
||||
def setUp(self):
|
||||
self.maxDiff = None
|
||||
|
||||
self.patched_read = patch("newsreader.news.collection.reddit.RedditStream.read")
|
||||
self.mocked_read = self.patched_read.start()
|
||||
|
||||
def tearDown(self):
|
||||
patch.stopall()
|
||||
|
||||
def test_client_retrieves_single_rules(self):
|
||||
subreddit = SubredditFactory()
|
||||
mock_stream = Mock(rule=subreddit)
|
||||
|
||||
self.mocked_read.return_value = (simple_mock, mock_stream)
|
||||
|
||||
with RedditClient([[subreddit]]) as client:
|
||||
for data, stream in client:
|
||||
with self.subTest(data=data, stream=stream):
|
||||
self.assertEquals(data, simple_mock)
|
||||
self.assertEquals(stream, mock_stream)
|
||||
|
||||
self.mocked_read.assert_called_once_with()
|
||||
|
||||
def test_client_catches_stream_exception(self):
|
||||
subreddit = SubredditFactory()
|
||||
|
||||
self.mocked_read.side_effect = StreamException(message="Stream exception")
|
||||
|
||||
with RedditClient([[subreddit]]) as client:
|
||||
for data, stream in client:
|
||||
with self.subTest(data=data, stream=stream):
|
||||
self.assertEquals(data, None)
|
||||
self.assertEquals(stream, None)
|
||||
self.assertEquals(stream.rule.error, "Stream exception")
|
||||
self.assertEquals(stream.rule.succeeded, False)
|
||||
|
||||
self.mocked_read.assert_called_once_with()
|
||||
|
||||
def test_client_catches_stream_not_found_exception(self):
|
||||
subreddit = SubredditFactory.create()
|
||||
|
||||
self.mocked_read.side_effect = StreamNotFoundException(
|
||||
message="Stream not found"
|
||||
)
|
||||
|
||||
with RedditClient([[subreddit]]) as client:
|
||||
for data, stream in client:
|
||||
with self.subTest(data=data, stream=stream):
|
||||
self.assertEquals(data, None)
|
||||
self.assertEquals(stream, None)
|
||||
self.assertEquals(stream.rule.error, "Stream not found")
|
||||
self.assertEquals(stream.rule.succeeded, False)
|
||||
|
||||
self.mocked_read.assert_called_once_with()
|
||||
|
||||
@patch("newsreader.news.collection.reddit.RedditTokenTask")
|
||||
def test_client_catches_stream_denied_exception(self, mocked_task):
|
||||
user = UserFactory(
|
||||
reddit_access_token=str(uuid4()), reddit_refresh_token=str(uuid4())
|
||||
)
|
||||
subreddit = SubredditFactory(user=user)
|
||||
|
||||
self.mocked_read.side_effect = StreamDeniedException(message="Token expired")
|
||||
|
||||
with RedditClient([(subreddit,)]) as client:
|
||||
results = [(data, stream) for data, stream in client]
|
||||
|
||||
self.mocked_read.assert_called_once_with()
|
||||
mocked_task.delay.assert_called_once_with(user.pk)
|
||||
|
||||
self.assertEquals(len(results), 0)
|
||||
|
||||
user.refresh_from_db()
|
||||
subreddit.refresh_from_db()
|
||||
|
||||
self.assertEquals(user.reddit_access_token, None)
|
||||
self.assertEquals(subreddit.succeeded, False)
|
||||
self.assertEquals(subreddit.error, "Token expired")
|
||||
|
||||
def test_client_catches_stream_timed_out_exception(self):
|
||||
subreddit = SubredditFactory()
|
||||
|
||||
self.mocked_read.side_effect = StreamTimeOutException(
|
||||
message="Stream timed out"
|
||||
)
|
||||
|
||||
with RedditClient([[subreddit]]) as client:
|
||||
for data, stream in client:
|
||||
with self.subTest(data=data, stream=stream):
|
||||
self.assertEquals(data, None)
|
||||
self.assertEquals(stream, None)
|
||||
self.assertEquals(stream.rule.error, "Stream timed out")
|
||||
self.assertEquals(stream.rule.succeeded, False)
|
||||
|
||||
self.mocked_read.assert_called_once_with()
|
||||
|
||||
def test_client_catches_stream_too_many_exception(self):
|
||||
subreddit = SubredditFactory()
|
||||
|
||||
self.mocked_read.side_effect = StreamTooManyException
|
||||
|
||||
with RedditClient([[subreddit]]) as client:
|
||||
for data, stream in client:
|
||||
with self.subTest(data=data, stream=stream):
|
||||
self.assertEquals(data, None)
|
||||
self.assertEquals(stream, None)
|
||||
self.assertEquals(stream.rule.error, "Too many requests")
|
||||
self.assertEquals(stream.rule.succeeded, False)
|
||||
|
||||
self.mocked_read.assert_called_once_with()
|
||||
|
||||
def test_client_catches_stream_parse_exception(self):
|
||||
subreddit = SubredditFactory()
|
||||
|
||||
self.mocked_read.side_effect = StreamParseException(
|
||||
message="Stream could not be parsed"
|
||||
)
|
||||
|
||||
with RedditClient([[subreddit]]) as client:
|
||||
for data, stream in client:
|
||||
with self.subTest(data=data, stream=stream):
|
||||
self.assertEquals(data, None)
|
||||
self.assertEquals(stream, None)
|
||||
self.assertEquals(stream.rule.error, "Stream could not be parsed")
|
||||
self.assertEquals(stream.rule.succeeded, False)
|
||||
|
||||
self.mocked_read.assert_called_once_with()
|
||||
|
||||
def test_client_catches_long_exception_text(self):
|
||||
subreddit = SubredditFactory()
|
||||
|
||||
self.mocked_read.side_effect = StreamParseException(message=words(1000))
|
||||
|
||||
with RedditClient([[subreddit]]) as client:
|
||||
for data, stream in client:
|
||||
self.assertEquals(data, None)
|
||||
self.assertEquals(stream, None)
|
||||
self.assertEquals(len(stream.rule.error), 1024)
|
||||
self.assertEquals(stream.rule.succeeded, False)
|
||||
|
||||
self.mocked_read.assert_called_once_with()
|
||||
File diff suppressed because it is too large
Load diff
|
|
@ -1,201 +0,0 @@
|
|||
from datetime import datetime, timezone
|
||||
from unittest.mock import patch
|
||||
from uuid import uuid4
|
||||
|
||||
from django.test import TestCase
|
||||
|
||||
from newsreader.news.collection.choices import RuleTypeChoices
|
||||
from newsreader.news.collection.exceptions import (
|
||||
StreamDeniedException,
|
||||
StreamForbiddenException,
|
||||
StreamNotFoundException,
|
||||
StreamTimeOutException,
|
||||
)
|
||||
from newsreader.news.collection.reddit import RedditCollector
|
||||
from newsreader.news.collection.tests.factories import SubredditFactory
|
||||
from newsreader.news.collection.tests.reddit.collector.mocks import (
|
||||
empty_mock,
|
||||
simple_mock_1,
|
||||
simple_mock_2,
|
||||
)
|
||||
from newsreader.news.core.models import Post
|
||||
|
||||
|
||||
class RedditCollectorTestCase(TestCase):
|
||||
def setUp(self):
|
||||
self.maxDiff = None
|
||||
|
||||
self.patched_get = patch("newsreader.news.collection.reddit.fetch")
|
||||
self.mocked_fetch = self.patched_get.start()
|
||||
|
||||
self.patched_parse = patch(
|
||||
"newsreader.news.collection.reddit.RedditStream.parse"
|
||||
)
|
||||
self.mocked_parse = self.patched_parse.start()
|
||||
|
||||
def tearDown(self):
|
||||
patch.stopall()
|
||||
|
||||
def test_simple_batch(self):
|
||||
self.mocked_parse.side_effect = (simple_mock_1, simple_mock_2)
|
||||
|
||||
rules = (
|
||||
(subreddit,)
|
||||
for subreddit in SubredditFactory.create_batch(
|
||||
user__reddit_access_token=str(uuid4()),
|
||||
user__reddit_refresh_token=str(uuid4()),
|
||||
enabled=True,
|
||||
size=2,
|
||||
)
|
||||
)
|
||||
|
||||
collector = RedditCollector()
|
||||
collector.collect(rules=rules)
|
||||
|
||||
self.assertCountEqual(
|
||||
Post.objects.values_list("remote_identifier", flat=True),
|
||||
(
|
||||
"hm6byg",
|
||||
"hpkhgj",
|
||||
"hph00n",
|
||||
"hp9mlw",
|
||||
"hpjn8x",
|
||||
"gdfaip",
|
||||
"hmd2ez",
|
||||
"hpr28u",
|
||||
"hpps6f",
|
||||
"hp7uqe",
|
||||
),
|
||||
)
|
||||
|
||||
for subreddit in rules:
|
||||
with self.subTest(subreddit=subreddit):
|
||||
self.assertEqual(subreddit.succeeded, True)
|
||||
self.assertEqual(subreddit.last_run, datetime.now(tz=timezone.utc))
|
||||
self.assertEqual(subreddit.error, None)
|
||||
|
||||
post = Post.objects.get(
|
||||
remote_identifier="hph00n", rule__type=RuleTypeChoices.subreddit
|
||||
)
|
||||
|
||||
self.assertEqual(
|
||||
post.publication_date,
|
||||
datetime(2020, 7, 11, 22, 23, 24, tzinfo=timezone.utc),
|
||||
)
|
||||
|
||||
self.assertEqual(post.author, "HannahB888")
|
||||
self.assertEqual(post.title, "Drake Interplanetary Smartkey thing that I made!")
|
||||
self.assertEqual(
|
||||
post.url,
|
||||
"https://www.reddit.com/r/starcitizen/comments/hph00n/drake_interplanetary_smartkey_thing_that_i_made/",
|
||||
)
|
||||
|
||||
post = Post.objects.get(
|
||||
remote_identifier="hpr28u", rule__type=RuleTypeChoices.subreddit
|
||||
)
|
||||
|
||||
self.assertEqual(
|
||||
post.publication_date,
|
||||
datetime(2020, 7, 12, 10, 29, 10, tzinfo=timezone.utc),
|
||||
)
|
||||
|
||||
self.assertEqual(post.author, "Sebaron")
|
||||
self.assertEqual(
|
||||
post.title,
|
||||
"I am a medical student, and I recently programmed an open-source eye-tracker for brain research",
|
||||
)
|
||||
self.assertEqual(
|
||||
post.url,
|
||||
"https://www.reddit.com/r/Python/comments/hpr28u/i_am_a_medical_student_and_i_recently_programmed/",
|
||||
)
|
||||
|
||||
def test_empty_batch(self):
|
||||
self.mocked_parse.side_effect = (empty_mock, empty_mock)
|
||||
|
||||
rules = (
|
||||
(subreddit,)
|
||||
for subreddit in SubredditFactory.create_batch(
|
||||
user__reddit_access_token=str(uuid4()),
|
||||
user__reddit_refresh_token=str(uuid4()),
|
||||
enabled=True,
|
||||
size=2,
|
||||
)
|
||||
)
|
||||
|
||||
collector = RedditCollector()
|
||||
collector.collect(rules=rules)
|
||||
|
||||
self.assertEqual(Post.objects.count(), 0)
|
||||
|
||||
for subreddit in rules:
|
||||
with self.subTest(subreddit=subreddit):
|
||||
self.assertEqual(subreddit.succeeded, True)
|
||||
self.assertEqual(subreddit.last_run, datetime.now(tz=timezone.utc))
|
||||
self.assertEqual(subreddit.error, None)
|
||||
|
||||
def test_not_found(self):
|
||||
self.mocked_fetch.side_effect = StreamNotFoundException
|
||||
|
||||
rule = SubredditFactory(
|
||||
user__reddit_access_token=str(uuid4()),
|
||||
user__reddit_refresh_token=str(uuid4()),
|
||||
enabled=True,
|
||||
)
|
||||
|
||||
collector = RedditCollector()
|
||||
collector.collect(rules=((rule,),))
|
||||
|
||||
self.assertEqual(Post.objects.count(), 0)
|
||||
self.assertEqual(rule.succeeded, False)
|
||||
self.assertEqual(rule.error, "Stream not found")
|
||||
|
||||
@patch("newsreader.news.collection.reddit.RedditTokenTask")
|
||||
def test_denied(self, mocked_task):
|
||||
self.mocked_fetch.side_effect = StreamDeniedException
|
||||
|
||||
rule = SubredditFactory(
|
||||
user__reddit_access_token=str(uuid4()),
|
||||
user__reddit_refresh_token=str(uuid4()),
|
||||
enabled=True,
|
||||
)
|
||||
|
||||
collector = RedditCollector()
|
||||
collector.collect(rules=((rule,),))
|
||||
|
||||
self.assertEqual(Post.objects.count(), 0)
|
||||
self.assertEqual(rule.succeeded, False)
|
||||
self.assertEqual(rule.error, "Stream does not have sufficient permissions")
|
||||
|
||||
mocked_task.delay.assert_called_once_with(rule.user.pk)
|
||||
|
||||
def test_forbidden(self):
|
||||
self.mocked_fetch.side_effect = StreamForbiddenException
|
||||
|
||||
rule = SubredditFactory(
|
||||
user__reddit_access_token=str(uuid4()),
|
||||
user__reddit_refresh_token=str(uuid4()),
|
||||
enabled=True,
|
||||
)
|
||||
|
||||
collector = RedditCollector()
|
||||
collector.collect(rules=((rule,),))
|
||||
|
||||
self.assertEqual(Post.objects.count(), 0)
|
||||
self.assertEqual(rule.succeeded, False)
|
||||
self.assertEqual(rule.error, "Stream forbidden")
|
||||
|
||||
def test_timed_out(self):
|
||||
self.mocked_fetch.side_effect = StreamTimeOutException
|
||||
|
||||
rule = SubredditFactory(
|
||||
user__reddit_access_token=str(uuid4()),
|
||||
user__reddit_refresh_token=str(uuid4()),
|
||||
enabled=True,
|
||||
)
|
||||
|
||||
collector = RedditCollector()
|
||||
collector.collect(rules=((rule,),))
|
||||
|
||||
self.assertEqual(Post.objects.count(), 0)
|
||||
self.assertEqual(rule.succeeded, False)
|
||||
self.assertEqual(rule.error, "Stream timed out")
|
||||
File diff suppressed because it is too large
Load diff
|
|
@ -1,144 +0,0 @@
|
|||
from json.decoder import JSONDecodeError
|
||||
from unittest.mock import patch
|
||||
from uuid import uuid4
|
||||
|
||||
from django.test import TestCase
|
||||
|
||||
from newsreader.accounts.tests.factories import UserFactory
|
||||
from newsreader.news.collection.exceptions import (
|
||||
StreamDeniedException,
|
||||
StreamException,
|
||||
StreamForbiddenException,
|
||||
StreamNotFoundException,
|
||||
StreamParseException,
|
||||
StreamTimeOutException,
|
||||
)
|
||||
from newsreader.news.collection.reddit import RedditStream
|
||||
from newsreader.news.collection.tests.factories import SubredditFactory
|
||||
from newsreader.news.collection.tests.reddit.stream.mocks import simple_mock
|
||||
|
||||
|
||||
class RedditStreamTestCase(TestCase):
|
||||
def setUp(self):
|
||||
self.maxDiff = None
|
||||
|
||||
self.patched_fetch = patch("newsreader.news.collection.reddit.fetch")
|
||||
self.mocked_fetch = self.patched_fetch.start()
|
||||
|
||||
def tearDown(self):
|
||||
patch.stopall()
|
||||
|
||||
def test_simple_stream(self):
|
||||
self.mocked_fetch.return_value.json.return_value = simple_mock
|
||||
|
||||
access_token = str(uuid4())
|
||||
user = UserFactory(reddit_access_token=access_token)
|
||||
|
||||
subreddit = SubredditFactory(user=user)
|
||||
stream = RedditStream(subreddit)
|
||||
|
||||
data, stream = stream.read()
|
||||
|
||||
self.assertEquals(data, simple_mock)
|
||||
self.assertEquals(stream, stream)
|
||||
self.mocked_fetch.assert_called_once_with(
|
||||
subreddit.url, headers={"Authorization": f"bearer {access_token}"}
|
||||
)
|
||||
|
||||
def test_stream_raises_exception(self):
|
||||
self.mocked_fetch.side_effect = StreamException
|
||||
|
||||
access_token = str(uuid4())
|
||||
user = UserFactory(reddit_access_token=access_token)
|
||||
|
||||
subreddit = SubredditFactory(user=user)
|
||||
stream = RedditStream(subreddit)
|
||||
|
||||
with self.assertRaises(StreamException):
|
||||
stream.read()
|
||||
|
||||
self.mocked_fetch.assert_called_once_with(
|
||||
subreddit.url, headers={"Authorization": f"bearer {access_token}"}
|
||||
)
|
||||
|
||||
def test_stream_raises_denied_exception(self):
|
||||
self.mocked_fetch.side_effect = StreamDeniedException
|
||||
|
||||
access_token = str(uuid4())
|
||||
user = UserFactory(reddit_access_token=access_token)
|
||||
|
||||
subreddit = SubredditFactory(user=user)
|
||||
stream = RedditStream(subreddit)
|
||||
|
||||
with self.assertRaises(StreamDeniedException):
|
||||
stream.read()
|
||||
|
||||
self.mocked_fetch.assert_called_once_with(
|
||||
subreddit.url, headers={"Authorization": f"bearer {access_token}"}
|
||||
)
|
||||
|
||||
def test_stream_raises_not_found_exception(self):
|
||||
self.mocked_fetch.side_effect = StreamNotFoundException
|
||||
|
||||
access_token = str(uuid4())
|
||||
user = UserFactory(reddit_access_token=access_token)
|
||||
|
||||
subreddit = SubredditFactory(user=user)
|
||||
stream = RedditStream(subreddit)
|
||||
|
||||
with self.assertRaises(StreamNotFoundException):
|
||||
stream.read()
|
||||
|
||||
self.mocked_fetch.assert_called_once_with(
|
||||
subreddit.url, headers={"Authorization": f"bearer {access_token}"}
|
||||
)
|
||||
|
||||
def test_stream_raises_time_out_exception(self):
|
||||
self.mocked_fetch.side_effect = StreamTimeOutException
|
||||
|
||||
access_token = str(uuid4())
|
||||
user = UserFactory(reddit_access_token=access_token)
|
||||
|
||||
subreddit = SubredditFactory(user=user)
|
||||
stream = RedditStream(subreddit)
|
||||
|
||||
with self.assertRaises(StreamTimeOutException):
|
||||
stream.read()
|
||||
|
||||
self.mocked_fetch.assert_called_once_with(
|
||||
subreddit.url, headers={"Authorization": f"bearer {access_token}"}
|
||||
)
|
||||
|
||||
def test_stream_raises_forbidden_exception(self):
|
||||
self.mocked_fetch.side_effect = StreamForbiddenException
|
||||
|
||||
access_token = str(uuid4())
|
||||
user = UserFactory(reddit_access_token=access_token)
|
||||
|
||||
subreddit = SubredditFactory(user=user)
|
||||
stream = RedditStream(subreddit)
|
||||
|
||||
with self.assertRaises(StreamForbiddenException):
|
||||
stream.read()
|
||||
|
||||
self.mocked_fetch.assert_called_once_with(
|
||||
subreddit.url, headers={"Authorization": f"bearer {access_token}"}
|
||||
)
|
||||
|
||||
def test_stream_raises_parse_exception(self):
|
||||
self.mocked_fetch.return_value.json.side_effect = JSONDecodeError(
|
||||
"No json found", "{}", 5
|
||||
)
|
||||
|
||||
access_token = str(uuid4())
|
||||
user = UserFactory(reddit_access_token=access_token)
|
||||
|
||||
subreddit = SubredditFactory(user=user)
|
||||
stream = RedditStream(subreddit)
|
||||
|
||||
with self.assertRaises(StreamParseException):
|
||||
stream.read()
|
||||
|
||||
self.mocked_fetch.assert_called_once_with(
|
||||
subreddit.url, headers={"Authorization": f"bearer {access_token}"}
|
||||
)
|
||||
|
|
@ -1,142 +0,0 @@
|
|||
from datetime import timedelta
|
||||
|
||||
from django.test import TestCase
|
||||
from django.utils import timezone
|
||||
|
||||
from freezegun import freeze_time
|
||||
|
||||
from newsreader.accounts.tests.factories import UserFactory
|
||||
from newsreader.news.collection.choices import RuleTypeChoices
|
||||
from newsreader.news.collection.reddit import RedditScheduler
|
||||
from newsreader.news.collection.tests.factories import CollectionRuleFactory
|
||||
|
||||
|
||||
@freeze_time("2019-10-30 12:30:00")
|
||||
class RedditSchedulerTestCase(TestCase):
|
||||
def test_simple(self):
|
||||
user_1 = UserFactory(
|
||||
reddit_access_token="1231414", reddit_refresh_token="5235262"
|
||||
)
|
||||
user_2 = UserFactory(
|
||||
reddit_access_token="3414777", reddit_refresh_token="3423425"
|
||||
)
|
||||
|
||||
user_1_rules = [
|
||||
CollectionRuleFactory(
|
||||
user=user_1,
|
||||
type=RuleTypeChoices.subreddit,
|
||||
last_run=timezone.now() - timedelta(days=4),
|
||||
enabled=True,
|
||||
),
|
||||
CollectionRuleFactory(
|
||||
user=user_1,
|
||||
type=RuleTypeChoices.subreddit,
|
||||
last_run=timezone.now() - timedelta(days=3),
|
||||
enabled=True,
|
||||
),
|
||||
CollectionRuleFactory(
|
||||
user=user_1,
|
||||
type=RuleTypeChoices.subreddit,
|
||||
last_run=timezone.now() - timedelta(days=2),
|
||||
enabled=True,
|
||||
),
|
||||
]
|
||||
|
||||
user_2_rules = [
|
||||
CollectionRuleFactory(
|
||||
user=user_2,
|
||||
type=RuleTypeChoices.subreddit,
|
||||
last_run=timezone.now() - timedelta(days=4),
|
||||
enabled=True,
|
||||
),
|
||||
CollectionRuleFactory(
|
||||
user=user_2,
|
||||
type=RuleTypeChoices.subreddit,
|
||||
last_run=timezone.now() - timedelta(days=3),
|
||||
enabled=True,
|
||||
),
|
||||
CollectionRuleFactory(
|
||||
user=user_2,
|
||||
type=RuleTypeChoices.subreddit,
|
||||
last_run=timezone.now() - timedelta(days=2),
|
||||
enabled=True,
|
||||
),
|
||||
]
|
||||
|
||||
scheduler = RedditScheduler()
|
||||
scheduled_subreddits = scheduler.get_scheduled_rules()
|
||||
|
||||
user_1_batch = [subreddit.pk for subreddit in scheduled_subreddits[0]]
|
||||
|
||||
self.assertIn(user_1_rules[0].pk, user_1_batch)
|
||||
self.assertIn(user_1_rules[1].pk, user_1_batch)
|
||||
self.assertIn(user_1_rules[2].pk, user_1_batch)
|
||||
|
||||
user_2_batch = [subreddit.pk for subreddit in scheduled_subreddits[1]]
|
||||
|
||||
self.assertIn(user_2_rules[0].pk, user_2_batch)
|
||||
self.assertIn(user_2_rules[1].pk, user_2_batch)
|
||||
self.assertIn(user_2_rules[2].pk, user_2_batch)
|
||||
|
||||
def test_max_amount(self):
|
||||
users = UserFactory.create_batch(
|
||||
reddit_access_token="1231414", reddit_refresh_token="5235262", size=5
|
||||
)
|
||||
|
||||
nested_rules = [
|
||||
CollectionRuleFactory.create_batch(
|
||||
name=f"rule-{index}",
|
||||
type=RuleTypeChoices.subreddit,
|
||||
last_run=timezone.now() - timedelta(seconds=index),
|
||||
enabled=True,
|
||||
user=user,
|
||||
size=15,
|
||||
)
|
||||
for index, user in enumerate(users)
|
||||
]
|
||||
|
||||
rules = [rule for rule_list in nested_rules for rule in rule_list]
|
||||
|
||||
scheduler = RedditScheduler()
|
||||
scheduled_subreddits = [
|
||||
subreddit.pk
|
||||
for batch in scheduler.get_scheduled_rules()
|
||||
for subreddit in batch
|
||||
]
|
||||
|
||||
for rule in rules[16:76]:
|
||||
with self.subTest(rule=rule):
|
||||
self.assertIn(rule.pk, scheduled_subreddits)
|
||||
|
||||
for rule in rules[0:15]:
|
||||
with self.subTest(rule=rule):
|
||||
self.assertNotIn(rule.pk, scheduled_subreddits)
|
||||
|
||||
def test_max_user_amount(self):
|
||||
user = UserFactory(
|
||||
reddit_access_token="1231414", reddit_refresh_token="5235262"
|
||||
)
|
||||
|
||||
rules = [
|
||||
CollectionRuleFactory(
|
||||
name=f"rule-{index}",
|
||||
type=RuleTypeChoices.subreddit,
|
||||
last_run=timezone.now() - timedelta(seconds=index),
|
||||
enabled=True,
|
||||
user=user,
|
||||
)
|
||||
for index in range(1, 17)
|
||||
]
|
||||
|
||||
scheduler = RedditScheduler()
|
||||
scheduled_subreddits = [
|
||||
subreddit.pk
|
||||
for batch in scheduler.get_scheduled_rules()
|
||||
for subreddit in batch
|
||||
]
|
||||
|
||||
for rule in rules[1:16]:
|
||||
with self.subTest(rule=rule):
|
||||
self.assertIn(rule.pk, scheduled_subreddits)
|
||||
|
||||
self.assertNotIn(rules[0].pk, scheduled_subreddits)
|
||||
|
|
@ -88,17 +88,3 @@ class FeedUpdateViewTestCase(CollectionRuleViewTestCase, TestCase):
|
|||
self.rule.refresh_from_db()
|
||||
|
||||
self.assertEqual(self.rule.category, None)
|
||||
|
||||
def test_rules_only(self):
|
||||
rule = FeedFactory(
|
||||
name="Python",
|
||||
url="https://reddit.com/r/python",
|
||||
user=self.user,
|
||||
category=self.category,
|
||||
type=RuleTypeChoices.subreddit,
|
||||
)
|
||||
url = reverse("news:collection:feed-update", kwargs={"pk": rule.pk})
|
||||
|
||||
response = self.client.get(url)
|
||||
|
||||
self.assertEqual(response.status_code, 404)
|
||||
|
|
|
|||
|
|
@ -1,133 +0,0 @@
|
|||
from django.test import TestCase
|
||||
from django.urls import reverse
|
||||
from django.utils.translation import gettext as _
|
||||
|
||||
from newsreader.news.collection.choices import RuleTypeChoices
|
||||
from newsreader.news.collection.models import CollectionRule
|
||||
from newsreader.news.collection.reddit import REDDIT_API_URL, REDDIT_URL
|
||||
from newsreader.news.collection.tests.factories import SubredditFactory
|
||||
from newsreader.news.collection.tests.views.base import CollectionRuleViewTestCase
|
||||
from newsreader.news.core.tests.factories import CategoryFactory
|
||||
|
||||
|
||||
class SubRedditCreateViewTestCase(CollectionRuleViewTestCase, TestCase):
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
|
||||
self.form_data = {
|
||||
"name": "new rule",
|
||||
"url": f"{REDDIT_API_URL}/r/aww",
|
||||
"category": str(self.category.pk),
|
||||
"reddit_allow_nfsw": False,
|
||||
"reddit_allow_spoiler": False,
|
||||
"reddit_allow_viewed": True,
|
||||
"reddit_upvotes_min": 0,
|
||||
"reddit_comments_min": 0,
|
||||
}
|
||||
|
||||
self.url = reverse("news:collection:subreddit-create")
|
||||
|
||||
def test_creation(self):
|
||||
response = self.client.post(self.url, self.form_data)
|
||||
|
||||
self.assertEqual(response.status_code, 302)
|
||||
|
||||
rule = CollectionRule.objects.get(name="new rule")
|
||||
|
||||
self.assertEqual(rule.type, RuleTypeChoices.subreddit)
|
||||
self.assertEqual(rule.url, f"{REDDIT_API_URL}/r/aww")
|
||||
self.assertEqual(rule.favicon, None)
|
||||
self.assertEqual(rule.category.pk, self.category.pk)
|
||||
self.assertEqual(rule.user.pk, self.user.pk)
|
||||
|
||||
def test_regular_reddit_url(self):
|
||||
self.form_data.update(url=f"{REDDIT_URL}/r/aww")
|
||||
|
||||
response = self.client.post(self.url, self.form_data)
|
||||
|
||||
self.assertContains(response, _("This does not look like an Reddit API URL"))
|
||||
|
||||
|
||||
class SubRedditUpdateViewTestCase(CollectionRuleViewTestCase, TestCase):
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
|
||||
self.rule = SubredditFactory(
|
||||
name="Python",
|
||||
url=f"{REDDIT_API_URL}/r/python.json",
|
||||
user=self.user,
|
||||
category=self.category,
|
||||
type=RuleTypeChoices.subreddit,
|
||||
)
|
||||
self.url = reverse(
|
||||
"news:collection:subreddit-update", kwargs={"pk": self.rule.pk}
|
||||
)
|
||||
|
||||
self.form_data = {
|
||||
"name": self.rule.name,
|
||||
"url": self.rule.url,
|
||||
"category": str(self.category.pk),
|
||||
"reddit_allow_nfsw": False,
|
||||
"reddit_allow_spoiler": False,
|
||||
"reddit_allow_viewed": True,
|
||||
"reddit_upvotes_min": 0,
|
||||
"reddit_comments_min": 0,
|
||||
}
|
||||
|
||||
def test_name_change(self):
|
||||
self.form_data.update(name="Python 2")
|
||||
|
||||
response = self.client.post(self.url, self.form_data)
|
||||
self.assertEqual(response.status_code, 302)
|
||||
|
||||
self.rule.refresh_from_db()
|
||||
|
||||
self.assertEqual(self.rule.name, "Python 2")
|
||||
|
||||
def test_category_change(self):
|
||||
new_category = CategoryFactory(user=self.user)
|
||||
|
||||
self.form_data.update(category=new_category.pk)
|
||||
|
||||
response = self.client.post(self.url, self.form_data)
|
||||
self.assertEqual(response.status_code, 302)
|
||||
|
||||
self.rule.refresh_from_db()
|
||||
|
||||
self.assertEqual(self.rule.category.pk, new_category.pk)
|
||||
|
||||
def test_subreddit_rules_only(self):
|
||||
rule = SubredditFactory(
|
||||
name="Fake subreddit",
|
||||
url="https://leddit.com/r/python",
|
||||
user=self.user,
|
||||
category=self.category,
|
||||
type=RuleTypeChoices.feed,
|
||||
)
|
||||
url = reverse("news:collection:subreddit-update", kwargs={"pk": rule.pk})
|
||||
|
||||
response = self.client.get(url)
|
||||
|
||||
self.assertEqual(response.status_code, 404)
|
||||
|
||||
def test_url_change(self):
|
||||
self.form_data.update(name="aww", url=f"{REDDIT_API_URL}/r/aww")
|
||||
|
||||
response = self.client.post(self.url, self.form_data)
|
||||
|
||||
self.assertEqual(response.status_code, 302)
|
||||
|
||||
rule = CollectionRule.objects.get(name="aww")
|
||||
|
||||
self.assertEqual(rule.type, RuleTypeChoices.subreddit)
|
||||
self.assertEqual(rule.url, f"{REDDIT_API_URL}/r/aww")
|
||||
self.assertEqual(rule.favicon, None)
|
||||
self.assertEqual(rule.category.pk, self.category.pk)
|
||||
self.assertEqual(rule.user.pk, self.user.pk)
|
||||
|
||||
def test_regular_reddit_url(self):
|
||||
self.form_data.update(url=f"{REDDIT_URL}/r/aww")
|
||||
|
||||
response = self.client.post(self.url, self.form_data)
|
||||
|
||||
self.assertContains(response, _("This does not look like an Reddit API URL"))
|
||||
|
|
@ -14,8 +14,6 @@ from newsreader.news.collection.views import (
|
|||
FeedCreateView,
|
||||
FeedUpdateView,
|
||||
OPMLImportView,
|
||||
SubRedditCreateView,
|
||||
SubRedditUpdateView,
|
||||
)
|
||||
|
||||
|
||||
|
|
@ -49,15 +47,4 @@ urlpatterns = [
|
|||
name="rules-disable",
|
||||
),
|
||||
path("rules/import/", login_required(OPMLImportView.as_view()), name="import"),
|
||||
# Reddit
|
||||
path(
|
||||
"subreddits/create/",
|
||||
login_required(SubRedditCreateView.as_view()),
|
||||
name="subreddit-create",
|
||||
),
|
||||
path(
|
||||
"subreddits/<int:pk>/",
|
||||
login_required(SubRedditUpdateView.as_view()),
|
||||
name="subreddit-update",
|
||||
),
|
||||
]
|
||||
|
|
|
|||
|
|
@ -60,6 +60,6 @@ def truncate_text(cls, field_name, value):
|
|||
return value
|
||||
|
||||
if len(value) > max_length:
|
||||
return f"{value[:max_length - 1]}…"
|
||||
return f"{value[: max_length - 1]}…"
|
||||
|
||||
return value
|
||||
|
|
|
|||
|
|
@ -3,10 +3,6 @@ from newsreader.news.collection.views.feed import (
|
|||
FeedUpdateView,
|
||||
OPMLImportView,
|
||||
)
|
||||
from newsreader.news.collection.views.reddit import (
|
||||
SubRedditCreateView,
|
||||
SubRedditUpdateView,
|
||||
)
|
||||
from newsreader.news.collection.views.rules import (
|
||||
CollectionRuleBulkDeleteView,
|
||||
CollectionRuleBulkDisableView,
|
||||
|
|
@ -19,8 +15,6 @@ __all__ = [
|
|||
"FeedCreateView",
|
||||
"FeedUpdateView",
|
||||
"OPMLImportView",
|
||||
"SubRedditCreateView",
|
||||
"SubRedditUpdateView",
|
||||
"CollectionRuleBulkDeleteView",
|
||||
"CollectionRuleBulkDisableView",
|
||||
"CollectionRuleBulkEnableView",
|
||||
|
|
|
|||
|
|
@ -1,27 +0,0 @@
|
|||
from django.views.generic.edit import CreateView, UpdateView
|
||||
|
||||
from newsreader.news.collection.choices import RuleTypeChoices
|
||||
from newsreader.news.collection.forms import SubRedditForm
|
||||
from newsreader.news.collection.views.base import (
|
||||
CollectionRuleDetailMixin,
|
||||
CollectionRuleViewMixin,
|
||||
)
|
||||
|
||||
|
||||
class SubRedditCreateView(
|
||||
CollectionRuleViewMixin, CollectionRuleDetailMixin, CreateView
|
||||
):
|
||||
form_class = SubRedditForm
|
||||
template_name = "news/collection/views/subreddit-create.html"
|
||||
|
||||
|
||||
class SubRedditUpdateView(
|
||||
CollectionRuleViewMixin, CollectionRuleDetailMixin, UpdateView
|
||||
):
|
||||
form_class = SubRedditForm
|
||||
template_name = "news/collection/views/subreddit-update.html"
|
||||
context_object_name = "subreddit"
|
||||
|
||||
def get_queryset(self):
|
||||
queryset = super().get_queryset()
|
||||
return queryset.filter(type=RuleTypeChoices.subreddit)
|
||||
|
|
@ -1,4 +1,5 @@
|
|||
from django.db.models import Prefetch
|
||||
|
||||
from rest_framework import status
|
||||
from rest_framework.generics import (
|
||||
GenericAPIView,
|
||||
|
|
|
|||
|
|
@ -4,7 +4,6 @@ import factory
|
|||
import factory.fuzzy
|
||||
|
||||
from newsreader.accounts.tests.factories import UserFactory
|
||||
from newsreader.news.collection.reddit import REDDIT_API_URL
|
||||
from newsreader.news.core.models import Category, Post
|
||||
|
||||
|
||||
|
|
@ -36,10 +35,3 @@ class PostFactory(factory.django.DjangoModelFactory):
|
|||
|
||||
class FeedPostFactory(PostFactory):
|
||||
rule = factory.SubFactory("newsreader.news.collection.tests.factories.FeedFactory")
|
||||
|
||||
|
||||
class RedditPostFactory(PostFactory):
|
||||
url = factory.fuzzy.FuzzyText(length=10, prefix=f"{REDDIT_API_URL}/")
|
||||
rule = factory.SubFactory(
|
||||
"newsreader.news.collection.tests.factories.SubredditFactory"
|
||||
)
|
||||
|
|
|
|||
|
|
@ -20,9 +20,6 @@ class NewsView(NavListMixin, TemplateView):
|
|||
**context,
|
||||
"homepageSettings": {
|
||||
"feedUrl": reverse_lazy("news:collection:feed-update", args=(0,)),
|
||||
"subredditUrl": reverse_lazy(
|
||||
"news:collection:subreddit-update", args=(0,)
|
||||
),
|
||||
"categoriesUrl": reverse_lazy("news:core:category-update", args=(0,)),
|
||||
"timezone": settings.TIME_ZONE,
|
||||
"autoMarking": self.request.user.auto_mark_read,
|
||||
|
|
|
|||
|
|
@ -18,8 +18,6 @@
|
|||
@import './sidebar/index';
|
||||
@import './table/index';
|
||||
|
||||
@import './integrations/index';
|
||||
|
||||
@import './rules/index';
|
||||
|
||||
@import './post/index';
|
||||
|
|
|
|||
|
|
@ -1,13 +0,0 @@
|
|||
.integrations {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 15px;
|
||||
|
||||
padding: 15px;
|
||||
|
||||
&__controls {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
}
|
||||
}
|
||||
|
|
@ -1 +0,0 @@
|
|||
@import './integrations';
|
||||
|
|
@ -13,12 +13,14 @@
|
|||
cursor: pointer;
|
||||
}
|
||||
|
||||
&--success, &--confirm {
|
||||
&--success,
|
||||
&--confirm {
|
||||
background-color: var(--confirm-color);
|
||||
color: $white !important;
|
||||
}
|
||||
|
||||
&--error, &--cancel {
|
||||
&--error,
|
||||
&--cancel {
|
||||
color: $white !important;
|
||||
background-color: var(--danger-color);
|
||||
}
|
||||
|
|
@ -28,15 +30,6 @@
|
|||
background-color: var(--info-color);
|
||||
}
|
||||
|
||||
&--reddit {
|
||||
color: $white !important;
|
||||
background-color: $reddit-orange;
|
||||
|
||||
&:hover {
|
||||
background-color: lighten($reddit-orange, 5%);
|
||||
}
|
||||
}
|
||||
|
||||
&--disabled {
|
||||
color: var(--font-color) !important;
|
||||
background-color: var(--background-color-secondary) !important;
|
||||
|
|
|
|||
|
|
@ -12,4 +12,3 @@
|
|||
@import './rules/index';
|
||||
|
||||
@import './settings/index';
|
||||
@import './integrations/index';
|
||||
|
|
|
|||
|
|
@ -1,5 +0,0 @@
|
|||
#integrations--page {
|
||||
.section {
|
||||
width: 70%;
|
||||
}
|
||||
}
|
||||
|
|
@ -59,6 +59,3 @@ $dark-info-color: $blue;
|
|||
$dark-info-font-color: $white;
|
||||
|
||||
$dark-sidebar-background-color: $dark-background-color-secondary;
|
||||
|
||||
// Third party
|
||||
$reddit-orange: rgba(255, 69, 0, 1);
|
||||
|
|
|
|||
432
uv.lock
generated
432
uv.lock
generated
|
|
@ -1,5 +1,5 @@
|
|||
version = 1
|
||||
revision = 1
|
||||
revision = 2
|
||||
requires-python = ">=3.11"
|
||||
resolution-markers = [
|
||||
"sys_platform == 'linux'",
|
||||
|
|
@ -15,18 +15,18 @@ source = { registry = "https://pypi.org/simple" }
|
|||
dependencies = [
|
||||
{ name = "vine", marker = "sys_platform == 'linux'" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/32/2c/6eb09fbdeb3c060b37bd33f8873832897a83e7a428afe01aad333fc405ec/amqp-5.2.0.tar.gz", hash = "sha256:a1ecff425ad063ad42a486c902807d1482311481c8ad95a72694b2975e75f7fd", size = 128754 }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/32/2c/6eb09fbdeb3c060b37bd33f8873832897a83e7a428afe01aad333fc405ec/amqp-5.2.0.tar.gz", hash = "sha256:a1ecff425ad063ad42a486c902807d1482311481c8ad95a72694b2975e75f7fd", size = 128754, upload-time = "2023-11-06T04:54:20.099Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/b3/f0/8e5be5d5e0653d9e1d02b1144efa33ff7d2963dfad07049e02c0fa9b2e8d/amqp-5.2.0-py3-none-any.whl", hash = "sha256:827cb12fb0baa892aad844fd95258143bce4027fdac4fccddbc43330fd281637", size = 50917 },
|
||||
{ url = "https://files.pythonhosted.org/packages/b3/f0/8e5be5d5e0653d9e1d02b1144efa33ff7d2963dfad07049e02c0fa9b2e8d/amqp-5.2.0-py3-none-any.whl", hash = "sha256:827cb12fb0baa892aad844fd95258143bce4027fdac4fccddbc43330fd281637", size = 50917, upload-time = "2023-11-06T04:54:08.603Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "asgiref"
|
||||
version = "3.8.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/29/38/b3395cc9ad1b56d2ddac9970bc8f4141312dbaec28bc7c218b0dfafd0f42/asgiref-3.8.1.tar.gz", hash = "sha256:c343bd80a0bec947a9860adb4c432ffa7db769836c64238fc34bdc3fec84d590", size = 35186 }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/29/38/b3395cc9ad1b56d2ddac9970bc8f4141312dbaec28bc7c218b0dfafd0f42/asgiref-3.8.1.tar.gz", hash = "sha256:c343bd80a0bec947a9860adb4c432ffa7db769836c64238fc34bdc3fec84d590", size = 35186, upload-time = "2024-03-22T14:39:36.863Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/39/e3/893e8757be2612e6c266d9bb58ad2e3651524b5b40cf56761e985a28b13e/asgiref-3.8.1-py3-none-any.whl", hash = "sha256:3e1e3ecc849832fe52ccf2cb6686b7a55f82bb1d6aee72a58826471390335e47", size = 23828 },
|
||||
{ url = "https://files.pythonhosted.org/packages/39/e3/893e8757be2612e6c266d9bb58ad2e3651524b5b40cf56761e985a28b13e/asgiref-3.8.1-py3-none-any.whl", hash = "sha256:3e1e3ecc849832fe52ccf2cb6686b7a55f82bb1d6aee72a58826471390335e47", size = 23828, upload-time = "2024-03-22T14:39:34.521Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
@ -36,18 +36,18 @@ source = { registry = "https://pypi.org/simple" }
|
|||
dependencies = [
|
||||
{ name = "soupsieve", marker = "sys_platform == 'linux'" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/b3/ca/824b1195773ce6166d388573fc106ce56d4a805bd7427b624e063596ec58/beautifulsoup4-4.12.3.tar.gz", hash = "sha256:74e3d1928edc070d21748185c46e3fb33490f22f52a3addee9aee0f4f7781051", size = 581181 }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/b3/ca/824b1195773ce6166d388573fc106ce56d4a805bd7427b624e063596ec58/beautifulsoup4-4.12.3.tar.gz", hash = "sha256:74e3d1928edc070d21748185c46e3fb33490f22f52a3addee9aee0f4f7781051", size = 581181, upload-time = "2024-01-17T16:53:17.902Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/b1/fe/e8c672695b37eecc5cbf43e1d0638d88d66ba3a44c4d321c796f4e59167f/beautifulsoup4-4.12.3-py3-none-any.whl", hash = "sha256:b80878c9f40111313e55da8ba20bdba06d8fa3969fc68304167741bbf9e082ed", size = 147925 },
|
||||
{ url = "https://files.pythonhosted.org/packages/b1/fe/e8c672695b37eecc5cbf43e1d0638d88d66ba3a44c4d321c796f4e59167f/beautifulsoup4-4.12.3-py3-none-any.whl", hash = "sha256:b80878c9f40111313e55da8ba20bdba06d8fa3969fc68304167741bbf9e082ed", size = 147925, upload-time = "2024-01-17T16:53:12.779Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "billiard"
|
||||
version = "4.2.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/09/52/f10d74fd56e73b430c37417658158ad8386202b069b70ff97d945c3ab67a/billiard-4.2.0.tar.gz", hash = "sha256:9a3c3184cb275aa17a732f93f65b20c525d3d9f253722d26a82194803ade5a2c", size = 154665 }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/09/52/f10d74fd56e73b430c37417658158ad8386202b069b70ff97d945c3ab67a/billiard-4.2.0.tar.gz", hash = "sha256:9a3c3184cb275aa17a732f93f65b20c525d3d9f253722d26a82194803ade5a2c", size = 154665, upload-time = "2023-11-06T05:23:38.562Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/50/8d/6e9fdeeab04d803abc5a715175f87e88893934d5590595eacff23ca12b07/billiard-4.2.0-py3-none-any.whl", hash = "sha256:07aa978b308f334ff8282bd4a746e681b3513db5c9a514cbdd810cbbdc19714d", size = 86720 },
|
||||
{ url = "https://files.pythonhosted.org/packages/50/8d/6e9fdeeab04d803abc5a715175f87e88893934d5590595eacff23ca12b07/billiard-4.2.0-py3-none-any.whl", hash = "sha256:07aa978b308f334ff8282bd4a746e681b3513db5c9a514cbdd810cbbdc19714d", size = 86720, upload-time = "2023-11-06T05:23:29.122Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
@ -58,9 +58,9 @@ dependencies = [
|
|||
{ name = "six", marker = "sys_platform == 'linux'" },
|
||||
{ name = "webencodings", marker = "sys_platform == 'linux'" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/6d/10/77f32b088738f40d4f5be801daa5f327879eadd4562f36a2b5ab975ae571/bleach-6.1.0.tar.gz", hash = "sha256:0a31f1837963c41d46bbf1331b8778e1308ea0791db03cc4e7357b97cf42a8fe", size = 202119 }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/6d/10/77f32b088738f40d4f5be801daa5f327879eadd4562f36a2b5ab975ae571/bleach-6.1.0.tar.gz", hash = "sha256:0a31f1837963c41d46bbf1331b8778e1308ea0791db03cc4e7357b97cf42a8fe", size = 202119, upload-time = "2023-10-06T19:30:51.304Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/ea/63/da7237f805089ecc28a3f36bca6a21c31fcbc2eb380f3b8f1be3312abd14/bleach-6.1.0-py3-none-any.whl", hash = "sha256:3225f354cfc436b9789c66c4ee030194bee0568fbf9cbdad3bc8b5c26c5f12b6", size = 162750 },
|
||||
{ url = "https://files.pythonhosted.org/packages/ea/63/da7237f805089ecc28a3f36bca6a21c31fcbc2eb380f3b8f1be3312abd14/bleach-6.1.0-py3-none-any.whl", hash = "sha256:3225f354cfc436b9789c66c4ee030194bee0568fbf9cbdad3bc8b5c26c5f12b6", size = 162750, upload-time = "2023-10-06T19:30:49.408Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
@ -78,56 +78,56 @@ dependencies = [
|
|||
{ name = "tzdata", marker = "sys_platform == 'linux'" },
|
||||
{ name = "vine", marker = "sys_platform == 'linux'" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/8a/9c/cf0bce2cc1c8971bf56629d8f180e4ca35612c7e79e6e432e785261a8be4/celery-5.4.0.tar.gz", hash = "sha256:504a19140e8d3029d5acad88330c541d4c3f64c789d85f94756762d8bca7e706", size = 1575692 }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/8a/9c/cf0bce2cc1c8971bf56629d8f180e4ca35612c7e79e6e432e785261a8be4/celery-5.4.0.tar.gz", hash = "sha256:504a19140e8d3029d5acad88330c541d4c3f64c789d85f94756762d8bca7e706", size = 1575692, upload-time = "2024-04-17T20:29:43.675Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/90/c4/6a4d3772e5407622feb93dd25c86ce3c0fee746fa822a777a627d56b4f2a/celery-5.4.0-py3-none-any.whl", hash = "sha256:369631eb580cf8c51a82721ec538684994f8277637edde2dfc0dacd73ed97f64", size = 425983 },
|
||||
{ url = "https://files.pythonhosted.org/packages/90/c4/6a4d3772e5407622feb93dd25c86ce3c0fee746fa822a777a627d56b4f2a/celery-5.4.0-py3-none-any.whl", hash = "sha256:369631eb580cf8c51a82721ec538684994f8277637edde2dfc0dacd73ed97f64", size = 425983, upload-time = "2024-04-17T20:29:39.406Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "certifi"
|
||||
version = "2024.7.4"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/c2/02/a95f2b11e207f68bc64d7aae9666fed2e2b3f307748d5123dffb72a1bbea/certifi-2024.7.4.tar.gz", hash = "sha256:5a1e7645bc0ec61a09e26c36f6106dd4cf40c6db3a1fb6352b0244e7fb057c7b", size = 164065 }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/c2/02/a95f2b11e207f68bc64d7aae9666fed2e2b3f307748d5123dffb72a1bbea/certifi-2024.7.4.tar.gz", hash = "sha256:5a1e7645bc0ec61a09e26c36f6106dd4cf40c6db3a1fb6352b0244e7fb057c7b", size = 164065, upload-time = "2024-07-04T01:36:11.653Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/1c/d5/c84e1a17bf61d4df64ca866a1c9a913874b4e9bdc131ec689a0ad013fb36/certifi-2024.7.4-py3-none-any.whl", hash = "sha256:c198e21b1289c2ab85ee4e67bb4b4ef3ead0892059901a8d5b622f24a1101e90", size = 162960 },
|
||||
{ url = "https://files.pythonhosted.org/packages/1c/d5/c84e1a17bf61d4df64ca866a1c9a913874b4e9bdc131ec689a0ad013fb36/certifi-2024.7.4-py3-none-any.whl", hash = "sha256:c198e21b1289c2ab85ee4e67bb4b4ef3ead0892059901a8d5b622f24a1101e90", size = 162960, upload-time = "2024-07-04T01:36:09.038Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "charset-normalizer"
|
||||
version = "3.3.2"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/63/09/c1bc53dab74b1816a00d8d030de5bf98f724c52c1635e07681d312f20be8/charset-normalizer-3.3.2.tar.gz", hash = "sha256:f30c3cb33b24454a82faecaf01b19c18562b1e89558fb6c56de4d9118a032fd5", size = 104809 }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/63/09/c1bc53dab74b1816a00d8d030de5bf98f724c52c1635e07681d312f20be8/charset-normalizer-3.3.2.tar.gz", hash = "sha256:f30c3cb33b24454a82faecaf01b19c18562b1e89558fb6c56de4d9118a032fd5", size = 104809, upload-time = "2023-11-01T04:04:59.997Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/e4/a6/7ee57823d46331ddc37dd00749c95b0edec2c79b15fc0d6e6efb532e89ac/charset_normalizer-3.3.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f27273b60488abe721a075bcca6d7f3964f9f6f067c8c4c605743023d7d3944f", size = 136582 },
|
||||
{ url = "https://files.pythonhosted.org/packages/74/f1/0d9fe69ac441467b737ba7f48c68241487df2f4522dd7246d9426e7c690e/charset_normalizer-3.3.2-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1ceae2f17a9c33cb48e3263960dc5fc8005351ee19db217e9b1bb15d28c02574", size = 146645 },
|
||||
{ url = "https://files.pythonhosted.org/packages/05/31/e1f51c76db7be1d4aef220d29fbfa5dbb4a99165d9833dcbf166753b6dc0/charset_normalizer-3.3.2-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:65f6f63034100ead094b8744b3b97965785388f308a64cf8d7c34f2f2e5be0c4", size = 139398 },
|
||||
{ url = "https://files.pythonhosted.org/packages/40/26/f35951c45070edc957ba40a5b1db3cf60a9dbb1b350c2d5bef03e01e61de/charset_normalizer-3.3.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:753f10e867343b4511128c6ed8c82f7bec3bd026875576dfd88483c5c73b2fd8", size = 140273 },
|
||||
{ url = "https://files.pythonhosted.org/packages/07/07/7e554f2bbce3295e191f7e653ff15d55309a9ca40d0362fcdab36f01063c/charset_normalizer-3.3.2-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:4a78b2b446bd7c934f5dcedc588903fb2f5eec172f3d29e52a9096a43722adfc", size = 142577 },
|
||||
{ url = "https://files.pythonhosted.org/packages/d8/b5/eb705c313100defa57da79277d9207dc8d8e45931035862fa64b625bfead/charset_normalizer-3.3.2-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:e537484df0d8f426ce2afb2d0f8e1c3d0b114b83f8850e5f2fbea0e797bd82ae", size = 137747 },
|
||||
{ url = "https://files.pythonhosted.org/packages/19/28/573147271fd041d351b438a5665be8223f1dd92f273713cb882ddafe214c/charset_normalizer-3.3.2-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:eb6904c354526e758fda7167b33005998fb68c46fbc10e013ca97f21ca5c8887", size = 143375 },
|
||||
{ url = "https://files.pythonhosted.org/packages/cf/7c/f3b682fa053cc21373c9a839e6beba7705857075686a05c72e0f8c4980ca/charset_normalizer-3.3.2-cp311-cp311-musllinux_1_1_ppc64le.whl", hash = "sha256:deb6be0ac38ece9ba87dea880e438f25ca3eddfac8b002a2ec3d9183a454e8ae", size = 148474 },
|
||||
{ url = "https://files.pythonhosted.org/packages/1e/49/7ab74d4ac537ece3bc3334ee08645e231f39f7d6df6347b29a74b0537103/charset_normalizer-3.3.2-cp311-cp311-musllinux_1_1_s390x.whl", hash = "sha256:4ab2fe47fae9e0f9dee8c04187ce5d09f48eabe611be8259444906793ab7cbce", size = 140232 },
|
||||
{ url = "https://files.pythonhosted.org/packages/2d/dc/9dacba68c9ac0ae781d40e1a0c0058e26302ea0660e574ddf6797a0347f7/charset_normalizer-3.3.2-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:80402cd6ee291dcb72644d6eac93785fe2c8b9cb30893c1af5b8fdd753b9d40f", size = 140859 },
|
||||
{ url = "https://files.pythonhosted.org/packages/99/b0/9c365f6d79a9f0f3c379ddb40a256a67aa69c59609608fe7feb6235896e1/charset_normalizer-3.3.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8f4a014bc36d3c57402e2977dada34f9c12300af536839dc38c0beab8878f38a", size = 137275 },
|
||||
{ url = "https://files.pythonhosted.org/packages/91/33/749df346e93d7a30cdcb90cbfdd41a06026317bfbfb62cd68307c1a3c543/charset_normalizer-3.3.2-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a10af20b82360ab00827f916a6058451b723b4e65030c5a18577c8b2de5b3389", size = 147518 },
|
||||
{ url = "https://files.pythonhosted.org/packages/72/1a/641d5c9f59e6af4c7b53da463d07600a695b9824e20849cb6eea8a627761/charset_normalizer-3.3.2-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8d756e44e94489e49571086ef83b2bb8ce311e730092d2c34ca8f7d925cb20aa", size = 140182 },
|
||||
{ url = "https://files.pythonhosted.org/packages/ee/fb/14d30eb4956408ee3ae09ad34299131fb383c47df355ddb428a7331cfa1e/charset_normalizer-3.3.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:90d558489962fd4918143277a773316e56c72da56ec7aa3dc3dbbe20fdfed15b", size = 141869 },
|
||||
{ url = "https://files.pythonhosted.org/packages/df/3e/a06b18788ca2eb6695c9b22325b6fde7dde0f1d1838b1792a0076f58fe9d/charset_normalizer-3.3.2-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:6ac7ffc7ad6d040517be39eb591cac5ff87416c2537df6ba3cba3bae290c0fed", size = 144042 },
|
||||
{ url = "https://files.pythonhosted.org/packages/45/59/3d27019d3b447a88fe7e7d004a1e04be220227760264cc41b405e863891b/charset_normalizer-3.3.2-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:7ed9e526742851e8d5cc9e6cf41427dfc6068d4f5a3bb03659444b4cabf6bc26", size = 138275 },
|
||||
{ url = "https://files.pythonhosted.org/packages/7b/ef/5eb105530b4da8ae37d506ccfa25057961b7b63d581def6f99165ea89c7e/charset_normalizer-3.3.2-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:8bdb58ff7ba23002a4c5808d608e4e6c687175724f54a5dade5fa8c67b604e4d", size = 144819 },
|
||||
{ url = "https://files.pythonhosted.org/packages/a2/51/e5023f937d7f307c948ed3e5c29c4b7a3e42ed2ee0b8cdf8f3a706089bf0/charset_normalizer-3.3.2-cp312-cp312-musllinux_1_1_ppc64le.whl", hash = "sha256:6b3251890fff30ee142c44144871185dbe13b11bab478a88887a639655be1068", size = 149415 },
|
||||
{ url = "https://files.pythonhosted.org/packages/24/9d/2e3ef673dfd5be0154b20363c5cdcc5606f35666544381bee15af3778239/charset_normalizer-3.3.2-cp312-cp312-musllinux_1_1_s390x.whl", hash = "sha256:b4a23f61ce87adf89be746c8a8974fe1c823c891d8f86eb218bb957c924bb143", size = 141212 },
|
||||
{ url = "https://files.pythonhosted.org/packages/5b/ae/ce2c12fcac59cb3860b2e2d76dc405253a4475436b1861d95fe75bdea520/charset_normalizer-3.3.2-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:efcb3f6676480691518c177e3b465bcddf57cea040302f9f4e6e191af91174d4", size = 142167 },
|
||||
{ url = "https://files.pythonhosted.org/packages/28/76/e6222113b83e3622caa4bb41032d0b1bf785250607392e1b778aca0b8a7d/charset_normalizer-3.3.2-py3-none-any.whl", hash = "sha256:3e4d1f6587322d2788836a99c69062fbb091331ec940e02d12d179c1d53e25fc", size = 48543 },
|
||||
{ url = "https://files.pythonhosted.org/packages/e4/a6/7ee57823d46331ddc37dd00749c95b0edec2c79b15fc0d6e6efb532e89ac/charset_normalizer-3.3.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f27273b60488abe721a075bcca6d7f3964f9f6f067c8c4c605743023d7d3944f", size = 136582, upload-time = "2023-11-01T04:02:59.776Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/74/f1/0d9fe69ac441467b737ba7f48c68241487df2f4522dd7246d9426e7c690e/charset_normalizer-3.3.2-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1ceae2f17a9c33cb48e3263960dc5fc8005351ee19db217e9b1bb15d28c02574", size = 146645, upload-time = "2023-11-01T04:03:02.186Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/05/31/e1f51c76db7be1d4aef220d29fbfa5dbb4a99165d9833dcbf166753b6dc0/charset_normalizer-3.3.2-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:65f6f63034100ead094b8744b3b97965785388f308a64cf8d7c34f2f2e5be0c4", size = 139398, upload-time = "2023-11-01T04:03:04.255Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/40/26/f35951c45070edc957ba40a5b1db3cf60a9dbb1b350c2d5bef03e01e61de/charset_normalizer-3.3.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:753f10e867343b4511128c6ed8c82f7bec3bd026875576dfd88483c5c73b2fd8", size = 140273, upload-time = "2023-11-01T04:03:05.983Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/07/07/7e554f2bbce3295e191f7e653ff15d55309a9ca40d0362fcdab36f01063c/charset_normalizer-3.3.2-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:4a78b2b446bd7c934f5dcedc588903fb2f5eec172f3d29e52a9096a43722adfc", size = 142577, upload-time = "2023-11-01T04:03:07.567Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d8/b5/eb705c313100defa57da79277d9207dc8d8e45931035862fa64b625bfead/charset_normalizer-3.3.2-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:e537484df0d8f426ce2afb2d0f8e1c3d0b114b83f8850e5f2fbea0e797bd82ae", size = 137747, upload-time = "2023-11-01T04:03:08.886Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/19/28/573147271fd041d351b438a5665be8223f1dd92f273713cb882ddafe214c/charset_normalizer-3.3.2-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:eb6904c354526e758fda7167b33005998fb68c46fbc10e013ca97f21ca5c8887", size = 143375, upload-time = "2023-11-01T04:03:10.613Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/cf/7c/f3b682fa053cc21373c9a839e6beba7705857075686a05c72e0f8c4980ca/charset_normalizer-3.3.2-cp311-cp311-musllinux_1_1_ppc64le.whl", hash = "sha256:deb6be0ac38ece9ba87dea880e438f25ca3eddfac8b002a2ec3d9183a454e8ae", size = 148474, upload-time = "2023-11-01T04:03:11.973Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1e/49/7ab74d4ac537ece3bc3334ee08645e231f39f7d6df6347b29a74b0537103/charset_normalizer-3.3.2-cp311-cp311-musllinux_1_1_s390x.whl", hash = "sha256:4ab2fe47fae9e0f9dee8c04187ce5d09f48eabe611be8259444906793ab7cbce", size = 140232, upload-time = "2023-11-01T04:03:13.505Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2d/dc/9dacba68c9ac0ae781d40e1a0c0058e26302ea0660e574ddf6797a0347f7/charset_normalizer-3.3.2-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:80402cd6ee291dcb72644d6eac93785fe2c8b9cb30893c1af5b8fdd753b9d40f", size = 140859, upload-time = "2023-11-01T04:03:17.362Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/99/b0/9c365f6d79a9f0f3c379ddb40a256a67aa69c59609608fe7feb6235896e1/charset_normalizer-3.3.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8f4a014bc36d3c57402e2977dada34f9c12300af536839dc38c0beab8878f38a", size = 137275, upload-time = "2023-11-01T04:03:28.466Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/91/33/749df346e93d7a30cdcb90cbfdd41a06026317bfbfb62cd68307c1a3c543/charset_normalizer-3.3.2-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a10af20b82360ab00827f916a6058451b723b4e65030c5a18577c8b2de5b3389", size = 147518, upload-time = "2023-11-01T04:03:29.82Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/72/1a/641d5c9f59e6af4c7b53da463d07600a695b9824e20849cb6eea8a627761/charset_normalizer-3.3.2-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8d756e44e94489e49571086ef83b2bb8ce311e730092d2c34ca8f7d925cb20aa", size = 140182, upload-time = "2023-11-01T04:03:31.511Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ee/fb/14d30eb4956408ee3ae09ad34299131fb383c47df355ddb428a7331cfa1e/charset_normalizer-3.3.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:90d558489962fd4918143277a773316e56c72da56ec7aa3dc3dbbe20fdfed15b", size = 141869, upload-time = "2023-11-01T04:03:32.887Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/df/3e/a06b18788ca2eb6695c9b22325b6fde7dde0f1d1838b1792a0076f58fe9d/charset_normalizer-3.3.2-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:6ac7ffc7ad6d040517be39eb591cac5ff87416c2537df6ba3cba3bae290c0fed", size = 144042, upload-time = "2023-11-01T04:03:34.412Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/45/59/3d27019d3b447a88fe7e7d004a1e04be220227760264cc41b405e863891b/charset_normalizer-3.3.2-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:7ed9e526742851e8d5cc9e6cf41427dfc6068d4f5a3bb03659444b4cabf6bc26", size = 138275, upload-time = "2023-11-01T04:03:35.759Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7b/ef/5eb105530b4da8ae37d506ccfa25057961b7b63d581def6f99165ea89c7e/charset_normalizer-3.3.2-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:8bdb58ff7ba23002a4c5808d608e4e6c687175724f54a5dade5fa8c67b604e4d", size = 144819, upload-time = "2023-11-01T04:03:37.216Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a2/51/e5023f937d7f307c948ed3e5c29c4b7a3e42ed2ee0b8cdf8f3a706089bf0/charset_normalizer-3.3.2-cp312-cp312-musllinux_1_1_ppc64le.whl", hash = "sha256:6b3251890fff30ee142c44144871185dbe13b11bab478a88887a639655be1068", size = 149415, upload-time = "2023-11-01T04:03:38.694Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/24/9d/2e3ef673dfd5be0154b20363c5cdcc5606f35666544381bee15af3778239/charset_normalizer-3.3.2-cp312-cp312-musllinux_1_1_s390x.whl", hash = "sha256:b4a23f61ce87adf89be746c8a8974fe1c823c891d8f86eb218bb957c924bb143", size = 141212, upload-time = "2023-11-01T04:03:40.07Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5b/ae/ce2c12fcac59cb3860b2e2d76dc405253a4475436b1861d95fe75bdea520/charset_normalizer-3.3.2-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:efcb3f6676480691518c177e3b465bcddf57cea040302f9f4e6e191af91174d4", size = 142167, upload-time = "2023-11-01T04:03:41.491Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/28/76/e6222113b83e3622caa4bb41032d0b1bf785250607392e1b778aca0b8a7d/charset_normalizer-3.3.2-py3-none-any.whl", hash = "sha256:3e4d1f6587322d2788836a99c69062fbb091331ec940e02d12d179c1d53e25fc", size = 48543, upload-time = "2023-11-01T04:04:58.622Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "click"
|
||||
version = "8.1.7"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/96/d3/f04c7bfcf5c1862a2a5b845c6b2b360488cf47af55dfa79c98f6a6bf98b5/click-8.1.7.tar.gz", hash = "sha256:ca9853ad459e787e2192211578cc907e7594e294c7ccc834310722b41b9ca6de", size = 336121 }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/96/d3/f04c7bfcf5c1862a2a5b845c6b2b360488cf47af55dfa79c98f6a6bf98b5/click-8.1.7.tar.gz", hash = "sha256:ca9853ad459e787e2192211578cc907e7594e294c7ccc834310722b41b9ca6de", size = 336121, upload-time = "2023-08-17T17:29:11.868Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/00/2e/d53fa4befbf2cfa713304affc7ca780ce4fc1fd8710527771b58311a3229/click-8.1.7-py3-none-any.whl", hash = "sha256:ae74fb96c20a0277a1d615f1e4d73c8414f5a98db8b799a7931d1582f3390c28", size = 97941 },
|
||||
{ url = "https://files.pythonhosted.org/packages/00/2e/d53fa4befbf2cfa713304affc7ca780ce4fc1fd8710527771b58311a3229/click-8.1.7-py3-none-any.whl", hash = "sha256:ae74fb96c20a0277a1d615f1e4d73c8414f5a98db8b799a7931d1582f3390c28", size = 97941, upload-time = "2023-08-17T17:29:10.08Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
@ -137,9 +137,9 @@ source = { registry = "https://pypi.org/simple" }
|
|||
dependencies = [
|
||||
{ name = "click", marker = "sys_platform == 'linux'" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/30/ce/217289b77c590ea1e7c24242d9ddd6e249e52c795ff10fac2c50062c48cb/click_didyoumean-0.3.1.tar.gz", hash = "sha256:4f82fdff0dbe64ef8ab2279bd6aa3f6a99c3b28c05aa09cbfc07c9d7fbb5a463", size = 3089 }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/30/ce/217289b77c590ea1e7c24242d9ddd6e249e52c795ff10fac2c50062c48cb/click_didyoumean-0.3.1.tar.gz", hash = "sha256:4f82fdff0dbe64ef8ab2279bd6aa3f6a99c3b28c05aa09cbfc07c9d7fbb5a463", size = 3089, upload-time = "2024-03-24T08:22:07.499Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/1b/5b/974430b5ffdb7a4f1941d13d83c64a0395114503cc357c6b9ae4ce5047ed/click_didyoumean-0.3.1-py3-none-any.whl", hash = "sha256:5c4bb6007cfea5f2fd6583a2fb6701a22a41eb98957e63d0fac41c10e7c3117c", size = 3631 },
|
||||
{ url = "https://files.pythonhosted.org/packages/1b/5b/974430b5ffdb7a4f1941d13d83c64a0395114503cc357c6b9ae4ce5047ed/click_didyoumean-0.3.1-py3-none-any.whl", hash = "sha256:5c4bb6007cfea5f2fd6583a2fb6701a22a41eb98957e63d0fac41c10e7c3117c", size = 3631, upload-time = "2024-03-24T08:22:06.356Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
@ -149,9 +149,9 @@ source = { registry = "https://pypi.org/simple" }
|
|||
dependencies = [
|
||||
{ name = "click", marker = "sys_platform == 'linux'" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/5f/1d/45434f64ed749540af821fd7e42b8e4d23ac04b1eda7c26613288d6cd8a8/click-plugins-1.1.1.tar.gz", hash = "sha256:46ab999744a9d831159c3411bb0c79346d94a444df9a3a3742e9ed63645f264b", size = 8164 }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/5f/1d/45434f64ed749540af821fd7e42b8e4d23ac04b1eda7c26613288d6cd8a8/click-plugins-1.1.1.tar.gz", hash = "sha256:46ab999744a9d831159c3411bb0c79346d94a444df9a3a3742e9ed63645f264b", size = 8164, upload-time = "2019-04-04T04:27:04.82Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/e9/da/824b92d9942f4e472702488857914bdd50f73021efea15b4cad9aca8ecef/click_plugins-1.1.1-py2.py3-none-any.whl", hash = "sha256:5d262006d3222f5057fd81e1623d4443e41dcda5dc815c06b442aa3c02889fc8", size = 7497 },
|
||||
{ url = "https://files.pythonhosted.org/packages/e9/da/824b92d9942f4e472702488857914bdd50f73021efea15b4cad9aca8ecef/click_plugins-1.1.1-py2.py3-none-any.whl", hash = "sha256:5d262006d3222f5057fd81e1623d4443e41dcda5dc815c06b442aa3c02889fc8", size = 7497, upload-time = "2019-04-04T04:27:03.36Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
@ -162,50 +162,50 @@ dependencies = [
|
|||
{ name = "click", marker = "sys_platform == 'linux'" },
|
||||
{ name = "prompt-toolkit", marker = "sys_platform == 'linux'" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/cb/a2/57f4ac79838cfae6912f997b4d1a64a858fb0c86d7fcaae6f7b58d267fca/click-repl-0.3.0.tar.gz", hash = "sha256:17849c23dba3d667247dc4defe1757fff98694e90fe37474f3feebb69ced26a9", size = 10449 }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/cb/a2/57f4ac79838cfae6912f997b4d1a64a858fb0c86d7fcaae6f7b58d267fca/click-repl-0.3.0.tar.gz", hash = "sha256:17849c23dba3d667247dc4defe1757fff98694e90fe37474f3feebb69ced26a9", size = 10449, upload-time = "2023-06-15T12:43:51.141Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/52/40/9d857001228658f0d59e97ebd4c346fe73e138c6de1bce61dc568a57c7f8/click_repl-0.3.0-py3-none-any.whl", hash = "sha256:fb7e06deb8da8de86180a33a9da97ac316751c094c6899382da7feeeeb51b812", size = 10289 },
|
||||
{ url = "https://files.pythonhosted.org/packages/52/40/9d857001228658f0d59e97ebd4c346fe73e138c6de1bce61dc568a57c7f8/click_repl-0.3.0-py3-none-any.whl", hash = "sha256:fb7e06deb8da8de86180a33a9da97ac316751c094c6899382da7feeeeb51b812", size = 10289, upload-time = "2023-06-15T12:43:48.626Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "coverage"
|
||||
version = "7.6.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/f7/08/7e37f82e4d1aead42a7443ff06a1e406aabf7302c4f00a546e4b320b994c/coverage-7.6.1.tar.gz", hash = "sha256:953510dfb7b12ab69d20135a0662397f077c59b1e6379a768e97c59d852ee51d", size = 798791 }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/f7/08/7e37f82e4d1aead42a7443ff06a1e406aabf7302c4f00a546e4b320b994c/coverage-7.6.1.tar.gz", hash = "sha256:953510dfb7b12ab69d20135a0662397f077c59b1e6379a768e97c59d852ee51d", size = 798791, upload-time = "2024-08-04T19:45:30.9Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/aa/cd/766b45fb6e090f20f8927d9c7cb34237d41c73a939358bc881883fd3a40d/coverage-7.6.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d85f5e9a5f8b73e2350097c3756ef7e785f55bd71205defa0bfdaf96c31616ff", size = 239279 },
|
||||
{ url = "https://files.pythonhosted.org/packages/70/6c/a9ccd6fe50ddaf13442a1e2dd519ca805cbe0f1fcd377fba6d8339b98ccb/coverage-7.6.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:9bc572be474cafb617672c43fe989d6e48d3c83af02ce8de73fff1c6bb3c198d", size = 236859 },
|
||||
{ url = "https://files.pythonhosted.org/packages/14/6f/8351b465febb4dbc1ca9929505202db909c5a635c6fdf33e089bbc3d7d85/coverage-7.6.1-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0c0420b573964c760df9e9e86d1a9a622d0d27f417e1a949a8a66dd7bcee7bc6", size = 238549 },
|
||||
{ url = "https://files.pythonhosted.org/packages/68/3c/289b81fa18ad72138e6d78c4c11a82b5378a312c0e467e2f6b495c260907/coverage-7.6.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1f4aa8219db826ce6be7099d559f8ec311549bfc4046f7f9fe9b5cea5c581c56", size = 237477 },
|
||||
{ url = "https://files.pythonhosted.org/packages/ed/1c/aa1efa6459d822bd72c4abc0b9418cf268de3f60eeccd65dc4988553bd8d/coverage-7.6.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:fc5a77d0c516700ebad189b587de289a20a78324bc54baee03dd486f0855d234", size = 236134 },
|
||||
{ url = "https://files.pythonhosted.org/packages/fb/c8/521c698f2d2796565fe9c789c2ee1ccdae610b3aa20b9b2ef980cc253640/coverage-7.6.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:b48f312cca9621272ae49008c7f613337c53fadca647d6384cc129d2996d1133", size = 236910 },
|
||||
{ url = "https://files.pythonhosted.org/packages/92/8f/2ead05e735022d1a7f3a0a683ac7f737de14850395a826192f0288703472/coverage-7.6.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:260933720fdcd75340e7dbe9060655aff3af1f0c5d20f46b57f262ab6c86a5e8", size = 240342 },
|
||||
{ url = "https://files.pythonhosted.org/packages/0f/ef/94043e478201ffa85b8ae2d2c79b4081e5a1b73438aafafccf3e9bafb6b5/coverage-7.6.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:07e2ca0ad381b91350c0ed49d52699b625aab2b44b65e1b4e02fa9df0e92ad2d", size = 237371 },
|
||||
{ url = "https://files.pythonhosted.org/packages/1f/0f/c890339dd605f3ebc269543247bdd43b703cce6825b5ed42ff5f2d6122c7/coverage-7.6.1-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c44fee9975f04b33331cb8eb272827111efc8930cfd582e0320613263ca849ca", size = 239455 },
|
||||
{ url = "https://files.pythonhosted.org/packages/d1/04/7fd7b39ec7372a04efb0f70c70e35857a99b6a9188b5205efb4c77d6a57a/coverage-7.6.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:877abb17e6339d96bf08e7a622d05095e72b71f8afd8a9fefc82cf30ed944163", size = 238924 },
|
||||
{ url = "https://files.pythonhosted.org/packages/ed/bf/73ce346a9d32a09cf369f14d2a06651329c984e106f5992c89579d25b27e/coverage-7.6.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:3e0cadcf6733c09154b461f1ca72d5416635e5e4ec4e536192180d34ec160f8a", size = 237252 },
|
||||
{ url = "https://files.pythonhosted.org/packages/86/74/1dc7a20969725e917b1e07fe71a955eb34bc606b938316bcc799f228374b/coverage-7.6.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:c3c02d12f837d9683e5ab2f3d9844dc57655b92c74e286c262e0fc54213c216d", size = 238897 },
|
||||
{ url = "https://files.pythonhosted.org/packages/14/5b/9dec847b305e44a5634d0fb8498d135ab1d88330482b74065fcec0622224/coverage-7.6.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d0c212c49b6c10e6951362f7c6df3329f04c2b1c28499563d4035d964ab8e08c", size = 239886 },
|
||||
{ url = "https://files.pythonhosted.org/packages/7b/b7/35760a67c168e29f454928f51f970342d23cf75a2bb0323e0f07334c85f3/coverage-7.6.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:6e81d7a3e58882450ec4186ca59a3f20a5d4440f25b1cff6f0902ad890e6748a", size = 237037 },
|
||||
{ url = "https://files.pythonhosted.org/packages/f7/95/d2fd31f1d638df806cae59d7daea5abf2b15b5234016a5ebb502c2f3f7ee/coverage-7.6.1-cp313-cp313-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:78b260de9790fd81e69401c2dc8b17da47c8038176a79092a89cb2b7d945d060", size = 239038 },
|
||||
{ url = "https://files.pythonhosted.org/packages/6e/bd/110689ff5752b67924efd5e2aedf5190cbbe245fc81b8dec1abaffba619d/coverage-7.6.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a78d169acd38300060b28d600344a803628c3fd585c912cacc9ea8790fe96862", size = 238690 },
|
||||
{ url = "https://files.pythonhosted.org/packages/d3/a8/08d7b38e6ff8df52331c83130d0ab92d9c9a8b5462f9e99c9f051a4ae206/coverage-7.6.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:2c09f4ce52cb99dd7505cd0fc8e0e37c77b87f46bc9c1eb03fe3bc9991085388", size = 236765 },
|
||||
{ url = "https://files.pythonhosted.org/packages/d6/6a/9cf96839d3147d55ae713eb2d877f4d777e7dc5ba2bce227167d0118dfe8/coverage-7.6.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6878ef48d4227aace338d88c48738a4258213cd7b74fd9a3d4d7582bb1d8a155", size = 238611 },
|
||||
{ url = "https://files.pythonhosted.org/packages/b8/d7/62095e355ec0613b08dfb19206ce3033a0eedb6f4a67af5ed267a8800642/coverage-7.6.1-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6a89ecca80709d4076b95f89f308544ec8f7b4727e8a547913a35f16717856cb", size = 250839 },
|
||||
{ url = "https://files.pythonhosted.org/packages/7c/1e/c2967cb7991b112ba3766df0d9c21de46b476d103e32bb401b1b2adf3380/coverage-7.6.1-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a318d68e92e80af8b00fa99609796fdbcdfef3629c77c6283566c6f02c6d6704", size = 246569 },
|
||||
{ url = "https://files.pythonhosted.org/packages/8b/61/a7a6a55dd266007ed3b1df7a3386a0d760d014542d72f7c2c6938483b7bd/coverage-7.6.1-cp313-cp313t-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:13b0a73a0896988f053e4fbb7de6d93388e6dd292b0d87ee51d106f2c11b465b", size = 248927 },
|
||||
{ url = "https://files.pythonhosted.org/packages/c8/fa/13a6f56d72b429f56ef612eb3bc5ce1b75b7ee12864b3bd12526ab794847/coverage-7.6.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:4421712dbfc5562150f7554f13dde997a2e932a6b5f352edcce948a815efee6f", size = 248401 },
|
||||
{ url = "https://files.pythonhosted.org/packages/75/06/0429c652aa0fb761fc60e8c6b291338c9173c6aa0f4e40e1902345b42830/coverage-7.6.1-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:166811d20dfea725e2e4baa71fffd6c968a958577848d2131f39b60043400223", size = 246301 },
|
||||
{ url = "https://files.pythonhosted.org/packages/52/76/1766bb8b803a88f93c3a2d07e30ffa359467810e5cbc68e375ebe6906efb/coverage-7.6.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:225667980479a17db1048cb2bf8bfb39b8e5be8f164b8f6628b64f78a72cf9d3", size = 247598 },
|
||||
{ url = "https://files.pythonhosted.org/packages/aa/cd/766b45fb6e090f20f8927d9c7cb34237d41c73a939358bc881883fd3a40d/coverage-7.6.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d85f5e9a5f8b73e2350097c3756ef7e785f55bd71205defa0bfdaf96c31616ff", size = 239279, upload-time = "2024-08-04T19:43:33.581Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/70/6c/a9ccd6fe50ddaf13442a1e2dd519ca805cbe0f1fcd377fba6d8339b98ccb/coverage-7.6.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:9bc572be474cafb617672c43fe989d6e48d3c83af02ce8de73fff1c6bb3c198d", size = 236859, upload-time = "2024-08-04T19:43:35.301Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/14/6f/8351b465febb4dbc1ca9929505202db909c5a635c6fdf33e089bbc3d7d85/coverage-7.6.1-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0c0420b573964c760df9e9e86d1a9a622d0d27f417e1a949a8a66dd7bcee7bc6", size = 238549, upload-time = "2024-08-04T19:43:37.578Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/68/3c/289b81fa18ad72138e6d78c4c11a82b5378a312c0e467e2f6b495c260907/coverage-7.6.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1f4aa8219db826ce6be7099d559f8ec311549bfc4046f7f9fe9b5cea5c581c56", size = 237477, upload-time = "2024-08-04T19:43:39.92Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ed/1c/aa1efa6459d822bd72c4abc0b9418cf268de3f60eeccd65dc4988553bd8d/coverage-7.6.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:fc5a77d0c516700ebad189b587de289a20a78324bc54baee03dd486f0855d234", size = 236134, upload-time = "2024-08-04T19:43:41.453Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fb/c8/521c698f2d2796565fe9c789c2ee1ccdae610b3aa20b9b2ef980cc253640/coverage-7.6.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:b48f312cca9621272ae49008c7f613337c53fadca647d6384cc129d2996d1133", size = 236910, upload-time = "2024-08-04T19:43:43.037Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/92/8f/2ead05e735022d1a7f3a0a683ac7f737de14850395a826192f0288703472/coverage-7.6.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:260933720fdcd75340e7dbe9060655aff3af1f0c5d20f46b57f262ab6c86a5e8", size = 240342, upload-time = "2024-08-04T19:43:53.746Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0f/ef/94043e478201ffa85b8ae2d2c79b4081e5a1b73438aafafccf3e9bafb6b5/coverage-7.6.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:07e2ca0ad381b91350c0ed49d52699b625aab2b44b65e1b4e02fa9df0e92ad2d", size = 237371, upload-time = "2024-08-04T19:43:55.993Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1f/0f/c890339dd605f3ebc269543247bdd43b703cce6825b5ed42ff5f2d6122c7/coverage-7.6.1-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c44fee9975f04b33331cb8eb272827111efc8930cfd582e0320613263ca849ca", size = 239455, upload-time = "2024-08-04T19:43:57.618Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d1/04/7fd7b39ec7372a04efb0f70c70e35857a99b6a9188b5205efb4c77d6a57a/coverage-7.6.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:877abb17e6339d96bf08e7a622d05095e72b71f8afd8a9fefc82cf30ed944163", size = 238924, upload-time = "2024-08-04T19:44:00.012Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ed/bf/73ce346a9d32a09cf369f14d2a06651329c984e106f5992c89579d25b27e/coverage-7.6.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:3e0cadcf6733c09154b461f1ca72d5416635e5e4ec4e536192180d34ec160f8a", size = 237252, upload-time = "2024-08-04T19:44:01.713Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/86/74/1dc7a20969725e917b1e07fe71a955eb34bc606b938316bcc799f228374b/coverage-7.6.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:c3c02d12f837d9683e5ab2f3d9844dc57655b92c74e286c262e0fc54213c216d", size = 238897, upload-time = "2024-08-04T19:44:03.898Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/14/5b/9dec847b305e44a5634d0fb8498d135ab1d88330482b74065fcec0622224/coverage-7.6.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d0c212c49b6c10e6951362f7c6df3329f04c2b1c28499563d4035d964ab8e08c", size = 239886, upload-time = "2024-08-04T19:44:12.83Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7b/b7/35760a67c168e29f454928f51f970342d23cf75a2bb0323e0f07334c85f3/coverage-7.6.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:6e81d7a3e58882450ec4186ca59a3f20a5d4440f25b1cff6f0902ad890e6748a", size = 237037, upload-time = "2024-08-04T19:44:15.393Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f7/95/d2fd31f1d638df806cae59d7daea5abf2b15b5234016a5ebb502c2f3f7ee/coverage-7.6.1-cp313-cp313-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:78b260de9790fd81e69401c2dc8b17da47c8038176a79092a89cb2b7d945d060", size = 239038, upload-time = "2024-08-04T19:44:17.466Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6e/bd/110689ff5752b67924efd5e2aedf5190cbbe245fc81b8dec1abaffba619d/coverage-7.6.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a78d169acd38300060b28d600344a803628c3fd585c912cacc9ea8790fe96862", size = 238690, upload-time = "2024-08-04T19:44:19.336Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d3/a8/08d7b38e6ff8df52331c83130d0ab92d9c9a8b5462f9e99c9f051a4ae206/coverage-7.6.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:2c09f4ce52cb99dd7505cd0fc8e0e37c77b87f46bc9c1eb03fe3bc9991085388", size = 236765, upload-time = "2024-08-04T19:44:20.994Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d6/6a/9cf96839d3147d55ae713eb2d877f4d777e7dc5ba2bce227167d0118dfe8/coverage-7.6.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6878ef48d4227aace338d88c48738a4258213cd7b74fd9a3d4d7582bb1d8a155", size = 238611, upload-time = "2024-08-04T19:44:22.616Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b8/d7/62095e355ec0613b08dfb19206ce3033a0eedb6f4a67af5ed267a8800642/coverage-7.6.1-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6a89ecca80709d4076b95f89f308544ec8f7b4727e8a547913a35f16717856cb", size = 250839, upload-time = "2024-08-04T19:44:32.412Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7c/1e/c2967cb7991b112ba3766df0d9c21de46b476d103e32bb401b1b2adf3380/coverage-7.6.1-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a318d68e92e80af8b00fa99609796fdbcdfef3629c77c6283566c6f02c6d6704", size = 246569, upload-time = "2024-08-04T19:44:34.547Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8b/61/a7a6a55dd266007ed3b1df7a3386a0d760d014542d72f7c2c6938483b7bd/coverage-7.6.1-cp313-cp313t-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:13b0a73a0896988f053e4fbb7de6d93388e6dd292b0d87ee51d106f2c11b465b", size = 248927, upload-time = "2024-08-04T19:44:36.313Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c8/fa/13a6f56d72b429f56ef612eb3bc5ce1b75b7ee12864b3bd12526ab794847/coverage-7.6.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:4421712dbfc5562150f7554f13dde997a2e932a6b5f352edcce948a815efee6f", size = 248401, upload-time = "2024-08-04T19:44:38.155Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/75/06/0429c652aa0fb761fc60e8c6b291338c9173c6aa0f4e40e1902345b42830/coverage-7.6.1-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:166811d20dfea725e2e4baa71fffd6c968a958577848d2131f39b60043400223", size = 246301, upload-time = "2024-08-04T19:44:39.883Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/52/76/1766bb8b803a88f93c3a2d07e30ffa359467810e5cbc68e375ebe6906efb/coverage-7.6.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:225667980479a17db1048cb2bf8bfb39b8e5be8f164b8f6628b64f78a72cf9d3", size = 247598, upload-time = "2024-08-04T19:44:41.59Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cron-descriptor"
|
||||
version = "1.4.5"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/02/83/70bd410dc6965e33a5460b7da84cf0c5a7330a68d6d5d4c3dfdb72ca117e/cron_descriptor-1.4.5.tar.gz", hash = "sha256:f51ce4ffc1d1f2816939add8524f206c376a42c87a5fca3091ce26725b3b1bca", size = 30666 }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/02/83/70bd410dc6965e33a5460b7da84cf0c5a7330a68d6d5d4c3dfdb72ca117e/cron_descriptor-1.4.5.tar.gz", hash = "sha256:f51ce4ffc1d1f2816939add8524f206c376a42c87a5fca3091ce26725b3b1bca", size = 30666, upload-time = "2024-08-24T18:16:48.654Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/88/20/2cfe598ead23a715a00beb716477cfddd3e5948cf203c372d02221e5b0c6/cron_descriptor-1.4.5-py3-none-any.whl", hash = "sha256:736b3ae9d1a99bc3dbfc5b55b5e6e7c12031e7ba5de716625772f8b02dcd6013", size = 50370 },
|
||||
{ url = "https://files.pythonhosted.org/packages/88/20/2cfe598ead23a715a00beb716477cfddd3e5948cf203c372d02221e5b0c6/cron_descriptor-1.4.5-py3-none-any.whl", hash = "sha256:736b3ae9d1a99bc3dbfc5b55b5e6e7c12031e7ba5de716625772f8b02dcd6013", size = 50370, upload-time = "2024-08-24T18:16:46.783Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
@ -216,9 +216,9 @@ dependencies = [
|
|||
{ name = "asgiref", marker = "sys_platform == 'linux'" },
|
||||
{ name = "sqlparse", marker = "sys_platform == 'linux'" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/65/d8/a607ee443b54a4db4ad28902328b906ae6218aa556fb9b3ac45c0bcb313d/Django-4.2.16.tar.gz", hash = "sha256:6f1616c2786c408ce86ab7e10f792b8f15742f7b7b7460243929cb371e7f1dad", size = 10436023 }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/65/d8/a607ee443b54a4db4ad28902328b906ae6218aa556fb9b3ac45c0bcb313d/Django-4.2.16.tar.gz", hash = "sha256:6f1616c2786c408ce86ab7e10f792b8f15742f7b7b7460243929cb371e7f1dad", size = 10436023, upload-time = "2024-09-03T14:02:02.046Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/94/2c/6b6c7e493d5ea789416918658ebfa16be7a64c77610307497ed09a93c8c4/Django-4.2.16-py3-none-any.whl", hash = "sha256:1ddc333a16fc139fd253035a1606bb24261951bbc3a6ca256717fa06cc41a898", size = 7992936 },
|
||||
{ url = "https://files.pythonhosted.org/packages/94/2c/6b6c7e493d5ea789416918658ebfa16be7a64c77610307497ed09a93c8c4/Django-4.2.16-py3-none-any.whl", hash = "sha256:1ddc333a16fc139fd253035a1606bb24261951bbc3a6ca256717fa06cc41a898", size = 7992936, upload-time = "2024-09-03T14:01:55.363Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
@ -229,9 +229,9 @@ dependencies = [
|
|||
{ name = "asgiref", marker = "sys_platform == 'linux'" },
|
||||
{ name = "django", marker = "sys_platform == 'linux'" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/6e/a5/31721dc9777fe7f01b4bd710f93d031ff03603b960bc282c53edd5578bf2/django_axes-6.5.1.tar.gz", hash = "sha256:d57f0fc95d581a602c642b3fe5bc31488b9401bd7441f3bec1fef0e599028499", size = 246679 }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/6e/a5/31721dc9777fe7f01b4bd710f93d031ff03603b960bc282c53edd5578bf2/django_axes-6.5.1.tar.gz", hash = "sha256:d57f0fc95d581a602c642b3fe5bc31488b9401bd7441f3bec1fef0e599028499", size = 246679, upload-time = "2024-07-01T14:36:45.168Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/96/39/316e4b5a4c931698480953ea5f43df0657f8c47b9e981cfc331b8ed9eef5/django_axes-6.5.1-py3-none-any.whl", hash = "sha256:7435068cc8523bfa3f34faa62bb3a772b76d00925c3ff54aef43e4316e74bf05", size = 68409 },
|
||||
{ url = "https://files.pythonhosted.org/packages/96/39/316e4b5a4c931698480953ea5f43df0657f8c47b9e981cfc331b8ed9eef5/django_axes-6.5.1-py3-none-any.whl", hash = "sha256:7435068cc8523bfa3f34faa62bb3a772b76d00925c3ff54aef43e4316e74bf05", size = 68409, upload-time = "2024-07-01T14:36:30.174Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
@ -246,9 +246,9 @@ dependencies = [
|
|||
{ name = "python-crontab", marker = "sys_platform == 'linux'" },
|
||||
{ name = "tzdata", marker = "sys_platform == 'linux'" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/03/8f/8a18f234173001bd7a7d63826d2d7f456b38031c892514d27c0f7aea10be/django_celery_beat-2.7.0.tar.gz", hash = "sha256:8482034925e09b698c05ad61c36ed2a8dbc436724a3fe119215193a4ca6dc967", size = 163472 }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/03/8f/8a18f234173001bd7a7d63826d2d7f456b38031c892514d27c0f7aea10be/django_celery_beat-2.7.0.tar.gz", hash = "sha256:8482034925e09b698c05ad61c36ed2a8dbc436724a3fe119215193a4ca6dc967", size = 163472, upload-time = "2024-08-22T11:12:24.943Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/29/f8/f5a25472222b19258c3a53ce71c4efd171a12ab3c988bb3026dec0522a64/django_celery_beat-2.7.0-py3-none-any.whl", hash = "sha256:851c680d8fbf608ca5fecd5836622beea89fa017bc2b3f94a5b8c648c32d84b1", size = 94097 },
|
||||
{ url = "https://files.pythonhosted.org/packages/29/f8/f5a25472222b19258c3a53ce71c4efd171a12ab3c988bb3026dec0522a64/django_celery_beat-2.7.0-py3-none-any.whl", hash = "sha256:851c680d8fbf608ca5fecd5836622beea89fa017bc2b3f94a5b8c648c32d84b1", size = 94097, upload-time = "2024-08-22T11:12:23.172Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
@ -259,9 +259,9 @@ dependencies = [
|
|||
{ name = "django", marker = "sys_platform == 'linux'" },
|
||||
{ name = "sqlparse", marker = "sys_platform == 'linux'" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/61/ff/b6d3cc2c31f9a6cf68eda0f7a640ada743f5c39122a0c14db8d3eee3f412/django_debug_toolbar-4.3.0.tar.gz", hash = "sha256:0b0dddee5ea29b9cb678593bc0d7a6d76b21d7799cb68e091a2148341a80f3c4", size = 261173 }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/61/ff/b6d3cc2c31f9a6cf68eda0f7a640ada743f5c39122a0c14db8d3eee3f412/django_debug_toolbar-4.3.0.tar.gz", hash = "sha256:0b0dddee5ea29b9cb678593bc0d7a6d76b21d7799cb68e091a2148341a80f3c4", size = 261173, upload-time = "2024-02-01T19:36:52.509Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/ee/0e/73d81b1dfd9a84f24e3869019309758b37a5a5a9fe2bc7e54fca08191ea0/django_debug_toolbar-4.3.0-py3-none-any.whl", hash = "sha256:e09b7dcb8417b743234dfc57c95a7c1d1d87a88844abd13b4c5387f807b31bf6", size = 223656 },
|
||||
{ url = "https://files.pythonhosted.org/packages/ee/0e/73d81b1dfd9a84f24e3869019309758b37a5a5a9fe2bc7e54fca08191ea0/django_debug_toolbar-4.3.0-py3-none-any.whl", hash = "sha256:e09b7dcb8417b743234dfc57c95a7c1d1d87a88844abd13b4c5387f807b31bf6", size = 223656, upload-time = "2024-02-01T19:37:22.036Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
@ -271,9 +271,9 @@ source = { registry = "https://pypi.org/simple" }
|
|||
dependencies = [
|
||||
{ name = "django", marker = "sys_platform == 'linux'" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/8a/f1/318684c9466968bf9a9c221663128206e460c1a67f595055be4b284cde8a/django-extensions-3.2.3.tar.gz", hash = "sha256:44d27919d04e23b3f40231c4ab7af4e61ce832ef46d610cc650d53e68328410a", size = 277216 }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/8a/f1/318684c9466968bf9a9c221663128206e460c1a67f595055be4b284cde8a/django-extensions-3.2.3.tar.gz", hash = "sha256:44d27919d04e23b3f40231c4ab7af4e61ce832ef46d610cc650d53e68328410a", size = 277216, upload-time = "2023-06-05T17:09:01.447Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/a7/7e/ba12b9660642663f5273141018d2bec0a1cae1711f4f6d1093920e157946/django_extensions-3.2.3-py3-none-any.whl", hash = "sha256:9600b7562f79a92cbf1fde6403c04fee314608fefbb595502e34383ae8203401", size = 229868 },
|
||||
{ url = "https://files.pythonhosted.org/packages/a7/7e/ba12b9660642663f5273141018d2bec0a1cae1711f4f6d1093920e157946/django_extensions-3.2.3-py3-none-any.whl", hash = "sha256:9600b7562f79a92cbf1fde6403c04fee314608fefbb595502e34383ae8203401", size = 229868, upload-time = "2023-06-05T17:08:58.197Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
@ -283,7 +283,7 @@ source = { registry = "https://pypi.org/simple" }
|
|||
dependencies = [
|
||||
{ name = "djangorestframework", marker = "sys_platform == 'linux'" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/ed/d2/61159bc6efd1bf16adc4a2a48f7ace2080d1f7aef054f606d1857cab490c/django-rest-framework-0.1.0.tar.gz", hash = "sha256:47a8f496fa69e3b6bd79f68dd7a1527d907d6b77f009e9db7cf9bb21cc565e4a", size = 969 }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/ed/d2/61159bc6efd1bf16adc4a2a48f7ace2080d1f7aef054f606d1857cab490c/django-rest-framework-0.1.0.tar.gz", hash = "sha256:47a8f496fa69e3b6bd79f68dd7a1527d907d6b77f009e9db7cf9bb21cc565e4a", size = 969, upload-time = "2017-07-20T17:14:33.345Z" }
|
||||
|
||||
[[package]]
|
||||
name = "django-stubs"
|
||||
|
|
@ -296,9 +296,9 @@ dependencies = [
|
|||
{ name = "types-pyyaml", marker = "sys_platform == 'linux'" },
|
||||
{ name = "typing-extensions", marker = "sys_platform == 'linux'" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/36/f4/2dfc77809e4d04164ec614755e2359ec2e68a32f7b5428909fa0b7f8f4e0/django_stubs-5.0.4.tar.gz", hash = "sha256:78e3764488fdfd2695f12502136548ec22f8d4b1780541a835042b8238d11514", size = 262238 }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/36/f4/2dfc77809e4d04164ec614755e2359ec2e68a32f7b5428909fa0b7f8f4e0/django_stubs-5.0.4.tar.gz", hash = "sha256:78e3764488fdfd2695f12502136548ec22f8d4b1780541a835042b8238d11514", size = 262238, upload-time = "2024-07-28T18:10:22.041Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/ec/f0/36c0f82ed7b4ef630b39e165590645c4fe4361f52d41bca5001327d62f57/django_stubs-5.0.4-py3-none-any.whl", hash = "sha256:c2502f5ecbae50c68f9a86d52b5b2447d8648fd205036dad0ccb41e19a445927", size = 466530 },
|
||||
{ url = "https://files.pythonhosted.org/packages/ec/f0/36c0f82ed7b4ef630b39e165590645c4fe4361f52d41bca5001327d62f57/django_stubs-5.0.4-py3-none-any.whl", hash = "sha256:c2502f5ecbae50c68f9a86d52b5b2447d8648fd205036dad0ccb41e19a445927", size = 466530, upload-time = "2024-07-28T18:10:19.978Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
@ -309,9 +309,9 @@ dependencies = [
|
|||
{ name = "django", marker = "sys_platform == 'linux'" },
|
||||
{ name = "typing-extensions", marker = "sys_platform == 'linux'" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/91/77/ef453a8286fff87db8efd7fe93c1a86f05aeddcc78973c883af91b667f74/django_stubs_ext-5.0.4.tar.gz", hash = "sha256:85da065224204774208be29c7d02b4482d5a69218a728465c2fbe41725fdc819", size = 9410 }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/91/77/ef453a8286fff87db8efd7fe93c1a86f05aeddcc78973c883af91b667f74/django_stubs_ext-5.0.4.tar.gz", hash = "sha256:85da065224204774208be29c7d02b4482d5a69218a728465c2fbe41725fdc819", size = 9410, upload-time = "2024-07-28T18:09:42.706Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/cc/54/66a7ccb1f4e4a8e37e0881a3dfdcabaee9fc0c0c91cbe64170e794acebd7/django_stubs_ext-5.0.4-py3-none-any.whl", hash = "sha256:910cbaff3d1e8e806a5c27d5ddd4088535aae8371ea921b7fd680fdfa5f14e30", size = 8954 },
|
||||
{ url = "https://files.pythonhosted.org/packages/cc/54/66a7ccb1f4e4a8e37e0881a3dfdcabaee9fc0c0c91cbe64170e794acebd7/django_stubs_ext-5.0.4-py3-none-any.whl", hash = "sha256:910cbaff3d1e8e806a5c27d5ddd4088535aae8371ea921b7fd680fdfa5f14e30", size = 8954, upload-time = "2024-07-28T18:09:41.325Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
@ -321,9 +321,9 @@ source = { registry = "https://pypi.org/simple" }
|
|||
dependencies = [
|
||||
{ name = "django", marker = "sys_platform == 'linux'" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/94/b3/992aa517b95f2e6934aa05b8160cf55f91c49c7b91e33076ea9af2f29920/django_timezone_field-7.0.tar.gz", hash = "sha256:aa6f4965838484317b7f08d22c0d91a53d64e7bbbd34264468ae83d4023898a7", size = 13683 }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/94/b3/992aa517b95f2e6934aa05b8160cf55f91c49c7b91e33076ea9af2f29920/django_timezone_field-7.0.tar.gz", hash = "sha256:aa6f4965838484317b7f08d22c0d91a53d64e7bbbd34264468ae83d4023898a7", size = 13683, upload-time = "2024-07-07T18:32:06.941Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/2a/f9/11769c4414026f1a9ce3e581731d07b084683fc7b4c580703dc71ef81347/django_timezone_field-7.0-py3-none-any.whl", hash = "sha256:3232e7ecde66ba4464abb6f9e6b8cc739b914efb9b29dc2cf2eee451f7cc2acb", size = 13161 },
|
||||
{ url = "https://files.pythonhosted.org/packages/2a/f9/11769c4414026f1a9ce3e581731d07b084683fc7b4c580703dc71ef81347/django_timezone_field-7.0-py3-none-any.whl", hash = "sha256:3232e7ecde66ba4464abb6f9e6b8cc739b914efb9b29dc2cf2eee451f7cc2acb", size = 13161, upload-time = "2024-07-07T18:32:04.639Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
@ -333,16 +333,16 @@ source = { registry = "https://pypi.org/simple" }
|
|||
dependencies = [
|
||||
{ name = "django", marker = "sys_platform == 'linux'" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/ec/60/cc2dd985400293fe7bf3fa1b9a5d61f5b44200c33f7d31952f2c9fd79e8a/djangorestframework-3.15.1.tar.gz", hash = "sha256:f88fad74183dfc7144b2756d0d2ac716ea5b4c7c9840995ac3bfd8ec034333c1", size = 1066194 }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/ec/60/cc2dd985400293fe7bf3fa1b9a5d61f5b44200c33f7d31952f2c9fd79e8a/djangorestframework-3.15.1.tar.gz", hash = "sha256:f88fad74183dfc7144b2756d0d2ac716ea5b4c7c9840995ac3bfd8ec034333c1", size = 1066194, upload-time = "2024-03-22T15:50:51.875Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/c0/7e/8c45ea7f85dd5d52ceddbacc6f56ecaca21ecbfc0e8c34c95618a14d5082/djangorestframework-3.15.1-py3-none-any.whl", hash = "sha256:3ccc0475bce968608cf30d07fb17d8e52d1d7fc8bfe779c905463200750cbca6", size = 1067096 },
|
||||
{ url = "https://files.pythonhosted.org/packages/c0/7e/8c45ea7f85dd5d52ceddbacc6f56ecaca21ecbfc0e8c34c95618a14d5082/djangorestframework-3.15.1-py3-none-any.whl", hash = "sha256:3ccc0475bce968608cf30d07fb17d8e52d1d7fc8bfe779c905463200750cbca6", size = 1067096, upload-time = "2024-03-22T15:50:48.759Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "djangorestframework-camel-case"
|
||||
version = "1.4.2"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/f4/87/647ce93053cb5e35e07bded676340774fe43190388b885c54aff47d8557b/djangorestframework-camel-case-1.4.2.tar.gz", hash = "sha256:cdae75846648abb6585c7470639a1d2fb064dc45f8e8b62aaa50be7f1a7a61f4", size = 8839 }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/f4/87/647ce93053cb5e35e07bded676340774fe43190388b885c54aff47d8557b/djangorestframework-camel-case-1.4.2.tar.gz", hash = "sha256:cdae75846648abb6585c7470639a1d2fb064dc45f8e8b62aaa50be7f1a7a61f4", size = 8839, upload-time = "2023-02-13T15:28:11.941Z" }
|
||||
|
||||
[[package]]
|
||||
name = "factory-boy"
|
||||
|
|
@ -351,9 +351,9 @@ source = { registry = "https://pypi.org/simple" }
|
|||
dependencies = [
|
||||
{ name = "faker", marker = "sys_platform == 'linux'" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/ba/98/75cacae9945f67cfe323829fc2ac451f64517a8a330b572a06a323997065/factory_boy-3.3.3.tar.gz", hash = "sha256:866862d226128dfac7f2b4160287e899daf54f2612778327dd03d0e2cb1e3d03", size = 164146 }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/ba/98/75cacae9945f67cfe323829fc2ac451f64517a8a330b572a06a323997065/factory_boy-3.3.3.tar.gz", hash = "sha256:866862d226128dfac7f2b4160287e899daf54f2612778327dd03d0e2cb1e3d03", size = 164146, upload-time = "2025-02-03T09:49:04.433Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/27/8d/2bc5f5546ff2ccb3f7de06742853483ab75bf74f36a92254702f8baecc79/factory_boy-3.3.3-py2.py3-none-any.whl", hash = "sha256:1c39e3289f7e667c4285433f305f8d506efc2fe9c73aaea4151ebd5cdea394fc", size = 37036 },
|
||||
{ url = "https://files.pythonhosted.org/packages/27/8d/2bc5f5546ff2ccb3f7de06742853483ab75bf74f36a92254702f8baecc79/factory_boy-3.3.3-py2.py3-none-any.whl", hash = "sha256:1c39e3289f7e667c4285433f305f8d506efc2fe9c73aaea4151ebd5cdea394fc", size = 37036, upload-time = "2025-02-03T09:49:01.659Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
@ -363,9 +363,9 @@ source = { registry = "https://pypi.org/simple" }
|
|||
dependencies = [
|
||||
{ name = "tzdata", marker = "sys_platform == 'linux'" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/37/62/80f15fe1b5abf3e5b09815178d7eb63a150fc7fcfebd5271ca4aab1d885a/faker-37.0.2.tar.gz", hash = "sha256:948bd27706478d3aa0b6f9f58b9f25207098f6ca79852c7b49c44a8ced2bc59b", size = 1875441 }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/37/62/80f15fe1b5abf3e5b09815178d7eb63a150fc7fcfebd5271ca4aab1d885a/faker-37.0.2.tar.gz", hash = "sha256:948bd27706478d3aa0b6f9f58b9f25207098f6ca79852c7b49c44a8ced2bc59b", size = 1875441, upload-time = "2025-03-19T15:29:47.141Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/a9/8b/b738d3d79ee4502ca966a2a4fa6833c11f50130127bdd57729e9b29c6d2f/faker-37.0.2-py3-none-any.whl", hash = "sha256:8955706c56c28099585e9e2b6f814eb0a3a227eb36a2ee3eb9ab577c4764eacc", size = 1918397 },
|
||||
{ url = "https://files.pythonhosted.org/packages/a9/8b/b738d3d79ee4502ca966a2a4fa6833c11f50130127bdd57729e9b29c6d2f/faker-37.0.2-py3-none-any.whl", hash = "sha256:8955706c56c28099585e9e2b6f814eb0a3a227eb36a2ee3eb9ab577c4764eacc", size = 1918397, upload-time = "2025-03-19T15:29:43.885Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
@ -375,9 +375,9 @@ source = { registry = "https://pypi.org/simple" }
|
|||
dependencies = [
|
||||
{ name = "sgmllib3k", marker = "sys_platform == 'linux'" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/ff/aa/7af346ebeb42a76bf108027fe7f3328bb4e57a3a96e53e21fd9ef9dd6dd0/feedparser-6.0.11.tar.gz", hash = "sha256:c9d0407b64c6f2a065d0ebb292c2b35c01050cc0dc33757461aaabdc4c4184d5", size = 286197 }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/ff/aa/7af346ebeb42a76bf108027fe7f3328bb4e57a3a96e53e21fd9ef9dd6dd0/feedparser-6.0.11.tar.gz", hash = "sha256:c9d0407b64c6f2a065d0ebb292c2b35c01050cc0dc33757461aaabdc4c4184d5", size = 286197, upload-time = "2023-12-10T16:03:20.854Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/7c/d4/8c31aad9cc18f451c49f7f9cfb5799dadffc88177f7917bc90a66459b1d7/feedparser-6.0.11-py3-none-any.whl", hash = "sha256:0be7ee7b395572b19ebeb1d6aafb0028dee11169f1c934e0ed67d54992f4ad45", size = 81343 },
|
||||
{ url = "https://files.pythonhosted.org/packages/7c/d4/8c31aad9cc18f451c49f7f9cfb5799dadffc88177f7917bc90a66459b1d7/feedparser-6.0.11-py3-none-any.whl", hash = "sha256:0be7ee7b395572b19ebeb1d6aafb0028dee11169f1c934e0ed67d54992f4ad45", size = 81343, upload-time = "2023-12-10T16:03:19.484Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
@ -387,9 +387,9 @@ source = { registry = "https://pypi.org/simple" }
|
|||
dependencies = [
|
||||
{ name = "python-dateutil", marker = "sys_platform == 'linux'" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/2c/ef/722b8d71ddf4d48f25f6d78aa2533d505bf3eec000a7cacb8ccc8de61f2f/freezegun-1.5.1.tar.gz", hash = "sha256:b29dedfcda6d5e8e083ce71b2b542753ad48cfec44037b3fc79702e2980a89e9", size = 33697 }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/2c/ef/722b8d71ddf4d48f25f6d78aa2533d505bf3eec000a7cacb8ccc8de61f2f/freezegun-1.5.1.tar.gz", hash = "sha256:b29dedfcda6d5e8e083ce71b2b542753ad48cfec44037b3fc79702e2980a89e9", size = 33697, upload-time = "2024-05-11T17:32:53.911Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/51/0b/0d7fee5919bccc1fdc1c2a7528b98f65c6f69b223a3fd8f809918c142c36/freezegun-1.5.1-py3-none-any.whl", hash = "sha256:bf111d7138a8abe55ab48a71755673dbaa4ab87f4cff5634a4442dfec34c15f1", size = 17569 },
|
||||
{ url = "https://files.pythonhosted.org/packages/51/0b/0d7fee5919bccc1fdc1c2a7528b98f65c6f69b223a3fd8f809918c142c36/freezegun-1.5.1-py3-none-any.whl", hash = "sha256:bf111d7138a8abe55ab48a71755673dbaa4ab87f4cff5634a4442dfec34c15f1", size = 17569, upload-time = "2024-05-11T17:32:51.715Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
@ -399,9 +399,9 @@ source = { registry = "https://pypi.org/simple" }
|
|||
dependencies = [
|
||||
{ name = "wcwidth", marker = "sys_platform == 'linux'" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/da/a9/59f4354257e8350a25be1774021991fb3a99a2fb87d0c1f367592548aed3/ftfy-6.2.3.tar.gz", hash = "sha256:79b505988f29d577a58a9069afe75553a02a46e42de6091c0660cdc67812badc", size = 64165 }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/da/a9/59f4354257e8350a25be1774021991fb3a99a2fb87d0c1f367592548aed3/ftfy-6.2.3.tar.gz", hash = "sha256:79b505988f29d577a58a9069afe75553a02a46e42de6091c0660cdc67812badc", size = 64165, upload-time = "2024-08-06T01:30:46.302Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/ed/46/14d230ad057048aea7ccd2f96a80905830866d281ea90a6662a825490659/ftfy-6.2.3-py3-none-any.whl", hash = "sha256:f15761b023f3061a66207d33f0c0149ad40a8319fd16da91796363e2c049fdf8", size = 43011 },
|
||||
{ url = "https://files.pythonhosted.org/packages/ed/46/14d230ad057048aea7ccd2f96a80905830866d281ea90a6662a825490659/ftfy-6.2.3-py3-none-any.whl", hash = "sha256:f15761b023f3061a66207d33f0c0149ad40a8319fd16da91796363e2c049fdf8", size = 43011, upload-time = "2024-08-06T01:30:44.955Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
@ -411,18 +411,18 @@ source = { registry = "https://pypi.org/simple" }
|
|||
dependencies = [
|
||||
{ name = "packaging", marker = "sys_platform == 'linux'" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/34/72/9614c465dc206155d93eff0ca20d42e1e35afc533971379482de953521a4/gunicorn-23.0.0.tar.gz", hash = "sha256:f014447a0101dc57e294f6c18ca6b40227a4c90e9bdb586042628030cba004ec", size = 375031 }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/34/72/9614c465dc206155d93eff0ca20d42e1e35afc533971379482de953521a4/gunicorn-23.0.0.tar.gz", hash = "sha256:f014447a0101dc57e294f6c18ca6b40227a4c90e9bdb586042628030cba004ec", size = 375031, upload-time = "2024-08-10T20:25:27.378Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/cb/7d/6dac2a6e1eba33ee43f318edbed4ff29151a49b5d37f080aad1e6469bca4/gunicorn-23.0.0-py3-none-any.whl", hash = "sha256:ec400d38950de4dfd418cff8328b2c8faed0edb0d517d3394e457c317908ca4d", size = 85029 },
|
||||
{ url = "https://files.pythonhosted.org/packages/cb/7d/6dac2a6e1eba33ee43f318edbed4ff29151a49b5d37f080aad1e6469bca4/gunicorn-23.0.0-py3-none-any.whl", hash = "sha256:ec400d38950de4dfd418cff8328b2c8faed0edb0d517d3394e457c317908ca4d", size = 85029, upload-time = "2024-08-10T20:25:24.996Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "idna"
|
||||
version = "3.8"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/e8/ac/e349c5e6d4543326c6883ee9491e3921e0d07b55fdf3cce184b40d63e72a/idna-3.8.tar.gz", hash = "sha256:d838c2c0ed6fced7693d5e8ab8e734d5f8fda53a039c0164afb0b82e771e3603", size = 189467 }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/e8/ac/e349c5e6d4543326c6883ee9491e3921e0d07b55fdf3cce184b40d63e72a/idna-3.8.tar.gz", hash = "sha256:d838c2c0ed6fced7693d5e8ab8e734d5f8fda53a039c0164afb0b82e771e3603", size = 189467, upload-time = "2024-08-23T16:01:51.339Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/22/7e/d71db821f177828df9dea8c42ac46473366f191be53080e552e628aad991/idna-3.8-py3-none-any.whl", hash = "sha256:050b4e5baadcd44d760cedbd2b8e639f2ff89bbc7a5730fcc662954303377aac", size = 66894 },
|
||||
{ url = "https://files.pythonhosted.org/packages/22/7e/d71db821f177828df9dea8c42ac46473366f191be53080e552e628aad991/idna-3.8-py3-none-any.whl", hash = "sha256:050b4e5baadcd44d760cedbd2b8e639f2ff89bbc7a5730fcc662954303377aac", size = 66894, upload-time = "2024-08-23T16:01:49.963Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
@ -433,56 +433,56 @@ dependencies = [
|
|||
{ name = "amqp", marker = "sys_platform == 'linux'" },
|
||||
{ name = "vine", marker = "sys_platform == 'linux'" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/b6/f4/d3e57b1c351bb47ce25b16e1cf6ea05df4613dbe56e3cf32ea80df1a8b4d/kombu-5.4.0.tar.gz", hash = "sha256:ad200a8dbdaaa2bbc5f26d2ee7d707d9a1fded353a0f4bd751ce8c7d9f449c60", size = 442120 }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/b6/f4/d3e57b1c351bb47ce25b16e1cf6ea05df4613dbe56e3cf32ea80df1a8b4d/kombu-5.4.0.tar.gz", hash = "sha256:ad200a8dbdaaa2bbc5f26d2ee7d707d9a1fded353a0f4bd751ce8c7d9f449c60", size = 442120, upload-time = "2024-08-06T13:42:58.842Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/df/17/34f8ec5b9d46a1ddb598b7bf8f779c567421d05cd73742d09e549254c782/kombu-5.4.0-py3-none-any.whl", hash = "sha256:c8dd99820467610b4febbc7a9e8a0d3d7da2d35116b67184418b51cc520ea6b6", size = 200870 },
|
||||
{ url = "https://files.pythonhosted.org/packages/df/17/34f8ec5b9d46a1ddb598b7bf8f779c567421d05cd73742d09e549254c782/kombu-5.4.0-py3-none-any.whl", hash = "sha256:c8dd99820467610b4febbc7a9e8a0d3d7da2d35116b67184418b51cc520ea6b6", size = 200870, upload-time = "2024-08-06T13:42:55.53Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "lxml"
|
||||
version = "5.3.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/e7/6b/20c3a4b24751377aaa6307eb230b66701024012c29dd374999cc92983269/lxml-5.3.0.tar.gz", hash = "sha256:4e109ca30d1edec1ac60cdbe341905dc3b8f55b16855e03a54aaf59e51ec8c6f", size = 3679318 }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/e7/6b/20c3a4b24751377aaa6307eb230b66701024012c29dd374999cc92983269/lxml-5.3.0.tar.gz", hash = "sha256:4e109ca30d1edec1ac60cdbe341905dc3b8f55b16855e03a54aaf59e51ec8c6f", size = 3679318, upload-time = "2024-08-10T18:17:29.668Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/f8/fb/128dddb7f9086236bce0eeae2bfb316d138b49b159f50bc681d56c1bdd19/lxml-5.3.0-cp311-cp311-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:5b8f5db71b28b8c404956ddf79575ea77aa8b1538e8b2ef9ec877945b3f46442", size = 5095197 },
|
||||
{ url = "https://files.pythonhosted.org/packages/b4/f9/a181a8ef106e41e3086629c8bdb2d21a942f14c84a0e77452c22d6b22091/lxml-5.3.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2c3406b63232fc7e9b8783ab0b765d7c59e7c59ff96759d8ef9632fca27c7ee4", size = 4809809 },
|
||||
{ url = "https://files.pythonhosted.org/packages/25/2f/b20565e808f7f6868aacea48ddcdd7e9e9fb4c799287f21f1a6c7c2e8b71/lxml-5.3.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:2ecdd78ab768f844c7a1d4a03595038c166b609f6395e25af9b0f3f26ae1230f", size = 5407593 },
|
||||
{ url = "https://files.pythonhosted.org/packages/23/0e/caac672ec246d3189a16c4d364ed4f7d6bf856c080215382c06764058c08/lxml-5.3.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:168f2dfcfdedf611eb285efac1516c8454c8c99caf271dccda8943576b67552e", size = 4866657 },
|
||||
{ url = "https://files.pythonhosted.org/packages/67/a4/1f5fbd3f58d4069000522196b0b776a014f3feec1796da03e495cf23532d/lxml-5.3.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:aa617107a410245b8660028a7483b68e7914304a6d4882b5ff3d2d3eb5948d8c", size = 4967017 },
|
||||
{ url = "https://files.pythonhosted.org/packages/ee/73/623ecea6ca3c530dd0a4ed0d00d9702e0e85cd5624e2d5b93b005fe00abd/lxml-5.3.0-cp311-cp311-manylinux_2_28_aarch64.whl", hash = "sha256:69959bd3167b993e6e710b99051265654133a98f20cec1d9b493b931942e9c16", size = 4810730 },
|
||||
{ url = "https://files.pythonhosted.org/packages/1d/ce/fb84fb8e3c298f3a245ae3ea6221c2426f1bbaa82d10a88787412a498145/lxml-5.3.0-cp311-cp311-manylinux_2_28_ppc64le.whl", hash = "sha256:bd96517ef76c8654446fc3db9242d019a1bb5fe8b751ba414765d59f99210b79", size = 5455154 },
|
||||
{ url = "https://files.pythonhosted.org/packages/b1/72/4d1ad363748a72c7c0411c28be2b0dc7150d91e823eadad3b91a4514cbea/lxml-5.3.0-cp311-cp311-manylinux_2_28_s390x.whl", hash = "sha256:ab6dd83b970dc97c2d10bc71aa925b84788c7c05de30241b9e96f9b6d9ea3080", size = 4969416 },
|
||||
{ url = "https://files.pythonhosted.org/packages/42/07/b29571a58a3a80681722ea8ed0ba569211d9bb8531ad49b5cacf6d409185/lxml-5.3.0-cp311-cp311-manylinux_2_28_x86_64.whl", hash = "sha256:eec1bb8cdbba2925bedc887bc0609a80e599c75b12d87ae42ac23fd199445654", size = 5013672 },
|
||||
{ url = "https://files.pythonhosted.org/packages/b9/93/bde740d5a58cf04cbd38e3dd93ad1e36c2f95553bbf7d57807bc6815d926/lxml-5.3.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:6a7095eeec6f89111d03dabfe5883a1fd54da319c94e0fb104ee8f23616b572d", size = 4878644 },
|
||||
{ url = "https://files.pythonhosted.org/packages/56/b5/645c8c02721d49927c93181de4017164ec0e141413577687c3df8ff0800f/lxml-5.3.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:6f651ebd0b21ec65dfca93aa629610a0dbc13dbc13554f19b0113da2e61a4763", size = 5511531 },
|
||||
{ url = "https://files.pythonhosted.org/packages/85/3f/6a99a12d9438316f4fc86ef88c5d4c8fb674247b17f3173ecadd8346b671/lxml-5.3.0-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:f422a209d2455c56849442ae42f25dbaaba1c6c3f501d58761c619c7836642ec", size = 5402065 },
|
||||
{ url = "https://files.pythonhosted.org/packages/80/8a/df47bff6ad5ac57335bf552babfb2408f9eb680c074ec1ba412a1a6af2c5/lxml-5.3.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:62f7fdb0d1ed2065451f086519865b4c90aa19aed51081979ecd05a21eb4d1be", size = 5069775 },
|
||||
{ url = "https://files.pythonhosted.org/packages/e0/d2/e9bff9fb359226c25cda3538f664f54f2804f4b37b0d7c944639e1a51f69/lxml-5.3.0-cp312-cp312-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:874a216bf6afaf97c263b56371434e47e2c652d215788396f60477540298218f", size = 5050778 },
|
||||
{ url = "https://files.pythonhosted.org/packages/88/69/6972bfafa8cd3ddc8562b126dd607011e218e17be313a8b1b9cc5a0ee876/lxml-5.3.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:65ab5685d56914b9a2a34d67dd5488b83213d680b0c5d10b47f81da5a16b0b0e", size = 4748628 },
|
||||
{ url = "https://files.pythonhosted.org/packages/5d/ea/a6523c7c7f6dc755a6eed3d2f6d6646617cad4d3d6d8ce4ed71bfd2362c8/lxml-5.3.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:aac0bbd3e8dd2d9c45ceb82249e8bdd3ac99131a32b4d35c8af3cc9db1657179", size = 5322215 },
|
||||
{ url = "https://files.pythonhosted.org/packages/99/37/396fbd24a70f62b31d988e4500f2068c7f3fd399d2fd45257d13eab51a6f/lxml-5.3.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b369d3db3c22ed14c75ccd5af429086f166a19627e84a8fdade3f8f31426e52a", size = 4813963 },
|
||||
{ url = "https://files.pythonhosted.org/packages/09/91/e6136f17459a11ce1757df864b213efbeab7adcb2efa63efb1b846ab6723/lxml-5.3.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c24037349665434f375645fa9d1f5304800cec574d0310f618490c871fd902b3", size = 4923353 },
|
||||
{ url = "https://files.pythonhosted.org/packages/1d/7c/2eeecf87c9a1fca4f84f991067c693e67340f2b7127fc3eca8fa29d75ee3/lxml-5.3.0-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:62d172f358f33a26d6b41b28c170c63886742f5b6772a42b59b4f0fa10526cb1", size = 4740541 },
|
||||
{ url = "https://files.pythonhosted.org/packages/3b/ed/4c38ba58defca84f5f0d0ac2480fdcd99fc7ae4b28fc417c93640a6949ae/lxml-5.3.0-cp312-cp312-manylinux_2_28_ppc64le.whl", hash = "sha256:c1f794c02903c2824fccce5b20c339a1a14b114e83b306ff11b597c5f71a1c8d", size = 5346504 },
|
||||
{ url = "https://files.pythonhosted.org/packages/a5/22/bbd3995437e5745cb4c2b5d89088d70ab19d4feabf8a27a24cecb9745464/lxml-5.3.0-cp312-cp312-manylinux_2_28_s390x.whl", hash = "sha256:5d6a6972b93c426ace71e0be9a6f4b2cfae9b1baed2eed2006076a746692288c", size = 4898077 },
|
||||
{ url = "https://files.pythonhosted.org/packages/0a/6e/94537acfb5b8f18235d13186d247bca478fea5e87d224644e0fe907df976/lxml-5.3.0-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:3879cc6ce938ff4eb4900d901ed63555c778731a96365e53fadb36437a131a99", size = 4946543 },
|
||||
{ url = "https://files.pythonhosted.org/packages/8d/e8/4b15df533fe8e8d53363b23a41df9be907330e1fa28c7ca36893fad338ee/lxml-5.3.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:74068c601baff6ff021c70f0935b0c7bc528baa8ea210c202e03757c68c5a4ff", size = 4816841 },
|
||||
{ url = "https://files.pythonhosted.org/packages/1a/e7/03f390ea37d1acda50bc538feb5b2bda6745b25731e4e76ab48fae7106bf/lxml-5.3.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:ecd4ad8453ac17bc7ba3868371bffb46f628161ad0eefbd0a855d2c8c32dd81a", size = 5417341 },
|
||||
{ url = "https://files.pythonhosted.org/packages/ea/99/d1133ab4c250da85a883c3b60249d3d3e7c64f24faff494cf0fd23f91e80/lxml-5.3.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:7e2f58095acc211eb9d8b5771bf04df9ff37d6b87618d1cbf85f92399c98dae8", size = 5327539 },
|
||||
{ url = "https://files.pythonhosted.org/packages/7d/ed/e6276c8d9668028213df01f598f385b05b55a4e1b4662ee12ef05dab35aa/lxml-5.3.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:e63601ad5cd8f860aa99d109889b5ac34de571c7ee902d6812d5d9ddcc77fa7d", size = 5012542 },
|
||||
{ url = "https://files.pythonhosted.org/packages/2d/a1/b901988aa6d4ff937f2e5cfc114e4ec561901ff00660c3e56713642728da/lxml-5.3.0-cp313-cp313-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1d04f064bebdfef9240478f7a779e8c5dc32b8b7b0b2fc6a62e39b928d428e51", size = 5048331 },
|
||||
{ url = "https://files.pythonhosted.org/packages/30/0f/b2a54f48e52de578b71bbe2a2f8160672a8a5e103df3a78da53907e8c7ed/lxml-5.3.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5c2fb570d7823c2bbaf8b419ba6e5662137f8166e364a8b2b91051a1fb40ab8b", size = 4744835 },
|
||||
{ url = "https://files.pythonhosted.org/packages/82/9d/b000c15538b60934589e83826ecbc437a1586488d7c13f8ee5ff1f79a9b8/lxml-5.3.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0c120f43553ec759f8de1fee2f4794452b0946773299d44c36bfe18e83caf002", size = 5316649 },
|
||||
{ url = "https://files.pythonhosted.org/packages/e3/ee/ffbb9eaff5e541922611d2c56b175c45893d1c0b8b11e5a497708a6a3b3b/lxml-5.3.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:562e7494778a69086f0312ec9689f6b6ac1c6b65670ed7d0267e49f57ffa08c4", size = 4812046 },
|
||||
{ url = "https://files.pythonhosted.org/packages/15/ff/7ff89d567485c7b943cdac316087f16b2399a8b997007ed352a1248397e5/lxml-5.3.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:423b121f7e6fa514ba0c7918e56955a1d4470ed35faa03e3d9f0e3baa4c7e492", size = 4918597 },
|
||||
{ url = "https://files.pythonhosted.org/packages/c6/a3/535b6ed8c048412ff51268bdf4bf1cf052a37aa7e31d2e6518038a883b29/lxml-5.3.0-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:c00f323cc00576df6165cc9d21a4c21285fa6b9989c5c39830c3903dc4303ef3", size = 4738071 },
|
||||
{ url = "https://files.pythonhosted.org/packages/7a/8f/cbbfa59cb4d4fd677fe183725a76d8c956495d7a3c7f111ab8f5e13d2e83/lxml-5.3.0-cp313-cp313-manylinux_2_28_ppc64le.whl", hash = "sha256:1fdc9fae8dd4c763e8a31e7630afef517eab9f5d5d31a278df087f307bf601f4", size = 5342213 },
|
||||
{ url = "https://files.pythonhosted.org/packages/5c/fb/db4c10dd9958d4b52e34d1d1f7c1f434422aeaf6ae2bbaaff2264351d944/lxml-5.3.0-cp313-cp313-manylinux_2_28_s390x.whl", hash = "sha256:658f2aa69d31e09699705949b5fc4719cbecbd4a97f9656a232e7d6c7be1a367", size = 4893749 },
|
||||
{ url = "https://files.pythonhosted.org/packages/f2/38/bb4581c143957c47740de18a3281a0cab7722390a77cc6e610e8ebf2d736/lxml-5.3.0-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:1473427aff3d66a3fa2199004c3e601e6c4500ab86696edffdbc84954c72d832", size = 4945901 },
|
||||
{ url = "https://files.pythonhosted.org/packages/fc/d5/18b7de4960c731e98037bd48fa9f8e6e8f2558e6fbca4303d9b14d21ef3b/lxml-5.3.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a87de7dd873bf9a792bf1e58b1c3887b9264036629a5bf2d2e6579fe8e73edff", size = 4815447 },
|
||||
{ url = "https://files.pythonhosted.org/packages/97/a8/cd51ceaad6eb849246559a8ef60ae55065a3df550fc5fcd27014361c1bab/lxml-5.3.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:0d7b36afa46c97875303a94e8f3ad932bf78bace9e18e603f2085b652422edcd", size = 5411186 },
|
||||
{ url = "https://files.pythonhosted.org/packages/89/c3/1e3dabab519481ed7b1fdcba21dcfb8832f57000733ef0e71cf6d09a5e03/lxml-5.3.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:cf120cce539453ae086eacc0130a324e7026113510efa83ab42ef3fcfccac7fb", size = 5324481 },
|
||||
{ url = "https://files.pythonhosted.org/packages/b6/17/71e9984cf0570cd202ac0a1c9ed5c1b8889b0fc8dc736f5ef0ffb181c284/lxml-5.3.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:df5c7333167b9674aa8ae1d4008fa4bc17a313cc490b2cca27838bbdcc6bb15b", size = 5011053 },
|
||||
{ url = "https://files.pythonhosted.org/packages/f8/fb/128dddb7f9086236bce0eeae2bfb316d138b49b159f50bc681d56c1bdd19/lxml-5.3.0-cp311-cp311-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:5b8f5db71b28b8c404956ddf79575ea77aa8b1538e8b2ef9ec877945b3f46442", size = 5095197, upload-time = "2024-08-10T18:10:16.825Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b4/f9/a181a8ef106e41e3086629c8bdb2d21a942f14c84a0e77452c22d6b22091/lxml-5.3.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2c3406b63232fc7e9b8783ab0b765d7c59e7c59ff96759d8ef9632fca27c7ee4", size = 4809809, upload-time = "2024-08-10T18:10:20.046Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/25/2f/b20565e808f7f6868aacea48ddcdd7e9e9fb4c799287f21f1a6c7c2e8b71/lxml-5.3.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:2ecdd78ab768f844c7a1d4a03595038c166b609f6395e25af9b0f3f26ae1230f", size = 5407593, upload-time = "2024-08-10T18:10:23.641Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/23/0e/caac672ec246d3189a16c4d364ed4f7d6bf856c080215382c06764058c08/lxml-5.3.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:168f2dfcfdedf611eb285efac1516c8454c8c99caf271dccda8943576b67552e", size = 4866657, upload-time = "2024-08-10T18:10:26.528Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/67/a4/1f5fbd3f58d4069000522196b0b776a014f3feec1796da03e495cf23532d/lxml-5.3.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:aa617107a410245b8660028a7483b68e7914304a6d4882b5ff3d2d3eb5948d8c", size = 4967017, upload-time = "2024-08-10T18:10:29.639Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ee/73/623ecea6ca3c530dd0a4ed0d00d9702e0e85cd5624e2d5b93b005fe00abd/lxml-5.3.0-cp311-cp311-manylinux_2_28_aarch64.whl", hash = "sha256:69959bd3167b993e6e710b99051265654133a98f20cec1d9b493b931942e9c16", size = 4810730, upload-time = "2024-08-10T18:10:33.387Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1d/ce/fb84fb8e3c298f3a245ae3ea6221c2426f1bbaa82d10a88787412a498145/lxml-5.3.0-cp311-cp311-manylinux_2_28_ppc64le.whl", hash = "sha256:bd96517ef76c8654446fc3db9242d019a1bb5fe8b751ba414765d59f99210b79", size = 5455154, upload-time = "2024-08-10T18:10:36.897Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b1/72/4d1ad363748a72c7c0411c28be2b0dc7150d91e823eadad3b91a4514cbea/lxml-5.3.0-cp311-cp311-manylinux_2_28_s390x.whl", hash = "sha256:ab6dd83b970dc97c2d10bc71aa925b84788c7c05de30241b9e96f9b6d9ea3080", size = 4969416, upload-time = "2024-08-10T18:10:40.331Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/42/07/b29571a58a3a80681722ea8ed0ba569211d9bb8531ad49b5cacf6d409185/lxml-5.3.0-cp311-cp311-manylinux_2_28_x86_64.whl", hash = "sha256:eec1bb8cdbba2925bedc887bc0609a80e599c75b12d87ae42ac23fd199445654", size = 5013672, upload-time = "2024-08-10T18:10:43.768Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b9/93/bde740d5a58cf04cbd38e3dd93ad1e36c2f95553bbf7d57807bc6815d926/lxml-5.3.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:6a7095eeec6f89111d03dabfe5883a1fd54da319c94e0fb104ee8f23616b572d", size = 4878644, upload-time = "2024-08-10T18:10:47.901Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/56/b5/645c8c02721d49927c93181de4017164ec0e141413577687c3df8ff0800f/lxml-5.3.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:6f651ebd0b21ec65dfca93aa629610a0dbc13dbc13554f19b0113da2e61a4763", size = 5511531, upload-time = "2024-08-10T18:10:51.581Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/85/3f/6a99a12d9438316f4fc86ef88c5d4c8fb674247b17f3173ecadd8346b671/lxml-5.3.0-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:f422a209d2455c56849442ae42f25dbaaba1c6c3f501d58761c619c7836642ec", size = 5402065, upload-time = "2024-08-10T18:10:54.841Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/80/8a/df47bff6ad5ac57335bf552babfb2408f9eb680c074ec1ba412a1a6af2c5/lxml-5.3.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:62f7fdb0d1ed2065451f086519865b4c90aa19aed51081979ecd05a21eb4d1be", size = 5069775, upload-time = "2024-08-10T18:10:57.808Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e0/d2/e9bff9fb359226c25cda3538f664f54f2804f4b37b0d7c944639e1a51f69/lxml-5.3.0-cp312-cp312-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:874a216bf6afaf97c263b56371434e47e2c652d215788396f60477540298218f", size = 5050778, upload-time = "2024-08-10T18:11:16.233Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/88/69/6972bfafa8cd3ddc8562b126dd607011e218e17be313a8b1b9cc5a0ee876/lxml-5.3.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:65ab5685d56914b9a2a34d67dd5488b83213d680b0c5d10b47f81da5a16b0b0e", size = 4748628, upload-time = "2024-08-10T18:11:19.507Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5d/ea/a6523c7c7f6dc755a6eed3d2f6d6646617cad4d3d6d8ce4ed71bfd2362c8/lxml-5.3.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:aac0bbd3e8dd2d9c45ceb82249e8bdd3ac99131a32b4d35c8af3cc9db1657179", size = 5322215, upload-time = "2024-08-10T18:11:23.708Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/99/37/396fbd24a70f62b31d988e4500f2068c7f3fd399d2fd45257d13eab51a6f/lxml-5.3.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b369d3db3c22ed14c75ccd5af429086f166a19627e84a8fdade3f8f31426e52a", size = 4813963, upload-time = "2024-08-10T18:11:26.997Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/09/91/e6136f17459a11ce1757df864b213efbeab7adcb2efa63efb1b846ab6723/lxml-5.3.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c24037349665434f375645fa9d1f5304800cec574d0310f618490c871fd902b3", size = 4923353, upload-time = "2024-08-10T18:11:30.478Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1d/7c/2eeecf87c9a1fca4f84f991067c693e67340f2b7127fc3eca8fa29d75ee3/lxml-5.3.0-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:62d172f358f33a26d6b41b28c170c63886742f5b6772a42b59b4f0fa10526cb1", size = 4740541, upload-time = "2024-08-10T18:11:34.344Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3b/ed/4c38ba58defca84f5f0d0ac2480fdcd99fc7ae4b28fc417c93640a6949ae/lxml-5.3.0-cp312-cp312-manylinux_2_28_ppc64le.whl", hash = "sha256:c1f794c02903c2824fccce5b20c339a1a14b114e83b306ff11b597c5f71a1c8d", size = 5346504, upload-time = "2024-08-10T18:11:37.595Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a5/22/bbd3995437e5745cb4c2b5d89088d70ab19d4feabf8a27a24cecb9745464/lxml-5.3.0-cp312-cp312-manylinux_2_28_s390x.whl", hash = "sha256:5d6a6972b93c426ace71e0be9a6f4b2cfae9b1baed2eed2006076a746692288c", size = 4898077, upload-time = "2024-08-10T18:11:40.867Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0a/6e/94537acfb5b8f18235d13186d247bca478fea5e87d224644e0fe907df976/lxml-5.3.0-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:3879cc6ce938ff4eb4900d901ed63555c778731a96365e53fadb36437a131a99", size = 4946543, upload-time = "2024-08-10T18:11:44.954Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8d/e8/4b15df533fe8e8d53363b23a41df9be907330e1fa28c7ca36893fad338ee/lxml-5.3.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:74068c601baff6ff021c70f0935b0c7bc528baa8ea210c202e03757c68c5a4ff", size = 4816841, upload-time = "2024-08-10T18:11:49.046Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1a/e7/03f390ea37d1acda50bc538feb5b2bda6745b25731e4e76ab48fae7106bf/lxml-5.3.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:ecd4ad8453ac17bc7ba3868371bffb46f628161ad0eefbd0a855d2c8c32dd81a", size = 5417341, upload-time = "2024-08-10T18:11:52.295Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ea/99/d1133ab4c250da85a883c3b60249d3d3e7c64f24faff494cf0fd23f91e80/lxml-5.3.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:7e2f58095acc211eb9d8b5771bf04df9ff37d6b87618d1cbf85f92399c98dae8", size = 5327539, upload-time = "2024-08-10T18:11:55.98Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7d/ed/e6276c8d9668028213df01f598f385b05b55a4e1b4662ee12ef05dab35aa/lxml-5.3.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:e63601ad5cd8f860aa99d109889b5ac34de571c7ee902d6812d5d9ddcc77fa7d", size = 5012542, upload-time = "2024-08-10T18:11:59.351Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2d/a1/b901988aa6d4ff937f2e5cfc114e4ec561901ff00660c3e56713642728da/lxml-5.3.0-cp313-cp313-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1d04f064bebdfef9240478f7a779e8c5dc32b8b7b0b2fc6a62e39b928d428e51", size = 5048331, upload-time = "2024-08-10T18:12:17.204Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/30/0f/b2a54f48e52de578b71bbe2a2f8160672a8a5e103df3a78da53907e8c7ed/lxml-5.3.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5c2fb570d7823c2bbaf8b419ba6e5662137f8166e364a8b2b91051a1fb40ab8b", size = 4744835, upload-time = "2024-08-10T18:12:21.172Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/82/9d/b000c15538b60934589e83826ecbc437a1586488d7c13f8ee5ff1f79a9b8/lxml-5.3.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0c120f43553ec759f8de1fee2f4794452b0946773299d44c36bfe18e83caf002", size = 5316649, upload-time = "2024-08-10T18:12:24.897Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e3/ee/ffbb9eaff5e541922611d2c56b175c45893d1c0b8b11e5a497708a6a3b3b/lxml-5.3.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:562e7494778a69086f0312ec9689f6b6ac1c6b65670ed7d0267e49f57ffa08c4", size = 4812046, upload-time = "2024-08-10T18:12:29.028Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/15/ff/7ff89d567485c7b943cdac316087f16b2399a8b997007ed352a1248397e5/lxml-5.3.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:423b121f7e6fa514ba0c7918e56955a1d4470ed35faa03e3d9f0e3baa4c7e492", size = 4918597, upload-time = "2024-08-10T18:12:32.278Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c6/a3/535b6ed8c048412ff51268bdf4bf1cf052a37aa7e31d2e6518038a883b29/lxml-5.3.0-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:c00f323cc00576df6165cc9d21a4c21285fa6b9989c5c39830c3903dc4303ef3", size = 4738071, upload-time = "2024-08-10T18:12:35.407Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7a/8f/cbbfa59cb4d4fd677fe183725a76d8c956495d7a3c7f111ab8f5e13d2e83/lxml-5.3.0-cp313-cp313-manylinux_2_28_ppc64le.whl", hash = "sha256:1fdc9fae8dd4c763e8a31e7630afef517eab9f5d5d31a278df087f307bf601f4", size = 5342213, upload-time = "2024-08-10T18:12:38.73Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5c/fb/db4c10dd9958d4b52e34d1d1f7c1f434422aeaf6ae2bbaaff2264351d944/lxml-5.3.0-cp313-cp313-manylinux_2_28_s390x.whl", hash = "sha256:658f2aa69d31e09699705949b5fc4719cbecbd4a97f9656a232e7d6c7be1a367", size = 4893749, upload-time = "2024-08-10T18:12:42.606Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f2/38/bb4581c143957c47740de18a3281a0cab7722390a77cc6e610e8ebf2d736/lxml-5.3.0-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:1473427aff3d66a3fa2199004c3e601e6c4500ab86696edffdbc84954c72d832", size = 4945901, upload-time = "2024-08-10T18:12:45.944Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fc/d5/18b7de4960c731e98037bd48fa9f8e6e8f2558e6fbca4303d9b14d21ef3b/lxml-5.3.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a87de7dd873bf9a792bf1e58b1c3887b9264036629a5bf2d2e6579fe8e73edff", size = 4815447, upload-time = "2024-08-10T18:12:49.051Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/97/a8/cd51ceaad6eb849246559a8ef60ae55065a3df550fc5fcd27014361c1bab/lxml-5.3.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:0d7b36afa46c97875303a94e8f3ad932bf78bace9e18e603f2085b652422edcd", size = 5411186, upload-time = "2024-08-10T18:12:52.388Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/89/c3/1e3dabab519481ed7b1fdcba21dcfb8832f57000733ef0e71cf6d09a5e03/lxml-5.3.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:cf120cce539453ae086eacc0130a324e7026113510efa83ab42ef3fcfccac7fb", size = 5324481, upload-time = "2024-08-10T18:12:56.021Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b6/17/71e9984cf0570cd202ac0a1c9ed5c1b8889b0fc8dc736f5ef0ffb181c284/lxml-5.3.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:df5c7333167b9674aa8ae1d4008fa4bc17a313cc490b2cca27838bbdcc6bb15b", size = 5011053, upload-time = "2024-08-10T18:12:59.714Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
@ -501,7 +501,7 @@ dependencies = [
|
|||
{ name = "feedparser", marker = "sys_platform == 'linux'" },
|
||||
{ name = "ftfy", marker = "sys_platform == 'linux'" },
|
||||
{ name = "lxml", marker = "sys_platform == 'linux'" },
|
||||
{ name = "psycopg", marker = "sys_platform == 'linux'" },
|
||||
{ name = "psycopg", extra = ["binary"], marker = "sys_platform == 'linux'" },
|
||||
{ name = "pymemcache", marker = "sys_platform == 'linux'" },
|
||||
{ name = "python-dotenv", marker = "sys_platform == 'linux'" },
|
||||
{ name = "requests", marker = "sys_platform == 'linux'" },
|
||||
|
|
@ -543,7 +543,7 @@ requires-dist = [
|
|||
{ name = "feedparser" },
|
||||
{ name = "ftfy", specifier = "~=6.2" },
|
||||
{ name = "lxml" },
|
||||
{ name = "psycopg" },
|
||||
{ name = "psycopg", extras = ["binary"] },
|
||||
{ name = "pymemcache" },
|
||||
{ name = "python-dotenv", specifier = "~=1.0.1" },
|
||||
{ name = "requests" },
|
||||
|
|
@ -569,9 +569,9 @@ test-tools = [
|
|||
name = "packaging"
|
||||
version = "24.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/51/65/50db4dda066951078f0a96cf12f4b9ada6e4b811516bf0262c0f4f7064d4/packaging-24.1.tar.gz", hash = "sha256:026ed72c8ed3fcce5bf8950572258698927fd1dbda10a5e981cdf0ac37f4f002", size = 148788 }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/51/65/50db4dda066951078f0a96cf12f4b9ada6e4b811516bf0262c0f4f7064d4/packaging-24.1.tar.gz", hash = "sha256:026ed72c8ed3fcce5bf8950572258698927fd1dbda10a5e981cdf0ac37f4f002", size = 148788, upload-time = "2024-06-09T23:19:24.956Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/08/aa/cc0199a5f0ad350994d660967a8efb233fe0416e4639146c089643407ce6/packaging-24.1-py3-none-any.whl", hash = "sha256:5b8f2217dbdbd2f7f384c41c628544e6d52f2d0f53c6d0c3ea61aa5d1d7ff124", size = 53985 },
|
||||
{ url = "https://files.pythonhosted.org/packages/08/aa/cc0199a5f0ad350994d660967a8efb233fe0416e4639146c089643407ce6/packaging-24.1-py3-none-any.whl", hash = "sha256:5b8f2217dbdbd2f7f384c41c628544e6d52f2d0f53c6d0c3ea61aa5d1d7ff124", size = 53985, upload-time = "2024-06-09T23:19:21.909Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
@ -581,9 +581,9 @@ source = { registry = "https://pypi.org/simple" }
|
|||
dependencies = [
|
||||
{ name = "wcwidth", marker = "sys_platform == 'linux'" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/47/6d/0279b119dafc74c1220420028d490c4399b790fc1256998666e3a341879f/prompt_toolkit-3.0.47.tar.gz", hash = "sha256:1e1b29cb58080b1e69f207c893a1a7bf16d127a5c30c9d17a25a5d77792e5360", size = 425859 }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/47/6d/0279b119dafc74c1220420028d490c4399b790fc1256998666e3a341879f/prompt_toolkit-3.0.47.tar.gz", hash = "sha256:1e1b29cb58080b1e69f207c893a1a7bf16d127a5c30c9d17a25a5d77792e5360", size = 425859, upload-time = "2024-06-10T11:02:14.045Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/e8/23/22750c4b768f09386d1c3cc4337953e8936f48a888fa6dddfb669b2c9088/prompt_toolkit-3.0.47-py3-none-any.whl", hash = "sha256:0d7bfa67001d5e39d02c224b663abc33687405033a8c422d0d675a5a13361d10", size = 386411 },
|
||||
{ url = "https://files.pythonhosted.org/packages/e8/23/22750c4b768f09386d1c3cc4337953e8936f48a888fa6dddfb669b2c9088/prompt_toolkit-3.0.47-py3-none-any.whl", hash = "sha256:0d7bfa67001d5e39d02c224b663abc33687405033a8c422d0d675a5a13361d10", size = 386411, upload-time = "2024-06-10T11:02:10.477Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
@ -593,18 +593,46 @@ source = { registry = "https://pypi.org/simple" }
|
|||
dependencies = [
|
||||
{ name = "typing-extensions", marker = "sys_platform == 'linux'" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/ff/8e/f176997fd790d3dce9fa0ca695391beaeee39af7ecd6d426c4c063cf6744/psycopg-3.2.1.tar.gz", hash = "sha256:dc8da6dc8729dacacda3cc2f17d2c9397a70a66cf0d2b69c91065d60d5f00cb7", size = 155313 }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/ff/8e/f176997fd790d3dce9fa0ca695391beaeee39af7ecd6d426c4c063cf6744/psycopg-3.2.1.tar.gz", hash = "sha256:dc8da6dc8729dacacda3cc2f17d2c9397a70a66cf0d2b69c91065d60d5f00cb7", size = 155313, upload-time = "2024-07-01T03:35:50.314Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/8a/0e/0f755db36f47f96464463385552f8f132a981731356837c9a30a11ab2d35/psycopg-3.2.1-py3-none-any.whl", hash = "sha256:ece385fb413a37db332f97c49208b36cf030ff02b199d7635ed2fbd378724175", size = 197743 },
|
||||
{ url = "https://files.pythonhosted.org/packages/8a/0e/0f755db36f47f96464463385552f8f132a981731356837c9a30a11ab2d35/psycopg-3.2.1-py3-none-any.whl", hash = "sha256:ece385fb413a37db332f97c49208b36cf030ff02b199d7635ed2fbd378724175", size = 197743, upload-time = "2024-07-01T03:30:14.942Z" },
|
||||
]
|
||||
|
||||
[package.optional-dependencies]
|
||||
binary = [
|
||||
{ name = "psycopg-binary", marker = "implementation_name != 'pypy' and sys_platform == 'linux'" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "psycopg-binary"
|
||||
version = "3.2.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/9d/2a/d45ff1f4b8d5b334695f3f5a68c722dbf483b65348f2e2639cf2f45c7b73/psycopg_binary-3.2.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:40bb515d042f6a345714ec0403df68ccf13f73b05e567837d80c886c7c9d3805", size = 4464849, upload-time = "2024-07-01T03:31:47.787Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8c/ce/60562887f1363747ce2e074841548f96b433dd50e78d822c88e7ad6ec817/psycopg_binary-3.2.1-cp311-cp311-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:6418712ba63cebb0c88c050b3997185b0ef54173b36568522d5634ac06153040", size = 4263085, upload-time = "2024-07-01T03:31:55.249Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2e/4f/af3cb85b967d2616c9c4e2bea9e865c8d0c38fc83ce5db1ef050ceba2bea/psycopg_binary-3.2.1-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:101472468d59c74bb8565fab603e032803fd533d16be4b2d13da1bab8deb32a3", size = 4514411, upload-time = "2024-07-01T03:31:59.738Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1d/00/685055d15f70e57d24cffe59021d53d428cdd7126b87442b5b07c9ffd222/psycopg_binary-3.2.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:aa3931f308ab4a479d0ee22dc04bea867a6365cac0172e5ddcba359da043854b", size = 4207636, upload-time = "2024-07-01T03:32:04.407Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/72/9f/d6f6c8f60c4ebcc270efda17ab22110b24934f610dc7d5d3e2dc1e9eecbc/psycopg_binary-3.2.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:dc314a47d44fe1a8069b075a64abffad347a3a1d8652fed1bab5d3baea37acb2", size = 3132484, upload-time = "2024-07-01T03:32:09.851Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8e/e8/742cca374ab3725606f79a9b3b2429bba73917e1d14d52ba39d83dec0a3c/psycopg_binary-3.2.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:cc304a46be1e291031148d9d95c12451ffe783ff0cc72f18e2cc7ec43cdb8c68", size = 3111128, upload-time = "2024-07-01T03:32:16.741Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/61/a9/046536ef56a785e12c72c2a2507058473889bd7d625fbce142f1a1662bc2/psycopg_binary-3.2.1-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:6f9e13600647087df5928875559f0eb8f496f53e6278b7da9511b4b3d0aff960", size = 3213088, upload-time = "2024-07-01T03:32:22.139Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2d/40/a988739a5d8e72c553a44abba71217c601400e5164a874916e2aa4285139/psycopg_binary-3.2.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:b140182830c76c74d17eba27df3755a46442ce8d4fb299e7f1cf2f74a87c877b", size = 3252404, upload-time = "2024-07-01T03:32:29.682Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3f/9a/28da916a65fb40fb3e1a97e1ae0a26860d8c1265c6e9766bd6c47abc437b/psycopg_binary-3.2.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2e8213bf50af073b1aa8dc3cff123bfeedac86332a16c1b7274910bc88a847c7", size = 4443593, upload-time = "2024-07-01T03:32:54.341Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b0/9a/3dc1237a2ef3344b347af79e1aad2a60277cfafa2846f54cb13e1cd8c528/psycopg_binary-3.2.1-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:74d623261655a169bc84a9669890975c229f2fa6e19a7f2d10a77675dcf1a707", size = 4247005, upload-time = "2024-07-01T03:33:01.162Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d0/a9/06491cb0338b6f0868d349d2a526586dc165e508b64daa2ff45f9db7ba4b/psycopg_binary-3.2.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:42781ba94e8842ee98bca5a7d0c44cc9d067500fedca2d6a90fa3609b6d16b42", size = 4484179, upload-time = "2024-07-01T03:33:09.385Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4b/5f/b1116467dd18b4efc1aa7f03c96da751724a43c6a630979c61f60a9fbe5f/psycopg_binary-3.2.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:33e6669091d09f8ba36e10ce678a6d9916e110446236a9b92346464a3565635e", size = 4186490, upload-time = "2024-07-01T03:33:15.295Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a4/87/6092d1701d36c5aeb74c35cb54266fd44ee0f7711cafa4c0bffd873bdb61/psycopg_binary-3.2.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:b09e8a576a2ac69d695032ee76f31e03b30781828b5dd6d18c6a009e5a3d1c35", size = 3109385, upload-time = "2024-07-01T03:33:20.174Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/62/61/4ad7e29d09202478b6f568fff19efa978a4f2c25cb5efcd73544a4ee8be7/psycopg_binary-3.2.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:8f28ff0cb9f1defdc4a6f8c958bf6787274247e7dfeca811f6e2f56602695fb1", size = 3094397, upload-time = "2024-07-01T03:33:25.285Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b7/dd/0ae42c64bf524d1fcf9bf861ab09d331e693ae00e527ba08131b2d3729a3/psycopg_binary-3.2.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:4c84fcac8a3a3479ac14673095cc4e1fdba2935499f72c436785ac679bec0d1a", size = 3184097, upload-time = "2024-07-01T03:33:31.268Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/dd/f0/09329ebb0cd03e2ee5786fc9914ac904f4965b78627f15826f8258fde734/psycopg_binary-3.2.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:950fd666ec9e9fe6a8eeb2b5a8f17301790e518953730ad44d715b59ffdbc67f", size = 3228517, upload-time = "2024-07-01T03:33:37.824Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pymemcache"
|
||||
version = "4.0.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/d9/b6/4541b664aeaad025dfb8e851dcddf8e25ab22607e674dd2b562ea3e3586f/pymemcache-4.0.0.tar.gz", hash = "sha256:27bf9bd1bbc1e20f83633208620d56de50f14185055e49504f4f5e94e94aff94", size = 70176 }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/d9/b6/4541b664aeaad025dfb8e851dcddf8e25ab22607e674dd2b562ea3e3586f/pymemcache-4.0.0.tar.gz", hash = "sha256:27bf9bd1bbc1e20f83633208620d56de50f14185055e49504f4f5e94e94aff94", size = 70176, upload-time = "2022-10-17T16:53:07.726Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/41/ba/2f7b22d8135b51c4fefb041461f8431e1908778e6539ff5af6eeaaee367a/pymemcache-4.0.0-py2.py3-none-any.whl", hash = "sha256:f507bc20e0dc8d562f8df9d872107a278df049fa496805c1431b926f3ddd0eab", size = 60772 },
|
||||
{ url = "https://files.pythonhosted.org/packages/41/ba/2f7b22d8135b51c4fefb041461f8431e1908778e6539ff5af6eeaaee367a/pymemcache-4.0.0-py2.py3-none-any.whl", hash = "sha256:f507bc20e0dc8d562f8df9d872107a278df049fa496805c1431b926f3ddd0eab", size = 60772, upload-time = "2022-10-17T16:53:04.388Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
@ -614,9 +642,9 @@ source = { registry = "https://pypi.org/simple" }
|
|||
dependencies = [
|
||||
{ name = "python-dateutil", marker = "sys_platform == 'linux'" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/e2/f0/25775565c133d4e29eeb607bf9ddba0075f3af36041a1844dd207881047f/python_crontab-3.2.0.tar.gz", hash = "sha256:40067d1dd39ade3460b2ad8557c7651514cd3851deffff61c5c60e1227c5c36b", size = 57001 }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/e2/f0/25775565c133d4e29eeb607bf9ddba0075f3af36041a1844dd207881047f/python_crontab-3.2.0.tar.gz", hash = "sha256:40067d1dd39ade3460b2ad8557c7651514cd3851deffff61c5c60e1227c5c36b", size = 57001, upload-time = "2024-07-01T22:29:10.903Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/3b/91/832fb3b3a1f62bd2ab4924f6be0c7736c9bc4f84d3b153b74efcf6d4e4a1/python_crontab-3.2.0-py3-none-any.whl", hash = "sha256:82cb9b6a312d41ff66fd3caf3eed7115c28c195bfb50711bc2b4b9592feb9fe5", size = 27351 },
|
||||
{ url = "https://files.pythonhosted.org/packages/3b/91/832fb3b3a1f62bd2ab4924f6be0c7736c9bc4f84d3b153b74efcf6d4e4a1/python_crontab-3.2.0-py3-none-any.whl", hash = "sha256:82cb9b6a312d41ff66fd3caf3eed7115c28c195bfb50711bc2b4b9592feb9fe5", size = 27351, upload-time = "2024-07-01T22:29:08.549Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
@ -626,18 +654,18 @@ source = { registry = "https://pypi.org/simple" }
|
|||
dependencies = [
|
||||
{ name = "six", marker = "sys_platform == 'linux'" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/66/c0/0c8b6ad9f17a802ee498c46e004a0eb49bc148f2fd230864601a86dcf6db/python-dateutil-2.9.0.post0.tar.gz", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3", size = 342432 }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/66/c0/0c8b6ad9f17a802ee498c46e004a0eb49bc148f2fd230864601a86dcf6db/python-dateutil-2.9.0.post0.tar.gz", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3", size = 342432, upload-time = "2024-03-01T18:36:20.211Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/ec/57/56b9bcc3c9c6a792fcbaf139543cee77261f3651ca9da0c93f5c1221264b/python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427", size = 229892 },
|
||||
{ url = "https://files.pythonhosted.org/packages/ec/57/56b9bcc3c9c6a792fcbaf139543cee77261f3651ca9da0c93f5c1221264b/python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427", size = 229892, upload-time = "2024-03-01T18:36:18.57Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "python-dotenv"
|
||||
version = "1.0.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/bc/57/e84d88dfe0aec03b7a2d4327012c1627ab5f03652216c63d49846d7a6c58/python-dotenv-1.0.1.tar.gz", hash = "sha256:e324ee90a023d808f1959c46bcbc04446a10ced277783dc6ee09987c37ec10ca", size = 39115 }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/bc/57/e84d88dfe0aec03b7a2d4327012c1627ab5f03652216c63d49846d7a6c58/python-dotenv-1.0.1.tar.gz", hash = "sha256:e324ee90a023d808f1959c46bcbc04446a10ced277783dc6ee09987c37ec10ca", size = 39115, upload-time = "2024-01-23T06:33:00.505Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/6a/3e/b68c118422ec867fa7ab88444e1274aa40681c606d59ac27de5a5588f082/python_dotenv-1.0.1-py3-none-any.whl", hash = "sha256:f7b63ef50f1b690dddf550d03497b66d609393b40b564ed0d674909a68ebf16a", size = 19863 },
|
||||
{ url = "https://files.pythonhosted.org/packages/6a/3e/b68c118422ec867fa7ab88444e1274aa40681c606d59ac27de5a5588f082/python_dotenv-1.0.1-py3-none-any.whl", hash = "sha256:f7b63ef50f1b690dddf550d03497b66d609393b40b564ed0d674909a68ebf16a", size = 19863, upload-time = "2024-01-23T06:32:58.246Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
@ -650,29 +678,29 @@ dependencies = [
|
|||
{ name = "idna", marker = "sys_platform == 'linux'" },
|
||||
{ name = "urllib3", marker = "sys_platform == 'linux'" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/63/70/2bf7780ad2d390a8d301ad0b550f1581eadbd9a20f896afe06353c2a2913/requests-2.32.3.tar.gz", hash = "sha256:55365417734eb18255590a9ff9eb97e9e1da868d4ccd6402399eaf68af20a760", size = 131218 }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/63/70/2bf7780ad2d390a8d301ad0b550f1581eadbd9a20f896afe06353c2a2913/requests-2.32.3.tar.gz", hash = "sha256:55365417734eb18255590a9ff9eb97e9e1da868d4ccd6402399eaf68af20a760", size = 131218, upload-time = "2024-05-29T15:37:49.536Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/f9/9b/335f9764261e915ed497fcdeb11df5dfd6f7bf257d4a6a2a686d80da4d54/requests-2.32.3-py3-none-any.whl", hash = "sha256:70761cfe03c773ceb22aa2f671b4757976145175cdfca038c02654d061d6dcc6", size = 64928 },
|
||||
{ url = "https://files.pythonhosted.org/packages/f9/9b/335f9764261e915ed497fcdeb11df5dfd6f7bf257d4a6a2a686d80da4d54/requests-2.32.3-py3-none-any.whl", hash = "sha256:70761cfe03c773ceb22aa2f671b4757976145175cdfca038c02654d061d6dcc6", size = 64928, upload-time = "2024-05-29T15:37:47.027Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ruff"
|
||||
version = "0.6.3"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/5d/f9/0b32e5d1c6f957df49398cd882a011e9488fcbca0d6acfeeea50ccd37a4d/ruff-0.6.3.tar.gz", hash = "sha256:183b99e9edd1ef63be34a3b51fee0a9f4ab95add123dbf89a71f7b1f0c991983", size = 2463514 }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/5d/f9/0b32e5d1c6f957df49398cd882a011e9488fcbca0d6acfeeea50ccd37a4d/ruff-0.6.3.tar.gz", hash = "sha256:183b99e9edd1ef63be34a3b51fee0a9f4ab95add123dbf89a71f7b1f0c991983", size = 2463514, upload-time = "2024-08-29T15:16:41.015Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/72/68/1da6a1e39a03a229ea57c511691d6225072759cc7764206c3f0989521194/ruff-0.6.3-py3-none-linux_armv6l.whl", hash = "sha256:97f58fda4e309382ad30ede7f30e2791d70dd29ea17f41970119f55bdb7a45c3", size = 9696928 },
|
||||
{ url = "https://files.pythonhosted.org/packages/a0/20/b0bcb29d4ee437f3567b73b6905c034e2e94d29b9b826c66daecc1cf6388/ruff-0.6.3-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bddfbb8d63c460f4b4128b6a506e7052bad4d6f3ff607ebbb41b0aa19c2770d1", size = 10108892 },
|
||||
{ url = "https://files.pythonhosted.org/packages/9c/e3/211bc759f424e8823a9937e0f678695ca02113c621dfde1fa756f9f26f6d/ruff-0.6.3-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ced3eeb44df75353e08ab3b6a9e113b5f3f996bea48d4f7c027bc528ba87b672", size = 9476471 },
|
||||
{ url = "https://files.pythonhosted.org/packages/b2/a3/2ec35a2d7a554364864206f0e46812b92a074ad8a014b923d821ead532aa/ruff-0.6.3-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:47021dff5445d549be954eb275156dfd7c37222acc1e8014311badcb9b4ec8c1", size = 10294802 },
|
||||
{ url = "https://files.pythonhosted.org/packages/03/8b/56ef687b3489c88886dea48c78fb4969b6b65f18007d0ac450070edd1f58/ruff-0.6.3-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:7d7bd20dc07cebd68cc8bc7b3f5ada6d637f42d947c85264f94b0d1cd9d87384", size = 11022372 },
|
||||
{ url = "https://files.pythonhosted.org/packages/a5/21/327d147feb442adb88975e81e2263102789eba9ad2afa102c661912a482f/ruff-0.6.3-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:500f166d03fc6d0e61c8e40a3ff853fa8a43d938f5d14c183c612df1b0d6c58a", size = 10596596 },
|
||||
{ url = "https://files.pythonhosted.org/packages/6c/86/ff386de63729da3e08c8099c57f577a00ec9f3eea711b23ac07cf3588dc5/ruff-0.6.3-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:42844ff678f9b976366b262fa2d1d1a3fe76f6e145bd92c84e27d172e3c34500", size = 11572830 },
|
||||
{ url = "https://files.pythonhosted.org/packages/38/5d/b33284c108e3f315ddd09b70296fd76bd28ecf8965a520bc93f3bbd8ac40/ruff-0.6.3-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:70452a10eb2d66549de8e75f89ae82462159855e983ddff91bc0bce6511d0470", size = 10262577 },
|
||||
{ url = "https://files.pythonhosted.org/packages/29/99/9cdfad0d7f460e66567236eddc691473791afd9aff93a0dfcdef0462a6c7/ruff-0.6.3-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:65a533235ed55f767d1fc62193a21cbf9e3329cf26d427b800fdeacfb77d296f", size = 10098751 },
|
||||
{ url = "https://files.pythonhosted.org/packages/a8/9f/f801a1619f5549e552f1f722f1db57eb39e7e1d83d482133142781d450de/ruff-0.6.3-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:d2e2c23cef30dc3cbe9cc5d04f2899e7f5e478c40d2e0a633513ad081f7361b5", size = 9563859 },
|
||||
{ url = "https://files.pythonhosted.org/packages/0b/4d/fb2424faf04ffdb960ae2b3a1d991c5183dd981003de727d2d5cc38abc98/ruff-0.6.3-py3-none-musllinux_1_2_i686.whl", hash = "sha256:d8a136aa7d228975a6aee3dd8bea9b28e2b43e9444aa678fb62aeb1956ff2351", size = 9914291 },
|
||||
{ url = "https://files.pythonhosted.org/packages/2e/dd/94fddf002a8f6152e8ebfbb51d3f93febc415c1fe694345623c31ce8b33b/ruff-0.6.3-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:f92fe93bc72e262b7b3f2bba9879897e2d58a989b4714ba6a5a7273e842ad2f8", size = 10331549 },
|
||||
{ url = "https://files.pythonhosted.org/packages/72/68/1da6a1e39a03a229ea57c511691d6225072759cc7764206c3f0989521194/ruff-0.6.3-py3-none-linux_armv6l.whl", hash = "sha256:97f58fda4e309382ad30ede7f30e2791d70dd29ea17f41970119f55bdb7a45c3", size = 9696928, upload-time = "2024-08-29T15:15:54.085Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a0/20/b0bcb29d4ee437f3567b73b6905c034e2e94d29b9b826c66daecc1cf6388/ruff-0.6.3-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bddfbb8d63c460f4b4128b6a506e7052bad4d6f3ff607ebbb41b0aa19c2770d1", size = 10108892, upload-time = "2024-08-29T15:16:01.885Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9c/e3/211bc759f424e8823a9937e0f678695ca02113c621dfde1fa756f9f26f6d/ruff-0.6.3-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ced3eeb44df75353e08ab3b6a9e113b5f3f996bea48d4f7c027bc528ba87b672", size = 9476471, upload-time = "2024-08-29T15:16:04.397Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b2/a3/2ec35a2d7a554364864206f0e46812b92a074ad8a014b923d821ead532aa/ruff-0.6.3-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:47021dff5445d549be954eb275156dfd7c37222acc1e8014311badcb9b4ec8c1", size = 10294802, upload-time = "2024-08-29T15:16:07.278Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/03/8b/56ef687b3489c88886dea48c78fb4969b6b65f18007d0ac450070edd1f58/ruff-0.6.3-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:7d7bd20dc07cebd68cc8bc7b3f5ada6d637f42d947c85264f94b0d1cd9d87384", size = 11022372, upload-time = "2024-08-29T15:16:10.014Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a5/21/327d147feb442adb88975e81e2263102789eba9ad2afa102c661912a482f/ruff-0.6.3-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:500f166d03fc6d0e61c8e40a3ff853fa8a43d938f5d14c183c612df1b0d6c58a", size = 10596596, upload-time = "2024-08-29T15:16:12.99Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6c/86/ff386de63729da3e08c8099c57f577a00ec9f3eea711b23ac07cf3588dc5/ruff-0.6.3-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:42844ff678f9b976366b262fa2d1d1a3fe76f6e145bd92c84e27d172e3c34500", size = 11572830, upload-time = "2024-08-29T15:16:15.479Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/38/5d/b33284c108e3f315ddd09b70296fd76bd28ecf8965a520bc93f3bbd8ac40/ruff-0.6.3-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:70452a10eb2d66549de8e75f89ae82462159855e983ddff91bc0bce6511d0470", size = 10262577, upload-time = "2024-08-29T15:16:18.593Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/29/99/9cdfad0d7f460e66567236eddc691473791afd9aff93a0dfcdef0462a6c7/ruff-0.6.3-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:65a533235ed55f767d1fc62193a21cbf9e3329cf26d427b800fdeacfb77d296f", size = 10098751, upload-time = "2024-08-29T15:16:21.862Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a8/9f/f801a1619f5549e552f1f722f1db57eb39e7e1d83d482133142781d450de/ruff-0.6.3-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:d2e2c23cef30dc3cbe9cc5d04f2899e7f5e478c40d2e0a633513ad081f7361b5", size = 9563859, upload-time = "2024-08-29T15:16:24.806Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0b/4d/fb2424faf04ffdb960ae2b3a1d991c5183dd981003de727d2d5cc38abc98/ruff-0.6.3-py3-none-musllinux_1_2_i686.whl", hash = "sha256:d8a136aa7d228975a6aee3dd8bea9b28e2b43e9444aa678fb62aeb1956ff2351", size = 9914291, upload-time = "2024-08-29T15:16:27.894Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2e/dd/94fddf002a8f6152e8ebfbb51d3f93febc415c1fe694345623c31ce8b33b/ruff-0.6.3-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:f92fe93bc72e262b7b3f2bba9879897e2d58a989b4714ba6a5a7273e842ad2f8", size = 10331549, upload-time = "2024-08-29T15:16:31.045Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
@ -683,103 +711,103 @@ dependencies = [
|
|||
{ name = "certifi", marker = "sys_platform == 'linux'" },
|
||||
{ name = "urllib3", marker = "sys_platform == 'linux'" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/bb/41/97f673384dae5ed81cc2a568cc5c28e76deee85f8ba50def862e86150a5a/sentry_sdk-2.13.0.tar.gz", hash = "sha256:8d4a576f7a98eb2fdb40e13106e41f330e5c79d72a68be1316e7852cf4995260", size = 279937 }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/bb/41/97f673384dae5ed81cc2a568cc5c28e76deee85f8ba50def862e86150a5a/sentry_sdk-2.13.0.tar.gz", hash = "sha256:8d4a576f7a98eb2fdb40e13106e41f330e5c79d72a68be1316e7852cf4995260", size = 279937, upload-time = "2024-08-13T14:33:34.177Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/ad/7e/e9ca09f24a6c334286631a2d32c267cdc5edad5ac03fd9d20a01a82f1c35/sentry_sdk-2.13.0-py2.py3-none-any.whl", hash = "sha256:6beede8fc2ab4043da7f69d95534e320944690680dd9a963178a49de71d726c6", size = 309078 },
|
||||
{ url = "https://files.pythonhosted.org/packages/ad/7e/e9ca09f24a6c334286631a2d32c267cdc5edad5ac03fd9d20a01a82f1c35/sentry_sdk-2.13.0-py2.py3-none-any.whl", hash = "sha256:6beede8fc2ab4043da7f69d95534e320944690680dd9a963178a49de71d726c6", size = 309078, upload-time = "2024-08-13T14:33:32.262Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "sgmllib3k"
|
||||
version = "1.0.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/9e/bd/3704a8c3e0942d711c1299ebf7b9091930adae6675d7c8f476a7ce48653c/sgmllib3k-1.0.0.tar.gz", hash = "sha256:7868fb1c8bfa764c1ac563d3cf369c381d1325d36124933a726f29fcdaa812e9", size = 5750 }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/9e/bd/3704a8c3e0942d711c1299ebf7b9091930adae6675d7c8f476a7ce48653c/sgmllib3k-1.0.0.tar.gz", hash = "sha256:7868fb1c8bfa764c1ac563d3cf369c381d1325d36124933a726f29fcdaa812e9", size = 5750, upload-time = "2010-08-24T14:33:52.445Z" }
|
||||
|
||||
[[package]]
|
||||
name = "six"
|
||||
version = "1.16.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/71/39/171f1c67cd00715f190ba0b100d606d440a28c93c7714febeca8b79af85e/six-1.16.0.tar.gz", hash = "sha256:1e61c37477a1626458e36f7b1d82aa5c9b094fa4802892072e49de9c60c4c926", size = 34041 }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/71/39/171f1c67cd00715f190ba0b100d606d440a28c93c7714febeca8b79af85e/six-1.16.0.tar.gz", hash = "sha256:1e61c37477a1626458e36f7b1d82aa5c9b094fa4802892072e49de9c60c4c926", size = 34041, upload-time = "2021-05-05T14:18:18.379Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/d9/5a/e7c31adbe875f2abbb91bd84cf2dc52d792b5a01506781dbcf25c91daf11/six-1.16.0-py2.py3-none-any.whl", hash = "sha256:8abb2f1d86890a2dfb989f9a77cfcfd3e47c2a354b01111771326f8aa26e0254", size = 11053 },
|
||||
{ url = "https://files.pythonhosted.org/packages/d9/5a/e7c31adbe875f2abbb91bd84cf2dc52d792b5a01506781dbcf25c91daf11/six-1.16.0-py2.py3-none-any.whl", hash = "sha256:8abb2f1d86890a2dfb989f9a77cfcfd3e47c2a354b01111771326f8aa26e0254", size = 11053, upload-time = "2021-05-05T14:18:17.237Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "soupsieve"
|
||||
version = "2.6"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/d7/ce/fbaeed4f9fb8b2daa961f90591662df6a86c1abf25c548329a86920aedfb/soupsieve-2.6.tar.gz", hash = "sha256:e2e68417777af359ec65daac1057404a3c8a5455bb8abc36f1a9866ab1a51abb", size = 101569 }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/d7/ce/fbaeed4f9fb8b2daa961f90591662df6a86c1abf25c548329a86920aedfb/soupsieve-2.6.tar.gz", hash = "sha256:e2e68417777af359ec65daac1057404a3c8a5455bb8abc36f1a9866ab1a51abb", size = 101569, upload-time = "2024-08-13T13:39:12.166Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/d1/c2/fe97d779f3ef3b15f05c94a2f1e3d21732574ed441687474db9d342a7315/soupsieve-2.6-py3-none-any.whl", hash = "sha256:e72c4ff06e4fb6e4b5a9f0f55fe6e81514581fca1515028625d0f299c602ccc9", size = 36186 },
|
||||
{ url = "https://files.pythonhosted.org/packages/d1/c2/fe97d779f3ef3b15f05c94a2f1e3d21732574ed441687474db9d342a7315/soupsieve-2.6-py3-none-any.whl", hash = "sha256:e72c4ff06e4fb6e4b5a9f0f55fe6e81514581fca1515028625d0f299c602ccc9", size = 36186, upload-time = "2024-08-13T13:39:10.986Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "sqlparse"
|
||||
version = "0.5.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/73/82/dfa23ec2cbed08a801deab02fe7c904bfb00765256b155941d789a338c68/sqlparse-0.5.1.tar.gz", hash = "sha256:bb6b4df465655ef332548e24f08e205afc81b9ab86cb1c45657a7ff173a3a00e", size = 84502 }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/73/82/dfa23ec2cbed08a801deab02fe7c904bfb00765256b155941d789a338c68/sqlparse-0.5.1.tar.gz", hash = "sha256:bb6b4df465655ef332548e24f08e205afc81b9ab86cb1c45657a7ff173a3a00e", size = 84502, upload-time = "2024-07-15T19:30:27.085Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/5d/a5/b2860373aa8de1e626b2bdfdd6df4355f0565b47e51f7d0c54fe70faf8fe/sqlparse-0.5.1-py3-none-any.whl", hash = "sha256:773dcbf9a5ab44a090f3441e2180efe2560220203dc2f8c0b0fa141e18b505e4", size = 44156 },
|
||||
{ url = "https://files.pythonhosted.org/packages/5d/a5/b2860373aa8de1e626b2bdfdd6df4355f0565b47e51f7d0c54fe70faf8fe/sqlparse-0.5.1-py3-none-any.whl", hash = "sha256:773dcbf9a5ab44a090f3441e2180efe2560220203dc2f8c0b0fa141e18b505e4", size = 44156, upload-time = "2024-07-15T19:30:25.033Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "types-pyyaml"
|
||||
version = "6.0.12.20240808"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/dd/08/6f5737f645571b7a0b1ebd2fe8b5cf1ee4ec3e707866ca96042a86fc1d10/types-PyYAML-6.0.12.20240808.tar.gz", hash = "sha256:b8f76ddbd7f65440a8bda5526a9607e4c7a322dc2f8e1a8c405644f9a6f4b9af", size = 12359 }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/dd/08/6f5737f645571b7a0b1ebd2fe8b5cf1ee4ec3e707866ca96042a86fc1d10/types-PyYAML-6.0.12.20240808.tar.gz", hash = "sha256:b8f76ddbd7f65440a8bda5526a9607e4c7a322dc2f8e1a8c405644f9a6f4b9af", size = 12359, upload-time = "2024-08-08T02:30:32.727Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/f3/ad/ffbad24e2bc8f20bf047ec22af0c0a92f6ce2071eb21c9103df600cda6de/types_PyYAML-6.0.12.20240808-py3-none-any.whl", hash = "sha256:deda34c5c655265fc517b546c902aa6eed2ef8d3e921e4765fe606fe2afe8d35", size = 15298 },
|
||||
{ url = "https://files.pythonhosted.org/packages/f3/ad/ffbad24e2bc8f20bf047ec22af0c0a92f6ce2071eb21c9103df600cda6de/types_PyYAML-6.0.12.20240808-py3-none-any.whl", hash = "sha256:deda34c5c655265fc517b546c902aa6eed2ef8d3e921e4765fe606fe2afe8d35", size = 15298, upload-time = "2024-08-08T02:30:31.101Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "typing-extensions"
|
||||
version = "4.12.2"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/df/db/f35a00659bc03fec321ba8bce9420de607a1d37f8342eee1863174c69557/typing_extensions-4.12.2.tar.gz", hash = "sha256:1a7ead55c7e559dd4dee8856e3a88b41225abfe1ce8df57b7c13915fe121ffb8", size = 85321 }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/df/db/f35a00659bc03fec321ba8bce9420de607a1d37f8342eee1863174c69557/typing_extensions-4.12.2.tar.gz", hash = "sha256:1a7ead55c7e559dd4dee8856e3a88b41225abfe1ce8df57b7c13915fe121ffb8", size = 85321, upload-time = "2024-06-07T18:52:15.995Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/26/9f/ad63fc0248c5379346306f8668cda6e2e2e9c95e01216d2b8ffd9ff037d0/typing_extensions-4.12.2-py3-none-any.whl", hash = "sha256:04e5ca0351e0f3f85c6853954072df659d0d13fac324d0072316b67d7794700d", size = 37438 },
|
||||
{ url = "https://files.pythonhosted.org/packages/26/9f/ad63fc0248c5379346306f8668cda6e2e2e9c95e01216d2b8ffd9ff037d0/typing_extensions-4.12.2-py3-none-any.whl", hash = "sha256:04e5ca0351e0f3f85c6853954072df659d0d13fac324d0072316b67d7794700d", size = 37438, upload-time = "2024-06-07T18:52:13.582Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tzdata"
|
||||
version = "2024.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/74/5b/e025d02cb3b66b7b76093404392d4b44343c69101cc85f4d180dd5784717/tzdata-2024.1.tar.gz", hash = "sha256:2674120f8d891909751c38abcdfd386ac0a5a1127954fbc332af6b5ceae07efd", size = 190559 }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/74/5b/e025d02cb3b66b7b76093404392d4b44343c69101cc85f4d180dd5784717/tzdata-2024.1.tar.gz", hash = "sha256:2674120f8d891909751c38abcdfd386ac0a5a1127954fbc332af6b5ceae07efd", size = 190559, upload-time = "2024-02-11T23:22:40.2Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/65/58/f9c9e6be752e9fcb8b6a0ee9fb87e6e7a1f6bcab2cdc73f02bb7ba91ada0/tzdata-2024.1-py2.py3-none-any.whl", hash = "sha256:9068bc196136463f5245e51efda838afa15aaeca9903f49050dfa2679db4d252", size = 345370 },
|
||||
{ url = "https://files.pythonhosted.org/packages/65/58/f9c9e6be752e9fcb8b6a0ee9fb87e6e7a1f6bcab2cdc73f02bb7ba91ada0/tzdata-2024.1-py2.py3-none-any.whl", hash = "sha256:9068bc196136463f5245e51efda838afa15aaeca9903f49050dfa2679db4d252", size = 345370, upload-time = "2024-02-11T23:22:38.223Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "urllib3"
|
||||
version = "2.2.2"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/43/6d/fa469ae21497ddc8bc93e5877702dca7cb8f911e337aca7452b5724f1bb6/urllib3-2.2.2.tar.gz", hash = "sha256:dd505485549a7a552833da5e6063639d0d177c04f23bc3864e41e5dc5f612168", size = 292266 }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/43/6d/fa469ae21497ddc8bc93e5877702dca7cb8f911e337aca7452b5724f1bb6/urllib3-2.2.2.tar.gz", hash = "sha256:dd505485549a7a552833da5e6063639d0d177c04f23bc3864e41e5dc5f612168", size = 292266, upload-time = "2024-06-17T13:40:11.401Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/ca/1c/89ffc63a9605b583d5df2be791a27bc1a42b7c32bab68d3c8f2f73a98cd4/urllib3-2.2.2-py3-none-any.whl", hash = "sha256:a448b2f64d686155468037e1ace9f2d2199776e17f0a46610480d311f73e3472", size = 121444 },
|
||||
{ url = "https://files.pythonhosted.org/packages/ca/1c/89ffc63a9605b583d5df2be791a27bc1a42b7c32bab68d3c8f2f73a98cd4/urllib3-2.2.2-py3-none-any.whl", hash = "sha256:a448b2f64d686155468037e1ace9f2d2199776e17f0a46610480d311f73e3472", size = 121444, upload-time = "2024-06-17T13:40:07.795Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "vine"
|
||||
version = "5.1.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/bd/e4/d07b5f29d283596b9727dd5275ccbceb63c44a1a82aa9e4bfd20426762ac/vine-5.1.0.tar.gz", hash = "sha256:8b62e981d35c41049211cf62a0a1242d8c1ee9bd15bb196ce38aefd6799e61e0", size = 48980 }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/bd/e4/d07b5f29d283596b9727dd5275ccbceb63c44a1a82aa9e4bfd20426762ac/vine-5.1.0.tar.gz", hash = "sha256:8b62e981d35c41049211cf62a0a1242d8c1ee9bd15bb196ce38aefd6799e61e0", size = 48980, upload-time = "2023-11-05T08:46:53.857Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/03/ff/7c0c86c43b3cbb927e0ccc0255cb4057ceba4799cd44ae95174ce8e8b5b2/vine-5.1.0-py3-none-any.whl", hash = "sha256:40fdf3c48b2cfe1c38a49e9ae2da6fda88e4794c810050a728bd7413811fb1dc", size = 9636 },
|
||||
{ url = "https://files.pythonhosted.org/packages/03/ff/7c0c86c43b3cbb927e0ccc0255cb4057ceba4799cd44ae95174ce8e8b5b2/vine-5.1.0-py3-none-any.whl", hash = "sha256:40fdf3c48b2cfe1c38a49e9ae2da6fda88e4794c810050a728bd7413811fb1dc", size = 9636, upload-time = "2023-11-05T08:46:51.205Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wcwidth"
|
||||
version = "0.2.13"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/6c/63/53559446a878410fc5a5974feb13d31d78d752eb18aeba59c7fef1af7598/wcwidth-0.2.13.tar.gz", hash = "sha256:72ea0c06399eb286d978fdedb6923a9eb47e1c486ce63e9b4e64fc18303972b5", size = 101301 }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/6c/63/53559446a878410fc5a5974feb13d31d78d752eb18aeba59c7fef1af7598/wcwidth-0.2.13.tar.gz", hash = "sha256:72ea0c06399eb286d978fdedb6923a9eb47e1c486ce63e9b4e64fc18303972b5", size = 101301, upload-time = "2024-01-06T02:10:57.829Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/fd/84/fd2ba7aafacbad3c4201d395674fc6348826569da3c0937e75505ead3528/wcwidth-0.2.13-py2.py3-none-any.whl", hash = "sha256:3da69048e4540d84af32131829ff948f1e022c1c6bdb8d6102117aac784f6859", size = 34166 },
|
||||
{ url = "https://files.pythonhosted.org/packages/fd/84/fd2ba7aafacbad3c4201d395674fc6348826569da3c0937e75505ead3528/wcwidth-0.2.13-py2.py3-none-any.whl", hash = "sha256:3da69048e4540d84af32131829ff948f1e022c1c6bdb8d6102117aac784f6859", size = 34166, upload-time = "2024-01-06T02:10:55.763Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "webencodings"
|
||||
version = "0.5.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/0b/02/ae6ceac1baeda530866a85075641cec12989bd8d31af6d5ab4a3e8c92f47/webencodings-0.5.1.tar.gz", hash = "sha256:b36a1c245f2d304965eb4e0a82848379241dc04b865afcc4aab16748587e1923", size = 9721 }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/0b/02/ae6ceac1baeda530866a85075641cec12989bd8d31af6d5ab4a3e8c92f47/webencodings-0.5.1.tar.gz", hash = "sha256:b36a1c245f2d304965eb4e0a82848379241dc04b865afcc4aab16748587e1923", size = 9721, upload-time = "2017-04-05T20:21:34.189Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/f4/24/2a3e3df732393fed8b3ebf2ec078f05546de641fe1b667ee316ec1dcf3b7/webencodings-0.5.1-py2.py3-none-any.whl", hash = "sha256:a0af1213f3c2226497a97e2b3aa01a7e4bee4f403f95be16fc9acd2947514a78", size = 11774 },
|
||||
{ url = "https://files.pythonhosted.org/packages/f4/24/2a3e3df732393fed8b3ebf2ec078f05546de641fe1b667ee316ec1dcf3b7/webencodings-0.5.1-py2.py3-none-any.whl", hash = "sha256:a0af1213f3c2226497a97e2b3aa01a7e4bee4f403f95be16fc9acd2947514a78", size = 11774, upload-time = "2017-04-05T20:21:32.581Z" },
|
||||
]
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue