inoopa_utils 1.0.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- inoopa_utils-1.0.0/.gitignore +53 -0
- inoopa_utils-1.0.0/PKG-INFO +15 -0
- inoopa_utils-1.0.0/README.md +5 -0
- inoopa_utils-1.0.0/pyproject.toml +18 -0
- inoopa_utils-1.0.0/requirements.txt +15 -0
- inoopa_utils-1.0.0/src/env_variables_helper.py +19 -0
- inoopa_utils-1.0.0/src/exceptions.py +8 -0
- inoopa_utils-1.0.0/src/inoopa_logging.py +39 -0
- inoopa_utils-1.0.0/src/rabbitmq_helpers.py +75 -0
- inoopa_utils-1.0.0/src/sql_helpers.py +100 -0
- inoopa_utils-1.0.0/src/tests/test_rabbitmq.py +35 -0
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
# These are some examples of commonly ignored file patterns.
|
|
2
|
+
# You should customize this list as applicable to your project.
|
|
3
|
+
# Learn more about .gitignore:
|
|
4
|
+
# https://www.atlassian.com/git/tutorials/saving-changes/gitignore
|
|
5
|
+
|
|
6
|
+
# Node artifact files
|
|
7
|
+
node_modules/
|
|
8
|
+
dist/
|
|
9
|
+
*__pycache__*
|
|
10
|
+
.env
|
|
11
|
+
venv
|
|
12
|
+
|
|
13
|
+
# Compiled Java class files
|
|
14
|
+
*.class
|
|
15
|
+
|
|
16
|
+
# Compiled Python bytecode
|
|
17
|
+
*.py[cod]
|
|
18
|
+
|
|
19
|
+
# Log files
|
|
20
|
+
*.log
|
|
21
|
+
|
|
22
|
+
# Package files
|
|
23
|
+
*.jar
|
|
24
|
+
|
|
25
|
+
# Maven
|
|
26
|
+
target/
|
|
27
|
+
dist/
|
|
28
|
+
|
|
29
|
+
# JetBrains IDE
|
|
30
|
+
.idea/
|
|
31
|
+
|
|
32
|
+
# Unit test reports
|
|
33
|
+
TEST*.xml
|
|
34
|
+
|
|
35
|
+
# Generated by MacOS
|
|
36
|
+
.DS_Store
|
|
37
|
+
|
|
38
|
+
# Generated by Windows
|
|
39
|
+
Thumbs.db
|
|
40
|
+
|
|
41
|
+
# Applications
|
|
42
|
+
*.app
|
|
43
|
+
*.exe
|
|
44
|
+
*.war
|
|
45
|
+
|
|
46
|
+
# Large media files
|
|
47
|
+
*.mp4
|
|
48
|
+
*.tiff
|
|
49
|
+
*.avi
|
|
50
|
+
*.flv
|
|
51
|
+
*.mov
|
|
52
|
+
*.wmv
|
|
53
|
+
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
Metadata-Version: 2.1
|
|
2
|
+
Name: inoopa_utils
|
|
3
|
+
Version: 1.0.0
|
|
4
|
+
Summary: Collection of python utils we use at Inoopa.
|
|
5
|
+
Project-URL: Homepage, https://bitbucket.org/inoopa/inoopa_utils/
|
|
6
|
+
Project-URL: Bug Tracker, https://bitbucket.org/inoopa/inoopa_utils/jira
|
|
7
|
+
Author-email: Inoopa <dev@inoopa.com>
|
|
8
|
+
Requires-Python: >=3.11
|
|
9
|
+
Description-Content-Type: text/markdown
|
|
10
|
+
|
|
11
|
+
# Inoopa's helpers
|
|
12
|
+
|
|
13
|
+
This repo contains helper functions we use in all of our python projects.
|
|
14
|
+
|
|
15
|
+
# This is pushed publicly to Pypi, so NEVER commit any secret here!
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "inoopa_utils"
|
|
7
|
+
version = "1.0.0"
|
|
8
|
+
authors = [
|
|
9
|
+
{ name="Inoopa", email="dev@inoopa.com" },
|
|
10
|
+
]
|
|
11
|
+
description = "Collection of python utils we use at Inoopa."
|
|
12
|
+
readme = "README.md"
|
|
13
|
+
requires-python = ">=3.11"
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
[project.urls]
|
|
17
|
+
"Homepage" = "https://bitbucket.org/inoopa/inoopa_utils/"
|
|
18
|
+
"Bug Tracker" = "https://bitbucket.org/inoopa/inoopa_utils/jira"
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
iniconfig==2.0.0
|
|
2
|
+
numpy==1.26.1
|
|
3
|
+
packaging==23.2
|
|
4
|
+
pamqp==2.3.0
|
|
5
|
+
pandas==2.1.2
|
|
6
|
+
pluggy==1.3.0
|
|
7
|
+
PyMySQL==1.1.0
|
|
8
|
+
pytest==7.4.3
|
|
9
|
+
python-dateutil==2.8.2
|
|
10
|
+
pytz==2023.3.post1
|
|
11
|
+
rabbitpy==2.0.1
|
|
12
|
+
six==1.16.0
|
|
13
|
+
SQLAlchemy==2.0.22
|
|
14
|
+
typing_extensions==4.8.0
|
|
15
|
+
tzdata==2023.3
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import os
|
|
2
|
+
from typing import Literal
|
|
3
|
+
|
|
4
|
+
from exceptions import MissingEnvVariable
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
EnvName = Literal["dev", "staging", "prod"]
|
|
8
|
+
|
|
9
|
+
def get_env_name() -> EnvName:
|
|
10
|
+
env = os.getenv("ENV")
|
|
11
|
+
|
|
12
|
+
if env is None:
|
|
13
|
+
raise MissingEnvVariable("Could not get 'ENV' env variable, therefore can't deduce logging_level.")
|
|
14
|
+
elif env in ["dev","staging", "prod"]:
|
|
15
|
+
return env
|
|
16
|
+
else:
|
|
17
|
+
raise ValueError(
|
|
18
|
+
f"'ENV' is set to: {env}. This is not a supported env! Please choose between: [dev, staging, prod]"
|
|
19
|
+
)
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
"""Base Inoopa configuration for python Logging."""
|
|
2
|
+
import logging
|
|
3
|
+
from typing import Literal
|
|
4
|
+
from datetime import datetime
|
|
5
|
+
|
|
6
|
+
from env_variables_helper import get_env_name
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
LoggingLevel = Literal["CRITICAL","FATAL","ERROR","WARN","WARNING","INFO","DEBUG"]
|
|
10
|
+
|
|
11
|
+
def create_logger(logger_name: str, logging_level: LoggingLevel | None = None) -> logging.Logger:
|
|
12
|
+
"""
|
|
13
|
+
Configure how logging should be done.
|
|
14
|
+
|
|
15
|
+
:param logger_name: The logger name to return.
|
|
16
|
+
:param logging_level: The level of logging to filter. If none, will deduce from "ENV" env variable:
|
|
17
|
+
'dev' will set logging_level to "DEBUG"
|
|
18
|
+
'staging' will set logging_level to "INFO"
|
|
19
|
+
'prod' will set logging_level to "INFO"
|
|
20
|
+
"""
|
|
21
|
+
logging.basicConfig(
|
|
22
|
+
# Define the log level for externals (libs) loggers
|
|
23
|
+
level=logging.ERROR,
|
|
24
|
+
handlers=[
|
|
25
|
+
# Write logs to file
|
|
26
|
+
logging.FileHandler(f"logs/{datetime.now().strftime('%d-%m-%Y_%H:%M')}.log"),
|
|
27
|
+
# Allow the logger to also log in console
|
|
28
|
+
logging.StreamHandler(),
|
|
29
|
+
],
|
|
30
|
+
format="%(asctime)s %(levelname)-8s %(name)-20s -> %(message)s",
|
|
31
|
+
datefmt="%d/%m/%Y %H:%M:%S",
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
if logging_level is None:
|
|
35
|
+
logging_level = "DEBUG" if get_env_name() == "dev" else "INFO"
|
|
36
|
+
|
|
37
|
+
logger = logging.getLogger(logger_name)
|
|
38
|
+
logger.setLevel(logging_level)
|
|
39
|
+
return logger
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
|
|
2
|
+
import os
|
|
3
|
+
import rabbitpy
|
|
4
|
+
from typing import List, Tuple
|
|
5
|
+
|
|
6
|
+
from inoopa_logging import create_logger
|
|
7
|
+
from exceptions import MissingEnvVariable
|
|
8
|
+
|
|
9
|
+
_logger = create_logger("INOOPA.UTILS.RABBITMQ_HELPERS")
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def _get_credentials_from_env() -> Tuple[str, str, str]:
|
|
13
|
+
env_variables = {
|
|
14
|
+
"queue_host": os.getenv("QUEUE_HOST"),
|
|
15
|
+
"queue_username": os.getenv("QUEUE_USERNAME"),
|
|
16
|
+
"queue_password": os.getenv("QUEUE_PASSWORD"),
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
missing_variables = []
|
|
20
|
+
for key, value in env_variables.items():
|
|
21
|
+
if value is None:
|
|
22
|
+
_logger.error(f"QUEUE env variable missing: {key}")
|
|
23
|
+
missing_variables.append(key)
|
|
24
|
+
if len(missing_variables) > 0:
|
|
25
|
+
raise MissingEnvVariable(f"Missing env variables: {missing_variables}")
|
|
26
|
+
|
|
27
|
+
return env_variables["queue_host"], env_variables['queue_username'], env_variables['queue_password']
|
|
28
|
+
|
|
29
|
+
def _get_queue_name_with_env(queue_name: str) -> str:
|
|
30
|
+
"""Helper function to add the env name to the queue name."""
|
|
31
|
+
env_name = os.getenv("ENV")
|
|
32
|
+
if env_name is None:
|
|
33
|
+
raise MissingEnvVariable("Missing env variable: ENV")
|
|
34
|
+
return f"{queue_name}_{env_name}"
|
|
35
|
+
|
|
36
|
+
def get_messages_from_queue(queue_name: str, queue_batch_size: int = 1) -> List[str]:
|
|
37
|
+
"""Helper function to get X messages from a rabbitMQ queue."""
|
|
38
|
+
host, username, password = _get_credentials_from_env()
|
|
39
|
+
queue_name_with_env = _get_queue_name_with_env(queue_name)
|
|
40
|
+
messages_body = []
|
|
41
|
+
_logger.info(f"Connecting to queue: {queue_name_with_env}...")
|
|
42
|
+
with rabbitpy.Connection(f"amqp://{username}:{password}@{host}/%2f") as conn:
|
|
43
|
+
with conn.channel() as channel:
|
|
44
|
+
queue = rabbitpy.Queue(channel, queue_name_with_env, durable=True)
|
|
45
|
+
queue.declare()
|
|
46
|
+
_logger.info("Connected to queue, reading messages...")
|
|
47
|
+
for i in range(queue_batch_size):
|
|
48
|
+
message = queue.get(acknowledge=True)
|
|
49
|
+
if message:
|
|
50
|
+
messages_body.append(message.body.decode())
|
|
51
|
+
# Tell the queue that this message has been read and should be removed from queue
|
|
52
|
+
message.ack()
|
|
53
|
+
_logger.info(f"Message {i+1}/{queue_batch_size} read")
|
|
54
|
+
else:
|
|
55
|
+
_logger.info("Queue empty, stoping...")
|
|
56
|
+
break
|
|
57
|
+
return messages_body
|
|
58
|
+
|
|
59
|
+
def push_to_queue(queue_name: str, message: str) -> None:
|
|
60
|
+
"""Helper function to push a message to a rabbitMQ queue."""
|
|
61
|
+
host, username, password = _get_credentials_from_env()
|
|
62
|
+
queue_name_with_env = _get_queue_name_with_env(queue_name)
|
|
63
|
+
|
|
64
|
+
_logger.info(f"Connecting to queue: {queue_name_with_env}...")
|
|
65
|
+
with rabbitpy.Connection(f"amqp://{username}:{password}@{host}/%2f") as conn:
|
|
66
|
+
with conn.channel() as channel:
|
|
67
|
+
queue = rabbitpy.Queue(channel, queue_name_with_env, durable=True)
|
|
68
|
+
queue.declare()
|
|
69
|
+
|
|
70
|
+
# Create a new message
|
|
71
|
+
msg = rabbitpy.Message(channel, message)
|
|
72
|
+
|
|
73
|
+
# Publish the message to the specified queue
|
|
74
|
+
msg.publish('', queue_name_with_env)
|
|
75
|
+
_logger.info(f"Message pushed to {queue_name_with_env}.")
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import os
|
|
2
|
+
from urllib.parse import quote_plus
|
|
3
|
+
from sqlalchemy import Engine, create_engine, text
|
|
4
|
+
|
|
5
|
+
from exceptions import MissingEnvVariable
|
|
6
|
+
from inoopa_logging import create_logger
|
|
7
|
+
from pandas import DataFrame
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
_logger = create_logger("INOOPA.UTILS.SQL_HELPERS")
|
|
11
|
+
|
|
12
|
+
def get_env_name():
|
|
13
|
+
return os.getenv("ENV", "dev").lower()
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def create_sql_engine(mysql_ca_cert_path: str = "/tmp/mysql_ca_cert.cert") -> Engine:
|
|
17
|
+
"""
|
|
18
|
+
Create a sqlalchemy engine to use to send SQL requests.
|
|
19
|
+
|
|
20
|
+
:param mysql_database: A string with the name of the database to connect to. Default is 'master' the prod database.
|
|
21
|
+
:param mysql_ca_certificate_path: Path where the sql ca certificate should be written to as a file.
|
|
22
|
+
:return: A sqlalchemy engine.
|
|
23
|
+
"""
|
|
24
|
+
connection_string = _get_sql_connection_string(mysql_ca_cert_path)
|
|
25
|
+
return create_engine(connection_string, connect_args={'ssl_ca': mysql_ca_cert_path})
|
|
26
|
+
|
|
27
|
+
def _get_sql_connection_string(mysql_ca_certificate_path: str) -> str:
|
|
28
|
+
"""
|
|
29
|
+
Create a connection string from env variables to connect to the MYSQL database.
|
|
30
|
+
Also create a mysql ca certificate file at specified path.
|
|
31
|
+
|
|
32
|
+
:param mysql_database: A string with the name of the database to connect to.
|
|
33
|
+
:param mysql_ca_certificate_path: Path where the sql ca certificate should be written to as a file.
|
|
34
|
+
:return: A formatted connection string for sql database
|
|
35
|
+
"""
|
|
36
|
+
env_variables = {
|
|
37
|
+
"mysql_host": os.getenv("MYSQL_HOST"),
|
|
38
|
+
"mysql_username": os.getenv("MYSQL_USERNAME"),
|
|
39
|
+
"mysql_password": os.getenv("MYSQL_PASSWORD"),
|
|
40
|
+
"mysql_database_name": os.getenv(f"MYSQL_DATABASE_NAME"),
|
|
41
|
+
"mysql_ca_certificate": os.getenv("MYSQL_CA_CERTIFICATE"),
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
missing_variables = []
|
|
45
|
+
for key, value in env_variables.items():
|
|
46
|
+
if value is None:
|
|
47
|
+
_logger.error(f"MYSQL env variable missing: {key}")
|
|
48
|
+
missing_variables.append(key)
|
|
49
|
+
if len(missing_variables) > 0:
|
|
50
|
+
raise MissingEnvVariable(f"Missing env variables: {missing_variables}")
|
|
51
|
+
|
|
52
|
+
# Create a ca certificate file to use to create your sql engine
|
|
53
|
+
# This is required with our DB security config
|
|
54
|
+
with open(mysql_ca_certificate_path, "w") as file:
|
|
55
|
+
# Convert the literal '\n' to the actual newline character
|
|
56
|
+
env_variables["mysql_ca_certificate"] = env_variables["mysql_ca_certificate"].replace('\\n', '\n')
|
|
57
|
+
file.write(env_variables["mysql_ca_certificate"])
|
|
58
|
+
_logger.info(f"MYSQL_CA_CERTIFICATE written to: {mysql_ca_certificate_path}")
|
|
59
|
+
|
|
60
|
+
return f"mysql+pymysql://{env_variables['mysql_username']}:{quote_plus(env_variables['mysql_password'])}@{env_variables['mysql_host']}/{env_variables['mysql_database_name']}"
|
|
61
|
+
|
|
62
|
+
def update_df_rows_in_db(df: DataFrame, table_name: str, engine: Engine, primary_key: str = "id") -> None:
|
|
63
|
+
"""
|
|
64
|
+
!!! READ THE FULL DOCSTRING BEFORE USAGE !!!
|
|
65
|
+
Update rows in a given SQL table based on the contents of a pandas DataFrame.
|
|
66
|
+
|
|
67
|
+
This function will update rows in the specified table based on the DataFrame's
|
|
68
|
+
rows using the "id" column as the primary key. Only the columns present in
|
|
69
|
+
the DataFrame will be updated, leaving other columns in the database untouched.
|
|
70
|
+
|
|
71
|
+
:param df: (pandas.DataFrame): The DataFrame containing the data to update.
|
|
72
|
+
It should have an "id" column which acts as the primary key.
|
|
73
|
+
:param table_name: The name of the SQL table to update.
|
|
74
|
+
|
|
75
|
+
Notes:
|
|
76
|
+
- We only use this because we don't manipulate much the data in the Dataframe. we follows this pattern:
|
|
77
|
+
- Get the data from db pd.Dataframe.read_sql(query)
|
|
78
|
+
- Update a few fields
|
|
79
|
+
- Push back the data to DB
|
|
80
|
+
- It is assumed that "id" is the primary key in both the DataFrame and the table.
|
|
81
|
+
- Be cautious when using this function to ensure that the DataFrame's structure
|
|
82
|
+
matches the table's structure, especially in terms of column data types.
|
|
83
|
+
"""
|
|
84
|
+
with engine.begin() as conn:
|
|
85
|
+
for _, row in df.iterrows():
|
|
86
|
+
# Construct an update statement
|
|
87
|
+
update_query = f"UPDATE {table_name} SET "
|
|
88
|
+
|
|
89
|
+
# Add each column to the update statement
|
|
90
|
+
update_parts = [f"`{col}` = :{col}" for col in df.columns if col != primary_key]
|
|
91
|
+
update_query += ", ".join(update_parts)
|
|
92
|
+
update_query += f" WHERE {primary_key} = :{primary_key}"
|
|
93
|
+
|
|
94
|
+
# Prepare the dictionary with the values from the row
|
|
95
|
+
values = {col: row[col] for col in df.columns if col != primary_key}
|
|
96
|
+
values[primary_key] = row[primary_key]
|
|
97
|
+
|
|
98
|
+
prepared_query = text(update_query).bindparams(**values)
|
|
99
|
+
_logger.debug(f"Update query: {prepared_query}")
|
|
100
|
+
conn.execute(prepared_query)
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import unittest
|
|
2
|
+
|
|
3
|
+
from rabbitmq_helpers import get_messages_from_queue, push_to_queue
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class IntegrationTestRabbitMQHelpers(unittest.TestCase):
|
|
7
|
+
"""Test class to ensure pushing & reading from queue works as expected."""
|
|
8
|
+
|
|
9
|
+
@classmethod
|
|
10
|
+
def setUpClass(cls):
|
|
11
|
+
"""Setup once for all tests"""
|
|
12
|
+
cls.queue_name = "updaters_integration_tests"
|
|
13
|
+
|
|
14
|
+
def test_push_and_get_message(self):
|
|
15
|
+
"""Push a message and then get it from the queue"""
|
|
16
|
+
|
|
17
|
+
# Given a message
|
|
18
|
+
test_message = "Integration test message"
|
|
19
|
+
|
|
20
|
+
# When pushing the message to the queue
|
|
21
|
+
push_to_queue(self.queue_name, test_message)
|
|
22
|
+
|
|
23
|
+
# Then it should be retrieved from the queue
|
|
24
|
+
messages = get_messages_from_queue(self.queue_name, queue_batch_size=1)
|
|
25
|
+
self.assertIn(test_message, messages)
|
|
26
|
+
|
|
27
|
+
@classmethod
|
|
28
|
+
def tearDownClass(cls):
|
|
29
|
+
"""Cleanup once after all tests"""
|
|
30
|
+
# Optionally, you can clear the queue if required
|
|
31
|
+
while get_messages_from_queue(cls.queue_name, queue_batch_size=1):
|
|
32
|
+
pass
|
|
33
|
+
|
|
34
|
+
if __name__ == '__main__':
|
|
35
|
+
unittest.main()
|