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.
@@ -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,5 @@
1
+ # Inoopa's helpers
2
+
3
+ This repo contains helper functions we use in all of our python projects.
4
+
5
+ # 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,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
+
@@ -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()