Skip to content
Permalink

Comparing changes

Choose two branches to see what’s changed or to start a new pull request. If you need to, you can also or learn more about diff comparisons.

Open a pull request

Create a new pull request by comparing changes across two branches. If you need to, you can also . Learn more about diff comparisons here.
base repository: hyper750/factorialhr
Failed to load repositories. Confirm that selected base ref is valid, then try again.
Loading
base: 0.2
Choose a base ref
...
head repository: hyper750/factorialhr
Failed to load repositories. Confirm that selected head ref is valid, then try again.
Loading
compare: master
Choose a head ref
  • 19 commits
  • 17 files changed
  • 1 contributor

Commits on Jan 16, 2020

  1. Update README.md

    hyper750 authored Jan 16, 2020

    Verified

    This commit was signed with the committer’s verified signature.
    sleepyfran Fran González
    Copy the full SHA
    36e0423 View commit details

Commits on Mar 24, 2020

  1. Merge pull request #3 from hyper750/develop

    Update README.md
    hyper750 authored Mar 24, 2020
    Copy the full SHA
    4964dc6 View commit details

Commits on Oct 30, 2020

  1. Copy the full SHA
    4f02906 View commit details

Commits on Nov 28, 2020

  1. Copy the full SHA
    5d19d94 View commit details
  2. Don't pollute namespace

    hyper750 committed Nov 28, 2020
    Copy the full SHA
    0b4b0c6 View commit details

Commits on Dec 2, 2020

  1. Copy the full SHA
    cd2a69b View commit details
  2. Copy the full SHA
    96991cf View commit details
  3. Copy the full SHA
    4081e83 View commit details
  4. Update issue templates

    hyper750 authored Dec 2, 2020
    Copy the full SHA
    cf30e3e View commit details
  5. Copy the full SHA
    0116cd4 View commit details
  6. Merge pull request #4 from hyper750/feature-load_user_from_api

    Abstract class to get the user and work info
    hyper750 authored Dec 2, 2020
    Copy the full SHA
    e00635a View commit details

Commits on Dec 3, 2020

  1. Update README.md

    hyper750 authored Dec 3, 2020
    Copy the full SHA
    4eae0b2 View commit details

Commits on Dec 12, 2020

  1. Changed login url form

    hyper750 committed Dec 12, 2020
    Copy the full SHA
    28898c9 View commit details
  2. Merge pull request #6 from hyper750/fix-login

    Fix login
    hyper750 authored Dec 12, 2020
    Copy the full SHA
    f129dd6 View commit details

Commits on Dec 13, 2020

  1. Copy the full SHA
    d4fde72 View commit details

Commits on Dec 21, 2020

  1. Fixed typo

    hyper750 authored Dec 21, 2020
    Copy the full SHA
    df6e9d7 View commit details

Commits on Feb 10, 2021

  1. Copy the full SHA
    2041ec9 View commit details

Commits on Apr 6, 2021

  1. Copy the full SHA
    28fc480 View commit details
  2. Merge pull request #7 from hyper750/feature-add_html5lib_requirement

    Added html5lib to requirements.txt
    hyper750 authored Apr 6, 2021
    Copy the full SHA
    fe48f73 View commit details
38 changes: 38 additions & 0 deletions .github/ISSUE_TEMPLATE/bug_report.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
---
name: Bug report
about: Create a report to help us improve
title: ''
labels: ''
assignees: ''

---

**Describe the bug**
A clear and concise description of what the bug is.

**To Reproduce**
Steps to reproduce the behavior:
1. Go to '...'
2. Click on '....'
3. Scroll down to '....'
4. See error

**Expected behavior**
A clear and concise description of what you expected to happen.

**Screenshots**
If applicable, add screenshots to help explain your problem.

**Desktop (please complete the following information):**
- OS: [e.g. iOS]
- Browser [e.g. chrome, safari]
- Version [e.g. 22]

**Smartphone (please complete the following information):**
- Device: [e.g. iPhone6]
- OS: [e.g. iOS8.1]
- Browser [e.g. stock browser, safari]
- Version [e.g. 22]

**Additional context**
Add any other context about the problem here.
2 changes: 1 addition & 1 deletion .gitignore
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
.idea
__pycache__
*.log
logs
sessions
*.pyc
75 changes: 57 additions & 18 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# Factorialhr
Python adapter to use the factorialhr api and automate
tasks.
sign tasks.

## Configure settings file
Configuring the settings file we can use the `main.py`
@@ -10,34 +10,44 @@ By default the name of the settings file is
`factorial_settings.json` you can always change it.
```json5
{
// Email to login on factorialhr
"email": "",
// Password to login on factorialhr
"password": "",
"user": {
// Email to login on factorialhr
"email": "",
// Password to login on factorialhr
"password": ""
},
"work": {
// The start hour of work
// Work start time
"start": "7:30",
// The end hour of work
// Work end time
"end": "15:30",
/* Random minutes to variate, max 15:40, min 7:20,
/* Random minutes to variate, for example if the variation
is 10, the sign time will be max 15:40 and min 7:20,
always with the same hours worked, eg:
start: 7:32
end: 15:32
-----
start: 7:36
end: 15:36
If the minutes_variation is 0 the start and end will
not variate in this case:
start: 7:30
end: 15:30
*/
"minutes_variation": 10,
/* If a day we have already sign the work and we
save it again, we should delete the saved worked or not
save it again, we should delete the saved worked and save it again
or not
*/
"resave": false,
/* List of breaks to take, following the same
structure of start and end of work
*/
"breaks": [
{
/*
A random break min: 9:30 and max 11:00, for example 9:45 - 10:15
*/
"start": "10:00",
"end": "10:30",
"minutes_variation": 30
@@ -47,29 +57,58 @@ By default the name of the settings file is
}
```

## Automatically sign for today
1. You just need to login through the method
`FactorialClient.load_from_settings` or through the
## Automatically sign today
1. You just need to login calling the method
`FactorialClient.load_from_settings` or the
constructor.

2. Call the method `client.worked_day` passing by
parameter the day to sign, by default is today. The
following code will sign for today, according to
the settings file.
parameter the day to sign, by default will sign today.

The following code will sign today, according to the
settings file.

```python
from factorial.exceptions import AuthenticationTokenNotFound, ApiError, UserNotLoggedIn
from factorial.factorialclient import FactorialClient
from factorial.loader import JsonCredentials, JsonWork

settings_file = 'factorial_settings.json'


if __name__ == '__main__':
try:
client = FactorialClient.load_from_settings(JsonCredentials(settings_file))
client.worked_day(JsonWork(settings_file))
except AuthenticationTokenNotFound as err:
print(f"Can't retrieve the login token: {err}")
except UserNotLoggedIn as err:
print(f'User not logged in: {err}')
except ApiError as err:
print(f"Api error: {err}")

```

Sign for a different day

```python
from factorial.exceptions import AuthenticationTokenNotFound, ApiError, UserNotLoggedIn
from factorial.factorialclient import FactorialClient
from factorial.loader import JsonCredentials, JsonWork
from datetime import date

settings_file = 'factorial_settings.json'
day = date(2021, 1, 19)

if __name__ == '__main__':
try:
client = FactorialClient.load_from_settings()
client.worked_day()
client = FactorialClient.load_from_settings(JsonCredentials(settings_file))
client.worked_day(JsonWork(settings_file), day)
except AuthenticationTokenNotFound as err:
print(f"Can't retrieve the login token: {err}")
except UserNotLoggedIn as err:
print(err)
print(f'User not logged in: {err}')
except ApiError as err:
print(f"Api error: {err}")

```
52 changes: 33 additions & 19 deletions constants.py
Original file line number Diff line number Diff line change
@@ -1,24 +1,38 @@
import logging.config
import os
import logging


BASE_PROJECT = os.path.abspath(os.path.dirname(__file__))

# Formaters
DEFAULT_FORMATER = logging.Formatter('%(name)s - %(asctime)s - %(levelname)s - %(message)s')

# Handlers
# File handler
FILE_HANDLER = logging.FileHandler(os.path.join(BASE_PROJECT, 'factorialclient.log'), 'a', 'utf-8')
FILE_HANDLER.setLevel(logging.DEBUG)
FILE_HANDLER.setFormatter(DEFAULT_FORMATER)
# Console handler
CONSOLE_HANDLER = logging.StreamHandler()
CONSOLE_HANDLER.setLevel(logging.DEBUG)
CONSOLE_HANDLER.setFormatter(DEFAULT_FORMATER)
LOGGING_CONFIG = {
'version': 1,
'formatters': {
'standard': {
'format': '%(name)s - %(asctime)s - %(levelname)s - %(message)s'
}
},
'handlers': {
'console': {
'level': 'DEBUG',
'formatter': 'standard',
'class': 'logging.StreamHandler'
},
'file': {
'level': 'DEBUG',
'formatter': 'standard',
'class': 'logging.handlers.TimedRotatingFileHandler',
'filename': os.path.join(BASE_PROJECT, 'logs', 'factorialclient.log'),
'interval': 1,
'when': 'W0',
'backupCount': 6
}
},
'loggers': {
'factorial.client': {
'handlers': ['console', 'file'],
'level': 'DEBUG',
'propagate': True
}
}
}

# Loggers
LOGGER = logging.getLogger('factorial.client')
LOGGER.setLevel(logging.DEBUG)
LOGGER.addHandler(FILE_HANDLER)
LOGGER.addHandler(CONSOLE_HANDLER)
logging.config.dictConfig(LOGGING_CONFIG)
95 changes: 51 additions & 44 deletions factorial/factorialclient.py
Original file line number Diff line number Diff line change
@@ -1,30 +1,32 @@
import json
import requests
from http import client as http_client
import pickle
import os
from bs4 import BeautifulSoup
from factorial.exceptions import AuthenticationTokenNotFound, UserNotLoggedIn, ApiError
import hashlib
import logging
import logging.config
import os
import pickle
import random
from datetime import date
from constants import BASE_PROJECT, LOGGER
from http import client as http_client

import requests
from bs4 import BeautifulSoup

from constants import BASE_PROJECT
from factorial.exceptions import AuthenticationTokenNotFound, UserNotLoggedIn, ApiError
from factorial.loader.credentials.abstract_credentials import AbstractCredentials
from factorial.loader.work.abstract_work import AbstractWork

LOGGER = logging.getLogger('factorial.client')


class FactorialClient:
# Folder to save the session's cookie
SESSIONS_FOLDER = os.path.join(BASE_PROJECT, "sessions")
# Default factorial settings file
DEFAULT_FACTORIAL_SETTINGS = os.path.join(BASE_PROJECT, 'factorial_settings.json')

# Endpoints
BASE_NAME = "https://api.factorialhr.com/"
# Url to be able to login (post: username, password) and logout (delete) on the api
SESSION_URL = '{}sessions'.format(BASE_NAME)
# Url to show the form to get the authentication token (get)
LOGIN_PAGE_URL = '{}users/sign_in'.format(BASE_NAME)
LOGIN_PAGE_URL = '{}es/users/sign_in'.format(BASE_NAME)
# Url to get the user info (get)
USER_INFO_URL = '{}accesses'.format(BASE_NAME)
# Get employee (get)
@@ -68,16 +70,15 @@ def login(self):
return True
except UserNotLoggedIn:
payload = {
'utf8': '✓',
'authenticity_token': self.generate_new_token(),
'user[email]': self.email,
'user[password]': self.password,
'user[remember_me]': "0",
'commit': 'Iniciar sesión'
}

response = self.session.post(url=self.SESSION_URL, data=payload)
loggedin = response.status_code == http_client.CREATED
response = self.session.post(url=self.LOGIN_PAGE_URL, data=payload)
loggedin = response.status_code == http_client.OK
if loggedin:
LOGGER.info('Login successfully')
# Load user data
@@ -90,28 +91,25 @@ def login(self):
LOGGER.info('Sessions saved')
return loggedin

@staticmethod
def generate_new_token():
def generate_new_token(self):
"""Generate new token to be able to login"""
response = requests.get(url=FactorialClient.LOGIN_PAGE_URL)
soup = BeautifulSoup(response.text, 'html.parser')
response = self.session.get(url=self.LOGIN_PAGE_URL)
soup = BeautifulSoup(response.text, 'html5lib')
auth_token = soup.find('input', attrs={'name': 'authenticity_token'})
token_value = auth_token.get('value')
if not token_value:
raise AuthenticationTokenNotFound()
return token_value

@staticmethod
def load_from_settings(json_settings=DEFAULT_FACTORIAL_SETTINGS):
def load_from_settings(credentials_loader: AbstractCredentials):
"""Login from the settings if the session still valid from the saved cookies, otherwise ask for the password
:param json_settings: string config filename
:param credentials_loader: AbstractFactorialLoader load email and password from abstract class
:return: FactorialClient
"""
with open(json_settings, 'r') as file:
settings = json.load(file)
factorial_client = FactorialClient(email=settings.get('email', ''),
password=settings.get('password', ''))
factorial_client = FactorialClient(email=credentials_loader.get_email(),
password=credentials_loader.get_password())
if not factorial_client.login():
# Session valid with the current cookie
raise ApiError('Cannot login with the given credentials')
@@ -219,7 +217,9 @@ def generate_period(self, start, end, minutes_variation):
end_hours, end_minutes = self.split_time(end)
total_minutes = self.get_total_minutes_period(start_hours, start_minutes, end_hours, end_minutes)
start_sign_hour, start_sign_minutes = FactorialClient.random_time(start_hours, start_minutes, minutes_variation)
end_sign_hour, end_sign_minutes = self.convert_to_time(self.convert_to_minutes(start_sign_hour, start_sign_minutes) + total_minutes)
end_sign_hour, end_sign_minutes = self.convert_to_time(
self.convert_to_minutes(start_sign_hour, start_sign_minutes) + total_minutes
)
return start_sign_hour, start_sign_minutes, end_sign_hour, end_sign_minutes

def add_breaks_to_period(self, start_sign_hour, start_sign_minutes, end_sign_hour, end_sign_minutes, breaks):
@@ -230,7 +230,9 @@ def add_breaks_to_period(self, start_sign_hour, start_sign_minutes, end_sign_hou
periods = []
start_hour = start_sign_hour
start_minute = start_sign_minutes
for _break in sorted(breaks, key=lambda current_break: self.convert_to_minutes(current_break['start_hour'], current_break['start_minute']), reverse=False):
for _break in sorted(breaks, key=lambda current_break: self.convert_to_minutes(current_break['start_hour'],
current_break['start_minute']),
reverse=False):
break_start_hour = _break.get('start_hour')
break_start_minute = _break.get('start_minute')
break_end_hour = _break.get('end_hour')
@@ -259,38 +261,38 @@ def generate_worked_periods(self, start_work, end_work, work_minutes_variation,
:param start_work: string time
:param end_work: string time
:param work_minutes_variation: int minutes to variate
:param breaks: list of dictionaries
:param breaks: list WorkBreak
:return: list of periods
"""
start_sign_hour, start_sign_minutes, end_sign_hour, end_sign_minutes = self.generate_period(start_work, end_work, work_minutes_variation)
start_sign_hour, start_sign_minutes, end_sign_hour, end_sign_minutes = self.generate_period(start_work,
end_work,
work_minutes_variation
)
breaks_with_variation = []
for _break in breaks:
start_break_hour, start_break_minutes, end_break_hour, end_break_minutes = self.generate_period(**_break)
start_break_hour, start_break_minutes, end_break_hour, end_break_minutes = self.generate_period(
start=_break.get_start_hour(),
end=_break.get_end_hour(),
minutes_variation=_break.get_minutes_variation()
)
breaks_with_variation.append({
'start_hour': start_break_hour,
'start_minute': start_break_minutes,
'end_hour': end_break_hour,
'end_minute': end_break_minutes
})
return self.add_breaks_to_period(start_sign_hour, start_sign_minutes, end_sign_hour, end_sign_minutes, breaks_with_variation)
return self.add_breaks_to_period(start_sign_hour, start_sign_minutes, end_sign_hour, end_sign_minutes,
breaks_with_variation)

def worked_day(self, day=date.today(), json_settings=DEFAULT_FACTORIAL_SETTINGS):
def worked_day(self, work_loader: AbstractWork, day=date.today()):
"""Mark today as worked day
:param work_loader: AbstractCredentialLoader load the working hours
:param day: date to save the worked day, by default is today
:param json_settings: string config filename
"""
with open(json_settings, 'r') as file:
settings = json.load(file)
work_settings_block = settings.get('work', {})
start_work = work_settings_block.get('start', '')
end_work = work_settings_block.get('end', '')
work_minutes_variation = work_settings_block.get('minutes_variation', 0)
breaks = work_settings_block.get('breaks', [])

already_work = self.get_day(year=day.year, month=day.month, day=day.day)
if already_work:
if work_settings_block.get('resave'):
if work_loader.get_resave():
for worked_period in already_work:
self.delete_worked_period(worked_period.get('id'))
else:
@@ -307,7 +309,12 @@ def worked_day(self, day=date.today(), json_settings=DEFAULT_FACTORIAL_SETTINGS)
'end_hour': 0,
'end_minute': 0
}
worked_periods = self.generate_worked_periods(start_work, end_work, work_minutes_variation, breaks)
worked_periods = self.generate_worked_periods(
work_loader.get_start_hour(),
work_loader.get_end_hour(),
work_loader.get_minutes_variation(),
work_loader.get_breaks()
)
for worked_period in worked_periods:
start_hour = worked_period.get('start_hour')
start_minute = worked_period.get('start_minute')
@@ -337,7 +344,7 @@ def logout(self):
path_file = os.path.join(self.SESSIONS_FOLDER, self.cookie_file)
if os.path.exists(path_file):
os.remove(path_file)
logging.info('Logout: Removed cookies file')
LOGGER.info('Logout: Removed cookies file')
self.mates.clear()
self.current_user = {}
return logout_correcty
2 changes: 2 additions & 0 deletions factorial/loader/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
from .credentials import JsonCredentials
from .work import JsonWork
1 change: 1 addition & 0 deletions factorial/loader/credentials/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
from .json_credentials_loader import JsonCredentials
20 changes: 20 additions & 0 deletions factorial/loader/credentials/abstract_credentials.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
from abc import ABC, abstractmethod


class AbstractCredentials(ABC):

@abstractmethod
def get_email(self) -> str:
"""Get email to login to factorialhr
:return: string
"""
pass

@abstractmethod
def get_password(self) -> str:
"""Get password to login to factorialhr
:return: string
"""
pass
32 changes: 32 additions & 0 deletions factorial/loader/credentials/json_credentials_loader.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import json

from .abstract_credentials import AbstractCredentials


class JsonCredentials(AbstractCredentials):

def __init__(self, filename: str):
super().__init__()

self.filename = filename

with open(filename, 'r') as f:
settings = json.load(f)

context = settings.get('user', {})
self.email = context.get('email')
self.password = context.get('password')

def get_email(self) -> str:
"""Get email from json file to login to factorialhr
:return: string
"""
return self.email

def get_password(self) -> str:
"""Get password from json file to login to factorialhr
:return: string
"""
return self.password
1 change: 1 addition & 0 deletions factorial/loader/work/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
from .json_work import JsonWork
57 changes: 57 additions & 0 deletions factorial/loader/work/abstract_work.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
from abc import ABC, abstractmethod
from typing import List
from .work_break import WorkBreak


class AbstractWork(ABC):

@abstractmethod
def get_start_hour(self) -> str:
"""Get the start hour to work
:return: str eg: "7:30"
"""
pass

@abstractmethod
def get_end_hour(self) -> str:
"""Get the end hour to work
:return: str eg: "15:30"
"""
pass

@abstractmethod
def get_minutes_variation(self) -> int:
"""Randomly variate the hour of start and end
Eg:
- start_hour: "7:30"
- end_hour: "7:30"
- minutes_variation: 10
With a minimum of "7:20" - "15:20" and a max of "7:40" - "15:40"
Possible outputs:
· "7:32" - "15:32"
· "7:26" - "15:26"
...
:return: int eg: 10
"""
pass

@abstractmethod
def get_resave(self) -> bool:
"""Can the work be overwrite
:return: bool
"""
pass

@abstractmethod
def get_breaks(self) -> List[WorkBreak]:
"""List of breaks to take, for example to breakfast
:return: list of WorkBreak
"""
pass
76 changes: 76 additions & 0 deletions factorial/loader/work/json_work.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
import json
from typing import List

from .abstract_work import AbstractWork
from .work_break import WorkBreak


class JsonWork(AbstractWork):

def __init__(self, filename: str):
super().__init__()

self.filename = filename
with open(filename, "r") as f:
settings = json.load(f)

context = settings.get('work', {})
self.start_hour = context.get('start')
self.end_hour = context.get('end')
self.minutes_variation = context.get('minutes_variation')
self.resave = context.get('resave')

self.breaks = [
WorkBreak(
start_hour=work_break.get('start'),
end_hour=work_break.get('end'),
minutes_variation=work_break.get('minutes_variation')
)
for work_break in context.get('breaks', [])
]

def get_start_hour(self) -> str:
"""Get the start hour to work
:return: str eg: "7:30"
"""
return self.start_hour

def get_end_hour(self) -> str:
"""Get the end hour to work
:return: str eg: "15:30"
"""
return self.end_hour

def get_minutes_variation(self) -> int:
"""Randomly variate the hour of start and end
Eg:
- start_hour: "7:30"
- end_hour: "7:30"
- minutes_variation: 10
With a minimum of "7:20" - "15:20" and a max of "7:40" - "15:40"
Possible outputs:
· "7:32" - "15:32"
· "7:26" - "15:26"
...
:return: int eg: 10
"""
return self.minutes_variation

def get_resave(self) -> bool:
"""Can the work be overwrite
:return: bool
"""
return self.resave

def get_breaks(self) -> List[WorkBreak]:
"""List of breaks to take, for example to breakfast
:return: list of WorkBreak
"""
return self.breaks
40 changes: 40 additions & 0 deletions factorial/loader/work/work_break.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
class WorkBreak:

def __init__(self, start_hour: str, end_hour: str, minutes_variation: int):
self.start_hour = start_hour
self.end_hour = end_hour
self.minutes_variation = minutes_variation

def get_start_hour(self) -> str:
"""Get the start hour of the break
:return: str eg: "10:30"
"""
return self.start_hour

def get_end_hour(self) -> str:
"""Get the end hour of the break
:return: str eg: "11:00"
"""
return self.end_hour

def get_minutes_variation(self) -> int:
"""Randomly variate the hour of start and end
Eg:
- start_hour: "10:30"
- end_hour: "11:00"
- minutes_variation: 15
With a minimum of "10:15" - "10:45" and a max of "10:45" - "11:15"
Possible outputs:
· "10:35" - "11:05"
· "10:40" - "11:10"
...
:return: int eg: 15
"""
return self.minutes_variation

def __repr__(self) -> str:
return f'{self.get_start_hour()} - {self.get_end_hour()} ~{self.get_minutes_variation()}m'
6 changes: 4 additions & 2 deletions factorial_settings.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
{
"email": "",
"password": "",
"user": {
"email": "xxxx",
"password": "xxxx"
},
"work": {
"start": "7:30",
"end": "15:30",
1 change: 1 addition & 0 deletions logs/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
!.gitignore
9 changes: 5 additions & 4 deletions main.py
Original file line number Diff line number Diff line change
@@ -1,11 +1,12 @@
from factorial.factorialclient import FactorialClient
from factorial.exceptions import AuthenticationTokenNotFound, ApiError, UserNotLoggedIn

from factorial.factorialclient import FactorialClient
from factorial.loader import JsonCredentials, JsonWork

if __name__ == '__main__':
settings_file = 'factorial_settings.json'
try:
client = FactorialClient.load_from_settings()
client.worked_day()
client = FactorialClient.load_from_settings(JsonCredentials(settings_file))
client.worked_day(JsonWork(settings_file))
except AuthenticationTokenNotFound as err:
print(f"Can't retrieve the login token: {err}")
except UserNotLoggedIn as err:
3 changes: 2 additions & 1 deletion requirements.txt
Original file line number Diff line number Diff line change
@@ -1,2 +1,3 @@
requests
bs4
bs4
html5lib