inoopa_utils 1.0.0__py3-none-any.whl

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.
@@ -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,8 @@
1
+ src/env_variables_helper.py,sha256=kBhK1uv5SGXprq6esCYms1QZRylxuTvgEOImvAQfGsY,537
2
+ src/exceptions.py,sha256=8porK_kXp5c6eVOSPgrGyOFjC27eAz3R6dD1JkbGRQM,144
3
+ src/inoopa_logging.py,sha256=iLAzPjjDUAB0L6l7UiI5lSoLCrK0y9BVGH-jRYrhgxU,1397
4
+ src/rabbitmq_helpers.py,sha256=cq6LJtxcggSgVXLLrP1AhL6Xxu3C2PRWuqrqqGbHJXc,3179
5
+ src/sql_helpers.py,sha256=9Q5pEtxXO61RfwFD030POkonOk-qstfZ5l6pmB2YK1I,4717
6
+ inoopa_utils-1.0.0.dist-info/METADATA,sha256=yynkZ9eR4Yg7gkr3xIUKH2Nykcva6VCIUkPaEMJtVAc,515
7
+ inoopa_utils-1.0.0.dist-info/WHEEL,sha256=9QBuHhg6FNW7lppboF2vKVbCGTVzsFykgRQjjlajrhA,87
8
+ inoopa_utils-1.0.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.18.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -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
+ )
src/exceptions.py ADDED
@@ -0,0 +1,8 @@
1
+ """List all our custom exceptions."""
2
+
3
+
4
+ class MissingEnvVariable(Exception):
5
+ """Raised if a critical env variable is missing."""
6
+
7
+ pass
8
+
src/inoopa_logging.py ADDED
@@ -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}.")
src/sql_helpers.py ADDED
@@ -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)