Set logging process

* Sets logging method and
  replaces basic "print" with
  logs

* Replaces /HOME/USER/.config/ directory
  with /HOME/USER/.inopy directory

* Moves config directory in new /HOME/USER/.inopy
  directory and creates a new "logs" directory

* Adapts README file
master
Alexandre Racine 2023-06-30 19:46:35 +02:00
parent f4fe6e717e
commit 7c3c922529
6 changed files with 361 additions and 246 deletions

View File

@ -11,7 +11,7 @@ Inopy is a Python application that retrieves unread articles from the [Inoreader
## Overview ## Overview
It uses OAuth authentication for accessing the [API](https://www.inoreader.com/developers) and stores the authentication data in a JSON configuration file located at `/HOME/USER/.config/inopy/config.json`. The program is divided into multiple modules and functions for making API requests, parsing response data, refreshing tokens, and sending notifications. It uses OAuth authentication for accessing the [API](https://www.inoreader.com/developers) and stores the authentication data in a JSON configuration file located at `/HOME/USER/.inopy/config/config.json`. The program is divided into multiple modules and functions for making API requests, parsing response data, refreshing tokens, sending notifications and logging the processes.
The code is organized into the following modules: The code is organized into the following modules:
@ -20,6 +20,7 @@ The code is organized into the following modules:
- `oauth.py`: Implements the OAuth authentication flow using Flask. - `oauth.py`: Implements the OAuth authentication flow using Flask.
- `refresh.py`: Contains a `refresh` function for refreshing OAuth access and refresh tokens and updating the configuration file. - `refresh.py`: Contains a `refresh` function for refreshing OAuth access and refresh tokens and updating the configuration file.
- `notif.py`: Provides a function for sending notifications using D-Bus (only tested with Cinnamon desktop environment). - `notif.py`: Provides a function for sending notifications using D-Bus (only tested with Cinnamon desktop environment).
- `logs.py`: Defines logging options.
## Installation ## Installation
@ -39,7 +40,7 @@ pip install requests pydbus flask waitress
## Usage ## Usage
The first time `ino.py` module is run, it will check if `config.json` exists in `/HOME/USER/.config/inopy/`. If not it will prompt user for configuration details and create the `config.json` file. The file should contain the OAuth endpoint, client ID, client secret, callback URL, scope, CSRF value and home URL. The first time `ino.py` module is run, it will check if `config.json` exists in `/HOME/USER/.inopy/config/`. If not it will prompt user for configuration details and create the `config.json` file. The file should contain the OAuth endpoint, client ID, client secret, callback URL, scope, CSRF value and home URL.
It should also contain the Inoreader API endpoints, notification labels, production status, browser path, host and port. It should also contain the Inoreader API endpoints, notification labels, production status, browser path, host and port.

View File

@ -32,6 +32,11 @@
import json import json
import os import os
import logging
from logs import LogFile
# Set logs file
log_file = LogFile()
''' '''
================================================================ ================================================================
@ -51,8 +56,10 @@ import os
def get_config(config_path, config_file_path): def get_config(config_path, config_file_path):
if os.path.exists(config_path): if os.path.exists(config_path):
logging.info(f'Found config directory at {config_path}')
if os.path.exists(config_file_path): if os.path.exists(config_file_path):
logging.info(f'Found config file at {config_file_path}!')
pass pass
else: else:
@ -64,7 +71,8 @@ def get_config(config_path, config_file_path):
# If the config directory doesn't exist, create it # If the config directory doesn't exist, create it
os.mkdir(config_path) os.mkdir(config_path)
print(f'{config_path} created!') logging.info(f'{config_path} created!')
create_file(config_file_path) create_file(config_file_path)
# load config content # load config content
@ -167,7 +175,8 @@ def create_file(config_file_path):
with open(config_file_path, "w") as file: with open(config_file_path, "w") as file:
json.dump(config, file, indent=4) json.dump(config, file, indent=4)
print(f"{config_file_path} created successfully!") #print(f"{config_file_path} created successfully!")
logging.info(f'Created config file at {config_file_path}!')
''' '''
============================================== ==============================================
@ -182,7 +191,7 @@ def config():
# Set the path of config file to # Set the path of config file to
# /HOME/USER/.config/inopy/config.json # /HOME/USER/.config/inopy/config.json
config_path = os.path.join(os.environ['HOME'], '.config/inopy') config_path = os.path.join(os.environ['HOME'], '.inopy/config')
config_file = 'config.json' config_file = 'config.json'
config_file_path = os.path.join(config_path, config_file) config_file_path = os.path.join(config_path, config_file)

79
ino.py
View File

@ -25,7 +25,7 @@
It performs token refreshing process if the API request returns an unauthorized status code. It performs token refreshing process if the API request returns an unauthorized status code.
Inopy is structured into functions and modules for making API requests, parsing response data, refreshing tokens and sending notifications. Inopy is structured into functions and modules for making API requests, parsing response data, refreshing tokens, sending notifications and logging the processes.
For more information about OAuth authentication, plase see <https://www.inoreader.com/developers/oauth> For more information about OAuth authentication, plase see <https://www.inoreader.com/developers/oauth>
@ -35,9 +35,14 @@
import requests import requests
import json import json
import notif import notif
import logging
from config import config from config import config
from oauth import app, run_app from oauth import run_app
from refresh import refresh from refresh import refresh
from logs import LogFile
# Set logs file
log_file = LogFile()
''' '''
=========================================== ===========================================
@ -100,7 +105,12 @@ subscriptions = {}
categories = [] categories = []
# Make API request to get unread counts # Make API request to get unread counts
unread_response = APIrequest(unread_counts_url, bearer)
try:
unread_response = APIrequest(unread_counts_url, bearer)
except Exception as e:
logging.debug(e)
''' '''
=================================================== ===================================================
@ -138,34 +148,47 @@ unread_response = APIrequest(unread_counts_url, bearer)
============================================ ============================================
''' '''
# Check for 403 error case try:
if unread_response.status_code == 403:
run_app()
with open(config_path) as config_file: # Check for 403 error case
new_config = json.load(config_file) if unread_response.status_code == 403:
logging.info('Token not available: starting oauth process...')
run_app()
bearer = new_config['oauth']['bearer'] with open(config_path) as config_file:
new_config = json.load(config_file)
unread_response = APIrequest(unread_counts_url, bearer) bearer = new_config['oauth']['bearer']
# Check for 401 error case unread_response = APIrequest(unread_counts_url, bearer)
elif unread_response.status_code == 401:
refresh(config_path, endpoint, client_id, client_secret, refresh_token)
with open(config_path) as config_file: # Check for 401 error case
new_config = json.load(config_file) elif unread_response.status_code == 401:
logging.info('Token expired: starting refresh process...')
refresh(config_path, endpoint, client_id, client_secret, refresh_token)
bearer = new_config['oauth']['bearer'] with open(config_path) as config_file:
new_config = json.load(config_file)
unread_response = APIrequest(unread_counts_url, bearer) bearer = new_config['oauth']['bearer']
# Proceed with the code execution unread_response = APIrequest(unread_counts_url, bearer)
elif unread_response.status_code == 200:
pass # Proceed with the code execution
elif unread_response.status_code == 200:
logging.info('API request ok: retrieving data...')
pass
except Exception as e:
logging.debug(e)
# Make API request to get feeds list # Make API request to get feeds list
feeds_list_response = APIrequest(feeds_list_url, bearer)
try:
feeds_list_response = APIrequest(feeds_list_url, bearer)
except Exception as e:
logging.debug(e)
''' '''
======================================= =======================================
@ -255,8 +278,14 @@ for unread_id, count in unreadcounts.items():
==================================== ====================================
''' '''
if message != "": try:
notif.send_notification(summary, message) if message != "":
notif.send_notification(summary, message)
logging.info('Notification successfully sent!')
else: else:
pass logging.info('No unread articles. Notification not sent.')
pass
except Exception as e:
logging.debug(e)

41
logs.py Normal file
View File

@ -0,0 +1,41 @@
"""
=========================================================================================
Copyright © 2023 Alexandre Racine <https://alex-racine.ch>
This file is part of Inopy.
Inopy is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version.
Inopy is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details.
You should have received a copy of the GNU General Public License along with Inopy. If not, see <https://www.gnu.org/licenses/>.
=========================================================================================
DISCLAIMER: parts of this code and comments blocks were created
with the help of ChatGPT developped by OpenAI <https://openai.com/>
Followed by human reviewing, refactoring and fine-tuning.
=========================================================================================
This module aims to log the program processes.
=========================================================================================
"""
import os
import logging
# Setting the logs file
def LogFile():
# Determine the user's home directory
logs_dir = os.path.join(os.environ['HOME'], '.inopy/logs')
os.makedirs(logs_dir, exist_ok=True)
# Configure logging
log_file = os.path.join(logs_dir, "inopy.log")
logging.basicConfig(format='%(asctime)s - %(levelname)s - %(message)s',
datefmt='%m/%d/%Y %I:%M:%S %p', filename=log_file, level=logging.DEBUG)

452
oauth.py
View File

@ -34,225 +34,255 @@ import time
import json import json
import subprocess import subprocess
import threading import threading
import logging
from flask import Flask, request, redirect, render_template from flask import Flask, request, redirect, render_template
from config import config from config import config
from waitress import serve from waitress import serve
from logs import LogFile
''' # Set logs file
=================================== log_file = LogFile()
Load configuration values
Extract values from the config
dictionary
Build the URL for authorization
===================================
'''
config = config()
endpoint = config['endpoint']
client_id = config['client_id']
client_secret = config['client_secret']
callback = config['callback']
scope = config['scope']
CSRF = config['csrf']
home_url = config['home_url']
prod_status = config['prod_status']
browser_path = config['browser_path']
host = config['host']
port = config['port']
config_file_path = config['config_file_path']
url = 'https://www.inoreader.com/oauth2/auth?client_id={}&redirect_uri={}&response_type=code&scope={}&state={}'.format(client_id, callback, scope, CSRF)
'''
==========================
Initiate the Flask app
==========================
'''
app = Flask(__name__)
'''
==================================
When the home url is accessed:
* Redirect the user to the
authorization URL
==================================
'''
@app.route('/')
def index():
return redirect(url)
'''
=====================================================
When the oauth-callback url is accessed:
* Get the authorization code and other
parameters from the callback URL
* Check CSRF validation token and if there
is an error parameter in the URL
* Request bearer token and refresh token
* Save the bearer token and refresh token
to the config file
* If process is successfull:
* render the success template with the
response
* If CSRF failed:
* render the CSRF failure template
* If an error parameter is present in the URL:
* Render the OAuth error template
=====================================================
'''
@app.route('/oauth-callback')
def oauth_callback():
authorization_code = request.args.get('code')
csrf_check = request.args.get('state')
error_param = request.args.get('error')
csrf = True if csrf_check == CSRF else False
error = True if error_param is not None else False
if csrf and not error:
access_token_url = endpoint
# Prepare data to request bearer token
payload = {
'grant_type': 'authorization_code',
'code': authorization_code,
'client_id': client_id,
'client_secret': client_secret,
'redirect_uri': callback
}
# Request bearer token and refresh token
response = requests.post(access_token_url, data=payload)
if response.status_code == 200:
access_token = response.json()['access_token']
refresh_token = response.json()['refresh_token']
with open(config_file_path, 'r+') as config_file:
config = json.load(config_file)
# Save the bearer token and refresh token to the config file
config['oauth']['bearer'] = access_token
config['oauth']['refresh_token'] = refresh_token
config_file.seek(0)
json.dump(config, config_file, indent=4)
config_file.truncate()
return render_template('success.html', response=(access_token, refresh_token))
else:
if not csrf:
return render_template('csrf-failed.html', response=(CSRF, csrf_check))
elif error:
error_content = request.args.get('error_description')
return render_template('oauth-error.html', response=(error_param, error_content))
else:
pass
'''
======================================
When the shutdown url is accessed:
* Shut down the Flask server
gracefully
======================================
'''
@app.route('/shutdown')
def shutdown():
request.environ.get('werkzeug.server.shutdown')
return 'Close this browser to terminate the process!'
'''
======================================
Define a function to start the
Flask server using Waitress in
production mode
======================================
'''
def start_server():
serve(app, host=host, port=port)
'''
======================================
Define a function to start the
production server inside a new
thread.
This is to ensure the server is
actually already running before
opening the web browser
======================================
'''
def run_prod():
server_thread = threading.Thread(target=start_server)
server_thread.start()
time.sleep(2)
# Launch a separate browser process with a new profile
subprocess.run([browser_path, "-CreateProfile", "new_profile", "-no-remote"])
subprocess.run([browser_path, "-P", "new_profile", "-no-remote", home_url])
'''
======================================
Define a function to start the
development server inside a new
thread.
This is to ensure the server is
actually already running before
opening the web browser
======================================
'''
def run_dev():
server_thread = threading.Thread(target=start_server)
server_thread.start()
time.sleep(2)
# Open the home URL in the default web browser
webbrowser.open(home_url)
app.run()
'''
======================================
Determine whether to run the
production or development server
based on the config
======================================
'''
def run_app(): def run_app():
if prod_status == "true": '''
run_prod() ===================================
Load configuration values
else: Extract values from the config
run_dev() dictionary
Build the URL for authorization
===================================
'''
conf = config()
endpoint = conf['endpoint']
client_id = conf['client_id']
client_secret = conf['client_secret']
callback = conf['callback']
scope = conf['scope']
CSRF = conf['csrf']
home_url = conf['home_url']
prod_status = conf['prod_status']
browser_path = conf['browser_path']
host = conf['host']
port = conf['port']
config_file_path = conf['config_file_path']
url = 'https://www.inoreader.com/oauth2/auth?client_id={}&redirect_uri={}&response_type=code&scope={}&state={}'.format(client_id, callback, scope, CSRF)
'''
==========================
Initiate the Flask app
==========================
'''
app = Flask(__name__)
'''
==================================
When the home url is accessed:
* Redirect the user to the
authorization URL
==================================
'''
try:
@app.route('/')
def index():
return redirect(url)
logging.info('Redirecting to authorization URL...')
except Exception as e:
logging.debug(e)
'''
=====================================================
When the oauth-callback url is accessed:
* Get the authorization code and other
parameters from the callback URL
* Check CSRF validation token and if there
is an error parameter in the URL
* Request bearer token and refresh token
* Save the bearer token and refresh token
to the config file
* If process is successfull:
* render the success template with the
response
* If CSRF failed:
* render the CSRF failure template
* If an error parameter is present in the URL:
* Render the OAuth error template
=====================================================
'''
try:
@app.route('/oauth-callback')
def oauth_callback():
authorization_code = request.args.get('code')
csrf_check = request.args.get('state')
error_param = request.args.get('error')
csrf = True if csrf_check == CSRF else False
error = True if error_param is not None else False
if csrf and not error:
access_token_url = endpoint
# Prepare data to request bearer token
payload = {
'grant_type': 'authorization_code',
'code': authorization_code,
'client_id': client_id,
'client_secret': client_secret,
'redirect_uri': callback
}
# Request bearer token and refresh token
response = requests.post(access_token_url, data=payload)
if response.status_code == 200:
access_token = response.json()['access_token']
refresh_token = response.json()['refresh_token']
with open(config_file_path, 'r+') as config_file:
config = json.load(config_file)
# Save the bearer token and refresh token to the config file
config['oauth']['bearer'] = access_token
config['oauth']['refresh_token'] = refresh_token
config_file.seek(0)
json.dump(config, config_file, indent=4)
config_file.truncate()
logging.info('New token obtained successfully...')
return render_template('success.html', response=(access_token, refresh_token))
else:
if not csrf:
logging.warning('CRSF validation failed...')
return render_template('csrf-failed.html', response=(CSRF, csrf_check))
elif error:
error_content = request.args.get('error_description')
logging.debug(error_content)
return render_template('oauth-error.html', response=(error_param, error_content))
else:
pass
except Exception as e:
logging.debug(e)
'''
======================================
When the shutdown url is accessed:
* Shut down the Flask server
gracefully
======================================
'''
try:
@app.route('/shutdown')
def shutdown():
request.environ.get('werkzeug.server.shutdown')
logging.info('Shutting down Flask server...')
return 'Close this browser to terminate the process!'
except Exception as e:
logging.debug(e)
'''
======================================
Define a function to start the
Flask server using Waitress in
production mode
======================================
'''
def start_server():
serve(app, host=host, port=port)
'''
======================================
Define a function to start the
production server inside a new
thread.
This is to ensure the server is
actually already running before
opening the web browser
======================================
'''
def run_prod():
logging.info('Running program in production mode...')
server_thread = threading.Thread(target=start_server)
server_thread.start()
time.sleep(2)
# Launch a separate browser process with a new profile
subprocess.run([browser_path, "-CreateProfile", "new_profile", "-no-remote"])
subprocess.run([browser_path, "-P", "new_profile", "-no-remote", home_url])
'''
======================================
Define a function to start the
development server inside a new
thread.
This is to ensure the server is
actually already running before
opening the web browser
======================================
'''
def run_dev():
logging.info('Running program in development mode...')
server_thread = threading.Thread(target=start_server)
server_thread.start()
time.sleep(2)
# Open the home URL in the default web browser
webbrowser.open(home_url)
app.run()
'''
======================================
Determine whether to run the
production or development server
based on the config
======================================
'''
try:
if prod_status == "true":
run_prod()
else:
run_dev()
except Exception as e:
logging.debug(e)
''' '''
====================================== ======================================

View File

@ -58,6 +58,11 @@
import requests import requests
import json import json
import logging
from logs import LogFile
# Set logs file
log_file = LogFile()
def refresh(config_path, endpoint, client_id, client_secret, refresh_token): def refresh(config_path, endpoint, client_id, client_secret, refresh_token):
headers = {"Content-type": "application/x-www-form-urlencoded"} headers = {"Content-type": "application/x-www-form-urlencoded"}
@ -72,10 +77,10 @@ def refresh(config_path, endpoint, client_id, client_secret, refresh_token):
response = requests.post(endpoint, data=payload, headers=headers) response = requests.post(endpoint, data=payload, headers=headers)
if response.status_code == 200: if response.status_code == 200:
print("Request was successful.") logging.info("Request was successful. Token was refreshed...")
else: else:
print("Request failed with status code:", response.status_code) logging.debug(f'Request failed with status code: {response.status_code}')
data = json.loads(response.text) data = json.loads(response.text)