<?xml version="1.0" encoding="UTF-8"?><rss xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:atom="http://www.w3.org/2005/Atom" version="2.0" xmlns:media="http://search.yahoo.com/mrss/"><channel><title><![CDATA[Manugarri's blog]]></title><description><![CDATA[eventually I'll change this]]></description><link>https://blog.manugarri.com/</link><image><url>https://blog.manugarri.com/favicon.png</url><title>Manugarri&apos;s blog</title><link>https://blog.manugarri.com/</link></image><generator>Ghost 5.47</generator><lastBuildDate>Tue, 23 Jun 2026 19:37:35 GMT</lastBuildDate><atom:link href="https://blog.manugarri.com/rss/" rel="self" type="application/rss+xml"/><ttl>60</ttl><item><title><![CDATA[Things I like/dislike about GCP (when compared with AWS)]]></title><description><![CDATA[<p>I have been working with AWS (Amazon Web Services) over the past decade. At my current job at Semrush, I am lucky enough that I got to work with GCP (Google Cloud Platform) for a change. </p><p>After a year of experience with it, here are my very personal comments on</p>]]></description><link>https://blog.manugarri.com/things-i-like-dislike-about-gcp-aws/</link><guid isPermaLink="false">691f0f9c37807903db8dfa33</guid><dc:creator><![CDATA[Manuel Garrido]]></dc:creator><pubDate>Thu, 20 Nov 2025 15:04:42 GMT</pubDate><content:encoded><![CDATA[<p>I have been working with AWS (Amazon Web Services) over the past decade. At my current job at Semrush, I am lucky enough that I got to work with GCP (Google Cloud Platform) for a change. </p><p>After a year of experience with it, here are my very personal comments on GCP:</p><h3 id="things-i-dont-like-about-gcp-when-compared-with-aws">Things I DONT like about GCP (when compared with AWS):</h3><ul><li>Regarding Bigquery, &#xA0;I find it a major design flaw how Bigquery supports a single database per project. This means that you are forced to either separate domains by schema , which is very ugly, and leads to namespace separation like <code>teamA_stage</code> versus <code>teamB_stage</code> , etcetera, &#xA0;all in the same database. This lack of database selection in the same project makes things much more complicated when you are dealing with tools that follow normal convention that a project contains multiple databases, each with schemas and tables that are specific to the database domain. DBT for multiproject is particularly tricky to implement in an elegant way with Bigquery, particularly for monorepos where multiple teams push their own models to it.</li><li>Google Secret manager is very similar to the AWS counterpart, however, there is the major difference that by default, &#xA0;once a secret in gsm is deleted, its gone <strong>forever. </strong>A devops at semrush once applied a stale terraform by mistake and dropped a few secrets for our platform, even when we scalated the request to recover to the highest level, GCP tech support told us there was nothing to be done to recover the secrets. i asked tech support and even they couldn&apos;t recover, no soft-deletes or grace period. This is very bad, because usually secrets are one of the things that dont usually have backups (because they are inherently security risks), and one wrong terraform apply can essentially destroy full data pipelines or applications.</li><li>Interacting with Google Services in python is a experience orders of magnitude worse than working with AWS. When you are interacting with AWS services in python (a very common use case since python is now a major player in data engineering among other disciplines), you just need to install a single package ( <code>boto3</code>). You install boto3 in your environment, then everything works. With Google, each individual service requires its own packaging, and the documentation for these packages is severly lacking, to the point of sometimes not being sure which package was the official google package to interact with a particular Google Service.</li></ul><p>On the other hand, google python packages are so strongly versioned (due to protobuf apis I assume), &#xA0;that it is almost impossible to install certain dependencies. &#xA0;For example, when working with Apache Airflow, providers usually provide a single package with all of their specific code inside (these packages are usually named apache-airflow-providers-XXX , for example apache-airflow-providers-google). When working with AWS, package conflicts were rare, because AWS packages have a good balance of dependency requirements. &#xA0;However when working with google as an airflow provider on airflow, because airflow google provider contains all of google services in there, and each google service is added in there (with the specific package and the specific requirements), sometimes you can end up in a place where you just cant find a version of a package to install.<br></p><h3 id="things-i-do-like-about-gcp-when-compared-with-aws"><br>Things I DO like about GCP (when compared with AWS):</h3><ul><li>Provisioning virtual machines with <a href="https://docs.cloud.google.com/compute/docs/instances/creating-instance-with-custom-machine-type?ref=blog.manugarri.com">custom machine types</a> is neat, that way you dont need to figure out which machine micro/macro/biggie/smallie is the one with your cpu/memory requirements, and instead you can just provision a machine with machine type <code><code>custom-6-20480</code></code> for a machine with 6 cpus and 20gb memory.</li><li>The fact that Google Cloud Storage buckets have soft delete by default is a nice thing, it allows you to recover objects when you do an oppsie and delete some data. In AWS S3 buckets do have versioning, but it is an opt in setting, which means if you dont enable it, by the time you need to recover some data it will be too late.</li></ul>]]></content:encoded></item><item><title><![CDATA[AI Agents are getting surprisingly easy to implement]]></title><description><![CDATA[<p>As most people in the Data world, I have been more and more exposed to LLM based AI tools. <br>Things like Copilot or Perplexity have been adopted as new, better tools and have indeed made my regular workflow faster (to an extent).</p><p><strong>Side note</strong>: I don&apos;t understand why</p>]]></description><link>https://blog.manugarri.com/agentic-workflows-are-getting-surprisingly-easy-to-implement/</link><guid isPermaLink="false">6777cd0eb4e49403d7062241</guid><dc:creator><![CDATA[Manuel Garrido]]></dc:creator><pubDate>Fri, 03 Jan 2025 13:10:20 GMT</pubDate><content:encoded><![CDATA[<p>As most people in the Data world, I have been more and more exposed to LLM based AI tools. <br>Things like Copilot or Perplexity have been adopted as new, better tools and have indeed made my regular workflow faster (to an extent).</p><p><strong>Side note</strong>: I don&apos;t understand why we have spent over a decade saying <em>&apos;Don&apos;t call it AI, call it Machine Learning, because AI is a much broader spectrum that does not necessarily rely on training data&apos;</em>, but then OpenAI released ChatGPT a couple years ago and suddenly we are all forced to use the dreaded term again.</p><p>While the big AI companies are pushing towards bigger , smarter models (which makes sense, since bigger models are quite expensive to train and are a natural business moat to protect their position of monopoly), other voices are proposing something different. What if, instead of having massive models that can do anything (compose a song, &#xA0;then solve differential equations, then tell a joke), we had ensembles of small, focused LLM models cooperating with each other? <br><br>These LLM models are called <strong>AI Agents (or Agentic workflows), </strong>They usually have access to tools (aka, functions) and can take a general user input and decide which tools to call, or which agents to call.</p><p>Up until recently, building these Agents has been quite messy, with different frameworks showing up. &#xA0;<br>The most popular framework for LLM work is Langchain, which is extremely convoluted and unintuitive from a software engineering point of view.</p><p>Alternatively, one can use specific provider &#xA0;packages directly (like <a href="https://github.com/openai/openai-python?ref=blog.manugarri.com">Openai python client</a>), which makes things easier to modify, at the cost of more verbose code.</p><p>However, just a few days ago, Huggingface released <a href="https://huggingface.co/blog/smolagents?ref=blog.manugarri.com">smolagents</a>, a library that dramatically simplifies Agent development. Its a batteries included package that makes developing AI Agents a breeze. </p><p>Lets use an example to compare the difference between building an Agent from scratch vs using smolagents.</p><h2 id="building-a-currency-exchange-agent">Building a currency exchange Agent</h2><p>We will blatantly copy the <a href="https://www.newsletter.swirlai.com/p/building-ai-agents-from-scratch-part?ref=blog.manugarri.com">excelent tutorial</a> at SwirlAI newsletter and we will build an Agent that can take user queries, and perform currency conversion and return the converted currency.</p><h3 id="manual-implementation">Manual implementation</h3><p>To build the Agent manually, the core thing we need to implement is the Tool. Remember, the main difference between an LLM powered chatbot and an LLM powered Agent, is that Agents have access to tools (which can be tools that call other agents).</p><p>We define a generic <code>Tool</code> class and a decorator that can turn a python function into an LLM compatible tool just by reading its docstring.</p><!--kg-card-begin: markdown--><pre><code class="language-python">&quot;&quot;&quot;https://www.newsletter.swirlai.com/p/building-ai-agents-from-scratch-part&quot;&quot;&quot;
from dataclasses import dataclass
from typing import Dict, Any, Callable, get_type_hints, _GenericAlias, List
import inspect
import os
from openai import OpenAI
import json
import urllib

GITHUB_API_TOKEN = os.environ[&quot;GITHUB_API_TOKEN&quot;]

def parse_docstring_params(docstring: str) -&gt; Dict[str, str]:
    &quot;&quot;&quot;Extract parameter descriptions from docstring.&quot;&quot;&quot;
    if not docstring:
        return {}
    
    params = {}
    lines = docstring.split(&apos;\n&apos;)
    in_params = False
    current_param = None
    
    for line in lines:
        line = line.strip()
        if line.startswith(&apos;Parameters:&apos;):
            in_params = True
        elif in_params:
            if line.startswith(&apos;-&apos;) or line.startswith(&apos;*&apos;):
                current_param = line.lstrip(&apos;- *&apos;).split(&apos;:&apos;)[0].strip()
                params[current_param] = line.lstrip(&apos;- *&apos;).split(&apos;:&apos;)[1].strip()
            elif current_param and line:
                params[current_param] += &apos; &apos; + line.strip()
            elif not line:
                in_params = False

    return params

def get_type_description(type_hint: Any) -&gt; str:
    &quot;&quot;&quot;Get a human-readable description of a type hint.&quot;&quot;&quot;
    if isinstance(type_hint, _GenericAlias):
        if type_hint._name == &apos;Literal&apos;:
            return f&quot;one of {type_hint.__args__}&quot;
    return type_hint.__name__

@dataclass
class Tool:
    &quot;&quot;&quot;Tool class that can produce valid Agent function calls from function docstrings&quot;&quot;&quot;
    name: str
    description: str
    func: Callable[..., str]
    parameters: Dict[str, Dict[str, str]]

    def __call__(self, *args, **kwargs) -&gt; str:
        return self.func(*args, **kwargs)

def tool(name: str = None):
    def decorator(func: Callable[..., str]) -&gt; Tool:
        tool_name = name or func.__name__
        description = inspect.getdoc(func) or &quot;No description available&quot;

        type_hints = get_type_hints(func)
        param_docs = parse_docstring_params(description)
        sig = inspect.signature(func)

        params = {}
        for param_name, param in sig.parameters.items():
            params[param_name] = {
                &quot;type&quot;: get_type_description(type_hints.get(param_name, Any)),
                &quot;description&quot;: param_docs.get(param_name, &quot;No description available&quot;)
            }

        return Tool(
            name=tool_name,
            description=description.split(&apos;\n\n&apos;)[0],
            func=func,
            parameters=params
        )
    return decorator
</code></pre>
<!--kg-card-end: markdown--><p>This tool class takes a function docstring and generates json documentation of the expected inputs the tool will take. It will also add a description to the json that will be used in the System prompt to tell the LLM which tools it has access to and how to use them.<br><br>Now that we have the decorator, we can create the currency exchange function fairly easily.</p><!--kg-card-begin: markdown--><pre><code class="language-python">@tool()
def convert_currency(amount: float, from_currency: str, to_currency: str) -&gt; str:
    &quot;&quot;&quot;Converts currency using latest exchange rates.
    
    Parameters:
        - amount: Amount to convert
        - from_currency: Source currency code (e.g., USD)
        - to_currency: Target currency code (e.g., EUR)
    &quot;&quot;&quot;
    try:
        url = f&quot;https://open.er-api.com/v6/latest/{from_currency.upper()}&quot;
        with urllib.request.urlopen(url) as response:
            data = json.loads(response.read())

        if &quot;rates&quot; not in data:
            return &quot;Error: Could not fetch exchange rates&quot;

        rate = data[&quot;rates&quot;].get(to_currency.upper())
        if not rate:
            return f&quot;Error: No rate found for {to_currency}&quot;

        converted = amount * rate
        return f&quot;{amount} {from_currency.upper()} = {converted:.2f} {to_currency.upper()}&quot;

    except Exception as e:
        return f&quot;Error converting currency: {str(e)}&quot;
</code></pre>
<!--kg-card-end: markdown--><p>Next we need to build the <code>Agent</code> class, this will be the class in charge of answering the user queries, and it will have access to a list of tools.</p><!--kg-card-begin: markdown--><pre><code class="language-python">class Agent:
    def __init__(self):
        &quot;&quot;&quot;Initialize Agent with empty tool registry.&quot;&quot;&quot;
        self.client = OpenAI(
              base_url=&quot;https://models.inference.ai.azure.com&quot;,
              api_key=os.environ[&quot;GITHUB_API_TOKEN&quot;],
        )
        self.tools: Dict[str, Tool] = {}
    
    def add_tool(self, tool: Tool) -&gt; None:
        &quot;&quot;&quot;Register a new tool with the agent.&quot;&quot;&quot;
        self.tools[tool.name] = tool
    
    def get_available_tools(self) -&gt; List[str]:
        &quot;&quot;&quot;Get list of available tool descriptions.&quot;&quot;&quot;
        return [f&quot;{tool.name}: {tool.description}&quot; for tool in self.tools.values()]
    
    def use_tool(self, tool_name: str, **kwargs: Any) -&gt; str:
        &quot;&quot;&quot;Execute a specific tool with given arguments.&quot;&quot;&quot;
        if tool_name not in self.tools:
            raise ValueError(f&quot;Tool &apos;{tool_name}&apos; not found. Available tools: {list(self.tools.keys())}&quot;)
        
        tool = self.tools[tool_name]
        return tool.func(**kwargs)

    def create_system_prompt(self) -&gt; str:
        &quot;&quot;&quot;Create the system prompt for the LLM with available tools.&quot;&quot;&quot;
        tools_json = {
            &quot;role&quot;: &quot;AI Assistant&quot;,
            &quot;capabilities&quot;: [
                &quot;Using provided tools to help users when necessary&quot;,
                &quot;Responding directly without tools for questions that don&apos;t require tool usage&quot;,
                &quot;Planning efficient tool usage sequences&quot;
            ],
            &quot;instructions&quot;: [
                &quot;Use tools only when they are necessary for the task&quot;,
                &quot;If a query can be answered directly, respond with a simple message instead of using tools&quot;,
                &quot;When tools are needed, plan their usage efficiently to minimize tool calls&quot;
            ],
            &quot;tools&quot;: [
                {
                    &quot;name&quot;: tool.name,
                    &quot;description&quot;: tool.description,
                    &quot;parameters&quot;: {
                        name: {
                            &quot;type&quot;: info[&quot;type&quot;],
                            &quot;description&quot;: info[&quot;description&quot;]
                        }
                        for name, info in tool.parameters.items()
                    }
                }
                for tool in self.tools.values()
            ],
            &quot;response_format&quot;: {
                &quot;type&quot;: &quot;json&quot;,
                &quot;schema&quot;: {
                    &quot;requires_tools&quot;: {
                        &quot;type&quot;: &quot;boolean&quot;,
                        &quot;description&quot;: &quot;whether tools are needed for this query&quot;
                    },
                    &quot;direct_response&quot;: {
                        &quot;type&quot;: &quot;string&quot;,
                        &quot;description&quot;: &quot;response when no tools are needed&quot;,
                        &quot;optional&quot;: True
                    },
                    &quot;thought&quot;: {
                        &quot;type&quot;: &quot;string&quot;, 
                        &quot;description&quot;: &quot;reasoning about how to solve the task (when tools are needed)&quot;,
                        &quot;optional&quot;: True
                    },
                    &quot;plan&quot;: {
                        &quot;type&quot;: &quot;array&quot;,
                        &quot;items&quot;: {&quot;type&quot;: &quot;string&quot;},
                        &quot;description&quot;: &quot;steps to solve the task (when tools are needed)&quot;,
                        &quot;optional&quot;: True
                    },
                    &quot;tool_calls&quot;: {
                        &quot;type&quot;: &quot;array&quot;,
                        &quot;items&quot;: {
                            &quot;type&quot;: &quot;object&quot;,
                            &quot;properties&quot;: {
                                &quot;tool&quot;: {
                                    &quot;type&quot;: &quot;string&quot;,
                                    &quot;description&quot;: &quot;name of the tool&quot;
                                },
                                &quot;args&quot;: {
                                    &quot;type&quot;: &quot;object&quot;,
                                    &quot;description&quot;: &quot;parameters for the tool&quot;
                                }
                            }
                        },
                        &quot;description&quot;: &quot;tools to call in sequence (when tools are needed)&quot;,
                        &quot;optional&quot;: True
                    }
                },
                &quot;examples&quot;: [
                    {
                        &quot;query&quot;: &quot;Convert 100 USD to EUR&quot;,
                        &quot;response&quot;: {
                            &quot;requires_tools&quot;: True,
                            &quot;thought&quot;: &quot;I need to use the currency conversion tool to convert USD to EUR&quot;,
                            &quot;plan&quot;: [
                                &quot;Use convert_currency tool to convert 100 USD to EUR&quot;,
                                &quot;Return the conversion result&quot;
                            ],
                            &quot;tool_calls&quot;: [
                                {
                                    &quot;tool&quot;: &quot;convert_currency&quot;,
                                    &quot;args&quot;: {
                                        &quot;amount&quot;: 100,
                                        &quot;from_currency&quot;: &quot;USD&quot;, 
                                        &quot;to_currency&quot;: &quot;EUR&quot;
                                    }
                                }
                            ]
                        }
                    },
                    {
                        &quot;query&quot;: &quot;What&apos;s 500 Japanese Yen in British Pounds?&quot;,
                        &quot;response&quot;: {
                            &quot;requires_tools&quot;: True,
                            &quot;thought&quot;: &quot;I need to convert JPY to GBP using the currency converter&quot;,
                            &quot;plan&quot;: [
                                &quot;Use convert_currency tool to convert 500 JPY to GBP&quot;,
                                &quot;Return the conversion result&quot;
                            ],
                            &quot;tool_calls&quot;: [
                                {
                                    &quot;tool&quot;: &quot;convert_currency&quot;,
                                    &quot;args&quot;: {
                                        &quot;amount&quot;: 500,
                                        &quot;from_currency&quot;: &quot;JPY&quot;,
                                        &quot;to_currency&quot;: &quot;GBP&quot;
                                    }
                                }
                            ]
                        }
                    },
                    {
                        &quot;query&quot;: &quot;What currency does Japan use?&quot;,
                        &quot;response&quot;: {
                            &quot;requires_tools&quot;: False,
                            &quot;direct_response&quot;: &quot;Japan uses the Japanese Yen (JPY) as its official currency. This is common knowledge that doesn&apos;t require using the currency conversion tool.&quot;
                        }
                    }
                ]
            }
        }
        
        return f&quot;&quot;&quot;You are an AI assistant that helps users by providing direct answers or using tools when necessary.
Configuration, instructions, and available tools are provided in JSON format below:

{json.dumps(tools_json, indent=2)}

Always respond with a JSON object following the response_format schema above. 
Remember to use tools only when they are actually needed for the task.&quot;&quot;&quot;

    def plan(self, user_query: str) -&gt; Dict:
        &quot;&quot;&quot;Use LLM to create a plan for tool usage.&quot;&quot;&quot;
        messages = [
            {&quot;role&quot;: &quot;system&quot;, &quot;content&quot;: self.create_system_prompt()},
            {&quot;role&quot;: &quot;user&quot;, &quot;content&quot;: user_query}
        ]
        
        response = self.client.chat.completions.create(
            model=&quot;gpt-4o-mini&quot;,
            messages=messages,
            temperature=0
        )
        
        try:
            return json.loads(response.choices[0].message.content)
        except json.JSONDecodeError:
            raise ValueError(&quot;Failed to parse LLM response as JSON&quot;)

    def execute(self, user_query: str) -&gt; str:
        &quot;&quot;&quot;Execute the full pipeline: plan and execute tools.&quot;&quot;&quot;
        try:
            plan = self.plan(user_query)
            
            if not plan.get(&quot;requires_tools&quot;, True):
                return plan[&quot;direct_response&quot;]
            
            # Execute each tool in sequence
            results = []
            for tool_call in plan[&quot;tool_calls&quot;]:
                tool_name = tool_call[&quot;tool&quot;]
                tool_args = tool_call[&quot;args&quot;]
                result = self.use_tool(tool_name, **tool_args)
                results.append(result)
            
            # Combine results
            return f&quot;&quot;&quot;Thought: {plan[&apos;thought&apos;]}
Plan: {&apos;. &apos;.join(plan[&apos;plan&apos;])}
Results: {&apos;. &apos;.join(results)}&quot;&quot;&quot;
            
        except Exception as e:
            return f&quot;Error executing plan: {str(e)}&quot;
</code></pre>
<!--kg-card-end: markdown--><p>Most of the code in the Agent is the system prompt, and how to add the available tools as part of the prompt.</p><p>Notice how the Agent also has a plan step in which it decides if any tool has to be used, then all the required tools are executed sequentially. </p><p>Also, for the sake of simplicity, this Agent doesnt run a <a href="https://huggingface.co/docs/smolagents/conceptual_guides/react?ref=blog.manugarri.com">ReAct </a>(Reason, Act) loop, meaning all potential tools are decided at the planning step and there is no chance of reevaluating such plan based on the tools output. For example if one tool returns an error, then we wont have a way to adapt to that error.</p><p>Now if we want to build an app with an Agent that can do currency conversion we just have to do this.</p><!--kg-card-begin: markdown--><pre><code class="language-python">agent = Agent()
agent.add_tool(convert_currency)

query = &quot;I am traveling to Japan from Serbia, I have 1500 of local currency, how much of Japanese currency will I be able to get?&quot;

print(f&quot;\nQuery: {query}&quot;)
result = agent.execute(query)
print(result)
</code></pre>
<!--kg-card-end: markdown--><p>And this is the output:</p><figure class="kg-card kg-image-card kg-card-hascaption"><img src="https://blog.manugarri.com/content/images/2025/01/image.png" class="kg-image" alt loading="lazy" width="1168" height="77" srcset="https://blog.manugarri.com/content/images/size/w600/2025/01/image.png 600w, https://blog.manugarri.com/content/images/size/w1000/2025/01/image.png 1000w, https://blog.manugarri.com/content/images/2025/01/image.png 1168w" sizes="(min-width: 720px) 720px"><figcaption>Magic!</figcaption></figure><p>Pretty neat right?</p><h3 id="smolagents-implementation">SmolAgents implementation</h3><p>With <code>smolagents</code> all of the class definition is managed by the library. For convenience I will use the HFAPI model (which uses Huggingface Inference service). But you can use OpenAI as well.</p><!--kg-card-begin: markdown--><pre><code class="language-python">import json
import os
from typing import Optional
import urllib

from smolagents.agents import ToolCallingAgent
from smolagents import tool, HfApiModel, LiteLLMModel

GITHUB_API_TOKEN = os.environ[&quot;GITHUB_API_TOKEN&quot;]


&apos;&apos;&apos;
# NOTE, I dont have a personal OpenAI account, and Azure Inference API doesnt 
# Have access to tool calling models. So i will use the Hf Inference API model for this example.

model = LiteLLMModel(
    model_id=&quot;gpt-4o&quot;,
    api_base=&quot;https://models.inference.ai.azure.com&quot;,
    api_key=os.environ[&quot;GITHUB_API_TOKEN&quot;],
)
&apos;&apos;&apos;
model = HfApiModel(&quot;Qwen/Qwen2.5-72B-Instruct&quot;)

@tool
def convert_currency(amount: float, from_currency: str, to_currency: str) -&gt; str:
    &quot;&quot;&quot;
    Converts currency using latest exchange rates.
    
    Args:
        amount: Amount to convert
        from_currency: Source currency code (e.g., USD)
        to_currency: Target currency code (e.g., EUR)
    &quot;&quot;&quot;
    try:
        url = f&quot;https://open.er-api.com/v6/latest/{from_currency.upper()}&quot;
        with urllib.request.urlopen(url) as response:
            data = json.loads(response.read())

        if &quot;rates&quot; not in data:
            return &quot;Error: Could not fetch exchange rates&quot;

        rate = data[&quot;rates&quot;].get(to_currency.upper())
        if not rate:
            return f&quot;Error: No rate found for {to_currency}&quot;

        converted = amount * rate
        return f&quot;{amount} {from_currency.upper()} = {converted:.2f} {to_currency.upper()}&quot;

    except Exception as e:
        return f&quot;Error converting currency: {str(e)}&quot;

agent = ToolCallingAgent(tools=[convert_currency], model=model)
query = &quot;I am traveling to Japan from Serbia, I have 1500 of local currency, how much of Japanese currency will I be able to get?&quot;

result = agent.run(query)
print(result)

</code></pre>
<!--kg-card-end: markdown--><p>As you can see, now we only need to define the custom tool we require, usually this is all we will require in production, as the business logic is usually enclosed in this kind of custom functions.</p><p>Running this version we get the following output.</p><figure class="kg-card kg-image-card kg-card-hascaption"><img src="https://blog.manugarri.com/content/images/2025/01/image-1.png" class="kg-image" alt loading="lazy" width="1795" height="340" srcset="https://blog.manugarri.com/content/images/size/w600/2025/01/image-1.png 600w, https://blog.manugarri.com/content/images/size/w1000/2025/01/image-1.png 1000w, https://blog.manugarri.com/content/images/size/w1600/2025/01/image-1.png 1600w, https://blog.manugarri.com/content/images/2025/01/image-1.png 1795w" sizes="(min-width: 720px) 720px"><figcaption>More magic!</figcaption></figure><p> Running the smolagents model takes significantly longer (even when using the OpenAI model). &#xA0;It seems like some processing is happening even before the inference is called.<br><br>However, the benefits are tremendous. As you can see the results are nicer, since smolagents adds logging by default. And we can add a ton of modifications to the behaviour (such as planning) with just one or two keywords.</p><p>I am very excited about this library, and cant recommend checking<a href="https://github.com/huggingface/smolagents/blob/main/examples?ref=blog.manugarri.com"> their examples </a>enough.</p>]]></content:encoded></item><item><title><![CDATA[One day project: Wikiloc exporter]]></title><description><![CDATA[<p>Last weekend, I had some time to kill, and I thought I would tackle a minor pet peeve of mine. &#xA0;<br><br>My wife and I are trying to go on a hike at least once a month, we like to try different trails around our area. In Spain, the dominant</p>]]></description><link>https://blog.manugarri.com/one-day-project-wikiloc-exporter/</link><guid isPermaLink="false">6733393e11d43103d030a581</guid><dc:creator><![CDATA[Manuel Garrido]]></dc:creator><pubDate>Tue, 12 Nov 2024 14:36:41 GMT</pubDate><content:encoded><![CDATA[<p>Last weekend, I had some time to kill, and I thought I would tackle a minor pet peeve of mine. &#xA0;<br><br>My wife and I are trying to go on a hike at least once a month, we like to try different trails around our area. In Spain, the dominant site for trail information is <a href="https://www.wikiloc.com/?ref=blog.manugarri.com">wikiloc</a>. Its an awesome sime, with hundreds of trails ranging from beginner trails to very hard trails. <br><br>However, the site also uses &#xA0;freemium scheme that doesnt resonate well with me. &#xA0;See, you can see the trail on your browser for free, but for navigation functionalities, you have to pay X a month, which is a bit steep for a casual user like me. This means when you are walking the trail, you have to use your orientation skills to figure out where the hell you are on the map, or risk getting lost, which has happened to us more than once.</p><p>I thought, wouldnt it be great if I could extract the trail information into a map service with proper geolocation? &#xA0;How about <a href="https://maphub.net/?ref=blog.manugarri.com">MapHub</a>, one of the simplest free map providers that exist?</p><p>Turns out it was easier than expected, and <a href="https://github.com/manugarri/wikiloc-maphub-exporter?ref=blog.manugarri.com">here is the code</a> if you want to use it yourself.</p><h2 id="exploring-the-site">Exploring the site</h2><p>First thing I did was to explore wikiloc. Its a site choke full of functionalities, most of them behind a paywall unfortunately.</p><figure class="kg-card kg-image-card kg-card-hascaption"><img src="https://blog.manugarri.com/content/images/2024/11/image.png" class="kg-image" alt loading="lazy" width="1204" height="731" srcset="https://blog.manugarri.com/content/images/size/w600/2024/11/image.png 600w, https://blog.manugarri.com/content/images/size/w1000/2024/11/image.png 1000w, https://blog.manugarri.com/content/images/2024/11/image.png 1204w" sizes="(min-width: 720px) 720px"><figcaption>Wikiloc Trail page</figcaption></figure><p>As we can see on the bottom left of the map, Wikiloc uses leaflet, an awesome OSS library for map building. I have used it a few times for personal projects (<a href="https://blog.manugarri.com/ghost/#/editor/post/64723d6550473e03b42726c7">here </a>is an example). </p><p>What that means is that somewhere on the javascript side, there is a geojson that is being used to generate the leaflet trail (as a polyline). Let&apos;s see how to do that.</p><h2 id="extracting-the-data">Extracting the data</h2><p>By inspecting the site&apos;s code, we can see that the waypoints (meaning, the individual marquers we can see highlighting points of interest) are defined as Json linked data inside the site.</p><figure class="kg-card kg-image-card kg-card-hascaption"><img src="https://blog.manugarri.com/content/images/2024/11/image-1.png" class="kg-image" alt loading="lazy" width="1044" height="361" srcset="https://blog.manugarri.com/content/images/size/w600/2024/11/image-1.png 600w, https://blog.manugarri.com/content/images/size/w1000/2024/11/image-1.png 1000w, https://blog.manugarri.com/content/images/2024/11/image-1.png 1044w" sizes="(min-width: 720px) 720px"><figcaption>json LD data for a particular waypoint</figcaption></figure><p>That is great, we can explore the site to see how to fetch these points. Chances are, there is a javascript variable somewhere that makes use of those points to display them on the leaflet map.</p><p>We can inspect the variables on the site inside the developer console. &#xA0;I always use this simple snippet to print the variables defined at the window scope level, because the website devs have probably used a reasonable,meaningful name to define the map variables.</p><figure class="kg-card kg-code-card"><pre><code class="language-javascript">```javascript
var variables = {}
undefined
for (var name in this) {
    variables[name] = name;
    variables[name]=this[name]
}
```</code></pre><figcaption>gimme all the vars</figcaption></figure><p>After running this snippet and printing the variable names, we find 2 objects that provide us the information that we need, <code>mapData</code> and <code>trailMap</code></p><figure class="kg-card kg-image-card kg-card-hascaption"><img src="https://blog.manugarri.com/content/images/2024/11/image-2.png" class="kg-image" alt loading="lazy" width="402" height="297"><figcaption>bingo</figcaption></figure><p><code>mapData</code> contains the waypoints with their coordinates, names, and so on. We will fetch that information and add it to our exported map.</p><p><code>trailMap</code> contains the reference to leaflet itself (that is imported as a separate library). This means we can export the trail information (meaning, the trail course ) as a geojson easily since thats what Leaflet uses internally.</p><figure class="kg-card kg-image-card kg-card-hascaption"><img src="https://blog.manugarri.com/content/images/2024/11/image-3.png" class="kg-image" alt loading="lazy" width="193" height="110"><figcaption>bingo 2</figcaption></figure><p>In particular, we can export the layers of the leaflet map with this simple JS snippet.</p><pre><code class="language-Javascript">```javascript
var collection = {&apos;type&apos;:&apos;FeatureCollection&apos;,&apos;features&apos;:[]}; trailMap.eachLayer(function (layer) {if (typeof(layer.toGeoJSON) === &apos;function&apos;) collection.features.push(layer.toGeoJSON())}); 
```</code></pre><p>That snippet will export a variable with valid geojson representing the trail.</p><h3 id="exporting-the-data">Exporting the data</h3><p>Now that we know how to extract the data from the site, we just have to copy all the js code into a python script that will run the extraction for us.</p><p>Since we need to execute Javascript to extract the data, we will use a headless browser (playwright) to execute the js. </p><figure class="kg-card kg-image-card kg-card-hascaption"><img src="https://blog.manugarri.com/content/images/2024/11/image-4.png" class="kg-image" alt loading="lazy" width="860" height="756" srcset="https://blog.manugarri.com/content/images/size/w600/2024/11/image-4.png 600w, https://blog.manugarri.com/content/images/2024/11/image-4.png 860w" sizes="(min-width: 720px) 720px"><figcaption>extracting the trail data using playwright</figcaption></figure><p>Then we need to push the data into MapHub. &#xA0;This is super easy, as the only thing we need to do is to create a maphub account, make a api token, and use that token to submit http requests to their api &#xA0;endpoint to create the map.<br></p><figure class="kg-card kg-image-card kg-card-hascaption"><img src="https://blog.manugarri.com/content/images/2024/11/image-5.png" class="kg-image" alt loading="lazy" width="990" height="923" srcset="https://blog.manugarri.com/content/images/size/w600/2024/11/image-5.png 600w, https://blog.manugarri.com/content/images/2024/11/image-5.png 990w" sizes="(min-width: 720px) 720px"><figcaption>api request to create Maphub map from a geojson object</figcaption></figure><p>We can do some final aesthetic improvements too, for example its nice to have the initial waypoint for a trail colored in green, we can do that by specifying the point&apos;s properties. For example, to change the color of the point we just have to set the property <code>marker-color</code> to a different hexcode color.</p><p>And voila, here is the exported map in MapHub!</p><figure class="kg-card kg-image-card kg-card-hascaption"><img src="https://blog.manugarri.com/content/images/2024/11/image-7.png" class="kg-image" alt loading="lazy" width="606" height="790" srcset="https://blog.manugarri.com/content/images/size/w600/2024/11/image-7.png 600w, https://blog.manugarri.com/content/images/2024/11/image-7.png 606w"><figcaption>success!</figcaption></figure>]]></content:encoded></item><item><title><![CDATA[Four lessons from managing a company wide Airflow plugin]]></title><description><![CDATA[<p>At my current company (Carpe Data, <a href="https://www.carpe.io/company/careers/?ref=blog.manugarri.com#open-positions">we are hiring!</a>) , one of my tasks is to maintain an internal Airflow plugin (<code>common-airflow-utils</code>) with Airflow related utilities. The library is used by 5 different teams, powering a big part of our production worfklows. and it provides higher abstractions on top of the</p>]]></description><link>https://blog.manugarri.com/lessons-from/</link><guid isPermaLink="false">65d86554de617303dcfd4c58</guid><dc:creator><![CDATA[Manuel Garrido]]></dc:creator><pubDate>Tue, 23 Apr 2024 07:42:19 GMT</pubDate><content:encoded><![CDATA[<p>At my current company (Carpe Data, <a href="https://www.carpe.io/company/careers/?ref=blog.manugarri.com#open-positions">we are hiring!</a>) , one of my tasks is to maintain an internal Airflow plugin (<code>common-airflow-utils</code>) with Airflow related utilities. The library is used by 5 different teams, powering a big part of our production worfklows. and it provides higher abstractions on top of the airflow API, with the goals of standardizing Airflow practices, as well as making dag writing much easier, particularly for those teams where Python expertise is lacking.<br><br>For example, for a regular dag owner (the person writing DAG code), instead of launching an emr job via an <a href="https://airflow.apache.org/docs/apache-airflow-providers-amazon/stable/_api/airflow/providers/amazon/aws/operators/emr/index.html?ref=blog.manugarri.com#airflow.providers.amazon.aws.operators.emr.EmrCreateJobFlowOperator">EmrCreateJobFlowOperator </a>they can just call the common utilities function <code>create_emr_job_flow(*args, **kwargs)</code>.<br><br>This library has been 1 year in production, and there are a few things that I have learned from building and maintaining it:</p><h3 id="1taskflow-api-is-great-but-not-for-internal-library-functions">1.Taskflow api is great, but not for internal library functions</h3><p>When I joined Carpe, there was already some Airflow code dangling around. It wasn&apos;t great, but since my goal was to set up new Airflow libraries with (hopefully) better standards, I tried to keep existing code whenever possible. <br><br>This meant keeping some internal airflow plugin functions that followed the <a href="https://airflow.apache.org/docs/apache-airflow/stable/tutorial/taskflow.html?ref=blog.manugarri.com">Airflow Taskflow API</a>. In a nutshell, the Airflow Taskflow API is a new way (for Airflow 2.0.0 or higher) to define operators, using standard python functions instead of the class based operators that were used before Taskflow API was released.<br><br><br>At my previous company, we were using Airflow &lt; 2.0.0, and that meant I was not used to using the taskflow API. &#xA0;When I saw the ease of use to define operators using regular python functions, I was hooked. So much easier to use! So elegant! its just python with a magical <code>@task</code>decorator!. <br><br>So I released the first version of the common utils keeping some of the legacy code that used the taskflow API to build internal operators. &#xA0;<br><br>In retrospective, this wasn&apos;t a great idea.</p><p>A year has passed, and this library has grown, not only the number of operators it contains, but also in the number of teams who have adopted it as well as the number of people contributing to it.<br><br>Recently, we have had some major issues with the library, and one of the main reasons is the choice of taskflow api for internal custom operators.<br><br>I will explain this with an example.<br><br>Lets assume we have a very simple dag, that performs the following steps:<br>- &#xA0;1 . Run a sql query in snowflake, via a library call to <code>snowflake_operator</code><br>- 2. Decide based on the output of that query, whether to run the next step<br>- 3. if the branch on 2) is true, then we want to print the output of the result of 1).<br><br>Here is how our <code>snowflake_operator</code> looks like inside the common-airflow-utils library. We use AWS ECS to run our operators inside containers (more on that later):</p><!--kg-card-begin: markdown--><pre><code class="language-python">def snowflake_operator(
    task_id: str,
    sql_query: str,
):
    &quot;&quot;&quot;
    Runs the ECS snowflake job
    &quot;&quot;&quot;

    @task(multiple_outputs=True)
    def _setup_operator_args(
        sql_query,
    ):
        &quot;&quot;&quot;
        Function that evaluates the lazy airflow parameters so they can be used as regulard arguments downstream.
        &quot;&quot;&quot;
        return {
            &quot;sql_query&quot;: sql_query,
        }

    @task
    def _setup_ecs_command(
        sql_query,
    ):
        &quot;&quot;&quot;
        Generates the full ECS docker command.
        &quot;&quot;&quot;
        command = [&quot;--sql_query&quot;, sql_query]
        return command

    with TaskGroup(group_id=task_id) as task_group:
        args = _setup_operator_args(
            sql_query=sql_query,
        )

        command = _setup_ecs_command.override(trigger_rule=trigger_rule)(
            sql_query=args[&apos;sql_query&apos;],
        )

        run_ecs_operator.override(task_id=&apos;run_snowflake_query&apos;,
                                  )(
                                      task_id=task_id,
                                      container_name=&quot;snowflake&quot;,
                                      command=command,
                                  )

    return task_group
</code></pre>
<!--kg-card-end: markdown--><p><br><br><br>And here is how our dag would look like to use the snowflake operator.</p><!--kg-card-begin: markdown--><pre><code class="language-python">
from airflow.decorators import task

from commons_airflow_utils.dag import DAG
from commons_airflow_utils.operators.snowflake.snowflake_secretsmanager import snowflake_operator

DAG_ID = &quot;super_dag&quot;

with DAG(
    dag_id=DAG_ID,
    doc_md=__doc__
) as dag:
    # this operator queries snowflake and returns randomly a 1 or a 2
    run_snowflake_1 = snowflake_operator(
        task_id=&apos;run_snowflake_1&apos;,
        sql_query=&quot;&quot;&quot;
                WITH arr AS (SELECT array_construct(1, 2) arr),
                number_selection AS (SELECT arr[ABS(MOD(RANDOM(), array_size(arr)))] number FROM arr)
                SELECT number FROM number_selection;
        &quot;&quot;&quot;
    )

    @task.branch
    def choose_run_next_step(number_selection):
        if number_selection == &apos;2&apos;:
            return &apos;run_snowflake_2&apos;
        else:
            return &apos;skip&apos;

    @task
    def skip():
        print(&quot;SKIPPING&quot;)

    # snowflake python output has a weird format
    run_snowflake_2 = snowflake_operator(
        task_id=&apos;run_snowflake_2&apos;,
        sql_query=&quot;&quot;&quot;
                SELECT {{ task_instance.xcom_pull(task_ids=&apos;run_snowflake_1&apos;)[0][&apos;result&apos;][0][0] }} + 1;
        &quot;&quot;&quot;
    )

    @task
    def print_snowflake_2_result(result):
        print(result)

    run_snowflake_1 &gt;&gt; [skip(), run_snowflake_2] &gt;&gt; print_snowflake_2_result(run_snowflake_2)
</code></pre>
<!--kg-card-end: markdown--><p>The dag looks like this on airflow:<br><br>Very simple so far.<br><br>Now let&apos;s imagine that the business logic changes, and we decide we want to change the workflow a bit:</p><p>- &#xA0;1 . Run a sql query in snowflake, via a library call to <code>snowflake_operator</code><br>- 2. Add one to the output of 1) (*product decision!*)<br>- 3. Decide based on the output of that query, whether to run the next step<br>- 4. if the branch on 3) is equal to `2` , then we want to print the output of the result of 2).<br><br><br>No problem, we just have to update our DAG to add the step: <br></p><!--kg-card-begin: markdown--><pre><code class="language-python">   # this operator queries snowflake and returns randomly a 1 or a 2
    run_snowflake_1 = snowflake_operator(
        task_id=&apos;run_snowflake_1&apos;,
        sql_query=&quot;&quot;&quot;
                WITH arr AS (SELECT array_construct(1, 2) arr),
                number_selection AS (SELECT arr[ABS(MOD(RANDOM(), array_size(arr)))] number FROM arr)
                SELECT number FROM number_selection;
        &quot;&quot;&quot;
    )

    @task
    def add_one_to_snowflake_1_result(snowflake_result):
        print(snowflake_result)
        return snowflake_result[0][&apos;result&apos;][0][0] + 1

    snowflake_1_output_plus_one = add_one_to_snowflake_1_result(run_snowflake_1)

    @task.branch
    def choose_run_next_step(number_selection):
        if number_selection == &apos;2&apos;:
            return &apos;run_snowflake_2&apos;
        else:
            return &apos;skip&apos;

    @task
    def skip():
        print(&quot;SKIPPING&quot;)



    @task
    def print_snowflake_result(result):
        print(result)


    run_snowflake_2 = snowflake_operator(
        task_id=&apos;run_snowflake_2&apos;,
        sql_query=&quot;&quot;&quot;
                SELECT {{ task_instance.xcom_pull(task_ids=&apos;snowflake_1_output_plus_one&apos;) }} + 1;
        &quot;&quot;&quot;
    )

    print_snowflake_2_result = print_snowflake_result(run_snowflake_2)

    run_snowflake_1 &gt;&gt; snowflake_1_output_plus_one &gt;&gt; [skip(), run_snowflake_2] &gt;&gt; print_snowflake_2_result
</code></pre>
<!--kg-card-end: markdown--><p>we go to test the new update to the dag , and we get an error, PANIC!!<br><br>We see our dag failing, why? <br><br>Well, the error message is clear:<br></p><figure class="kg-card kg-image-card kg-card-hascaption"><img src="https://blog.manugarri.com/content/images/2024/02/image-1.png" class="kg-image" alt loading="lazy" width="637" height="69" srcset="https://blog.manugarri.com/content/images/size/w600/2024/02/image-1.png 600w, https://blog.manugarri.com/content/images/2024/02/image-1.png 637w"><figcaption>Sadness</figcaption></figure><p><br>Since the function snowflake_operator returns a task group, task groups have no output since they are not tasks per se. T<strong>ask groups are not lazily evaluated like tasks are</strong>, so you cant really use <code>{{}}</code> params, or xcoms inside them (only inside the internal tasks).</p><p>No problem, you think, let&apos;s fix that. We can just instead of returning the taskgroup, we can return the output of the last step inside the task, since the operator essentially runs a docker ecs command and its output is the only thing we care about. That way downstream tasks can easily interact with the output of the <code>snowflake_operator</code>.<br><br>Here is how the snowflake_operator looks like with that slight modification:<br></p><!--kg-card-begin: markdown--><pre><code class="language-python">def snowflake_operator(
    task_id: str,
    sql_query: str,
):
    &quot;&quot;&quot;
    Runs the ECS snowflake job
    &quot;&quot;&quot;

    @task(multiple_outputs=True)
    def _setup_operator_args(
        sql_query,
    ):
        &quot;&quot;&quot;
        Function that evaluates the lazy airflow parameters so they can be used as regulard arguments downstream.
        &quot;&quot;&quot;
        return {
            &quot;sql_query&quot;: sql_query,
        }

    @task
    def _setup_ecs_command(
        sql_query,
    ):
        &quot;&quot;&quot;
        Generates the full ECS docker command.
        &quot;&quot;&quot;
        command = [&quot;--sql_query&quot;, sql_query]
        return command

    with TaskGroup(group_id=task_id) as task_group:
        args = _setup_operator_args(
            sql_query=sql_query,
        )

        command = _setup_ecs_command.override(trigger_rule=trigger_rule)(
            sql_query=args[&apos;sql_query&apos;],
        )

        ecs_output = run_ecs_operator.override(task_id=&apos;run_snowflake_query&apos;,
                                  )(
                                      task_id=task_id,
                                      container_name=&quot;snowflake&quot;,
                                      command=command,
                                  )

    return ecs_output
</code></pre>
<!--kg-card-end: markdown--><p></p><p>We run the dag with the new version of the snowflake operator and with a few modifications, the dag works, yaaay!.<br><br>Wait a second, lets look at the dag structure:</p><figure class="kg-card kg-image-card kg-card-hascaption"><img src="https://blog.manugarri.com/content/images/2024/02/image--1-.png" class="kg-image" alt loading="lazy" width="1855" height="246" srcset="https://blog.manugarri.com/content/images/size/w600/2024/02/image--1-.png 600w, https://blog.manugarri.com/content/images/size/w1000/2024/02/image--1-.png 1000w, https://blog.manugarri.com/content/images/size/w1600/2024/02/image--1-.png 1600w, https://blog.manugarri.com/content/images/2024/02/image--1-.png 1855w" sizes="(min-width: 720px) 720px"><figcaption>Madness</figcaption></figure><p><br><br>The dependencies are all messed up! Now the dependencies are only pointing to the last step of the internal task group for <code>snowflake_operator</code>.<br><br>To make the dag work with this new operator change we have to change the branch operator to look like this:</p><!--kg-card-begin: markdown--><pre><code class="language-python">@task.branch
    def choose_run_next_step(number_selection):
        if number_selection == &apos;2&apos;:
            return &apos;run_snowflake_2.run_snowflake_query&apos;
        else:
            return &apos;skip&apos;
</code></pre>
<!--kg-card-end: markdown--><p>Since we now have to reference the internal step inside the taskgroup as the next step in the branch operator, that means the internal api of the common-airflow-utils library now is used by every one and it becomes essentially part of the public api (if you change it, things will break).</p><p>All of this effort for what? All of the benefits of writing vanilla python functions to build taskflow operators are gone since we are adding a lot of complexity just to manage the fact that <strong>task_groups are not tasks</strong>, and thus not lazily evaluated and cannot interact by themselves with airflow context.</p><p>Let&apos;s see now the snowflake_operator following the standard class based method to develop<a href="https://airflow.apache.org/docs/apache-airflow/stable/howto/custom-operator.html?ref=blog.manugarri.com"> custom operators</a>:</p><!--kg-card-begin: markdown--><pre><code class="language-python">from airflow.models.baseoperator import BaseOperator
from common_airflow_utils.ecs import run_ecs_operator_function

class SnowflakeECsOperator(BaseOperator):
    &quot;&quot;&quot;
    ECS Operator to run sql queries on Snowflake.
    &quot;&quot;&quot;

    ui_color = &quot;#abf0ff&quot;
    template_fields = (
        &quot;sql_query&quot;,

    )
    container_name = &quot;snowflake&quot;


    def __init__(
            self,
            sql_query: str,
            **kwargs
    ) -&gt; None:
        super().__init__(**kwargs)
        self.sql_query = sql_query

    def _setup_ecs_command(self):
        command = [&quot;--sql-query&quot;, self.sql_query]
        return command

    def execute(self, **context):  # pylint: disable=unused-argument
        &quot;&quot;&quot;
        Executes the ECS docker command and returns.
        &quot;&quot;&quot;
        command = self._setup_ecs_command()

        ecs_output = run_ecs_operator_function(
            task_id=self.task_id,
            container_name=self.container_name,
            task_version=self.task_version,
            command=command,
        )
        return  ecs_output

def snowflake_operator(
    task_id: str,
    sql_query: str,
):
    &quot;&quot;&quot;
    Runs the ECS snowflake job
    &quot;&quot;&quot;
    return SnowflakeECsOperator(task_id=task_id, sql_query=sql_query)
</code></pre>
<!--kg-card-end: markdown--><p>This operator is a single airflow task, this means branching and sequencing works out of the box, and the output can be easily accessed via the <code>.output</code> attribute. &#xA0;Since it inherits from BaseOperator, we can pass to it all of the standard arguments that airflow operators support (trigger_rules, retries, hooks, and so on).<br>Since its a single task, the scheduler has to track 3 times less objects. <br><br>Here is how the dag graph looks like now that we are using class based operators:</p><figure class="kg-card kg-image-card"><img src="https://blog.manugarri.com/content/images/2024/02/image--2-.png" class="kg-image" alt loading="lazy" width="1556" height="200" srcset="https://blog.manugarri.com/content/images/size/w600/2024/02/image--2-.png 600w, https://blog.manugarri.com/content/images/size/w1000/2024/02/image--2-.png 1000w, https://blog.manugarri.com/content/images/2024/02/image--2-.png 1556w" sizes="(min-width: 720px) 720px"></figure><p>Much simpler! And the thing is, from the point of view of the dag writer, <strong>they don&apos;t care about the internals of the snowflake operator, only that it receives a sql query and it runs it!</strong></p><h3 id="2airflow-uni-tests-are-hard-smoke-tests-are-less-hard"><br><br>2.Airflow uni-tests are hard , smoke tests are less hard</h3><p>Unittests are the first line of defense for software engineers, they check that all the individual parts of your codebase are working as you expect.</p><p>Unittests on airflow are very tricky, for a couple reasons.</p><p>First and foremost, Airflow dags and operators require an Airflow context to work. This means in production there is a separate process that takes care of the triggering, computing, state checking for the workflows.</p><p>If you read tutorials about airflow testing, you can see that its easy to test that the dags produce valid airflow code (as seen &#xA0;on the <a href="https://airflow.apache.org/docs/apache-airflow/stable/best-practices.html?ref=blog.manugarri.com#testing-a-dag">official docs</a>), or that the dags have the specifications you want (meaning, that if you want your dags to have <code>task2</code> after <code>task1</code> that is the case). &#xA0;</p><p>However, in practice most of the errors that happen with Airflow dags have nothing to do with those kind of bugs, in my experience errors usually happen because of intradependencies between tasks. Those are hard to unittest on airflow, and to be able to unittest properly you are forced to modify the actual implementation of your dags (see an example <a href="https://medium.com/@jw_ng/writing-unit-tests-for-an-airflow-dag-78f738fe6bfc?ref=blog.manugarri.com">here</a>). Changing production code to make testing easier is a big no no on my book (tests should adapt to production, not the other way around).</p><p>Another big set of issues have nothing to do with the code itself, but are related to the environment. Airflow being a monolithic orchestrator, requires tons of secrets/variables/connections to properly work. Implenting all of that complexity in a pure unittest suite is hard, that is why I usually have very lightweight unittests that ensure we avoid stupid mistakes (for example, if a dag is supposed to run daily, I want to make sure a dev doesnt remove the cron argument to work locally and pushes to prod). But I mostly test dags via local runs (using the excellent <a href="https://github.com/aws/aws-mwaa-local-runner?ref=blog.manugarri.com">aws repo</a> for local airflow running)</p><h3 id="3airflow-code-is-not-standard-python-code">3.Airflow code is not standard Python code</h3><p>Every framework is by definition based on a set of assumptions/modifications that make some things easier, at the cost of making other things harder, or plain impossible. </p><p>Airflow takes this to the next level, and every dev who is dealing with Airflow code probably has struggled to implement standard libraries for code quality in a way that do not crease false flags when dealing with DAG code. <br><br>At my current company, we use standard pre-commit plugins for code quality, plus Sonar for company wide static code analysis. <br><br>We have had to modify our pylint settings quite a bit in order to fit airflow specific quirks, one of the most common one is how airflow recommends settign up tasks dependencies via bitwise operators.<br><br>For example, if you want to define 2 tasks in a way that <code>task2</code> starts after <code>task1</code>, the recommended way to implement that dependency is this:</p><pre><code class="language-python">task1 = DummyOperator(...)
task2 = DummyOperator(...)
task1 &gt;&gt; task2</code></pre><p>Pylint will freak out at this with the very obvious error code <code>pointless statement</code> &#xA0;why would you do a bitwise operation if you are not going to assign the result to anything?<br>Sonar might also freak out depending on your company settings.</p><p>The way to disable these false alarms is to either disable them poroject wide (which means real issues wont be detected), or pepper your dag code with comments like these ones:<br></p><pre><code class="language-python">task1 = DummyOperator(...)
task2 = DummyOperator(...)
#this is sad
task1 &gt; task2 # pylint: disable=pointless statement #NOSONAR</code></pre><p>Another one of Airflow&apos;s &#xA0;quirks that doesnt play well with vanilla code analysers is the top level imports. As stated on <a href="https://github.com/aws/aws-mwaa-local-runner?ref=blog.manugarri.com">Airflow&apos;s Best Practices docs</a>:<br></p><blockquote>...if you have an import statement that takes a long time or the imported module itself executes code at the top-level, that can also impact the performance of the scheduler.</blockquote><p>Which goes against the most basic of python PEP8 guidelines (imports at the top of the files).<br><br>Pylint rightly will throw the error <code><a href="https://pylint.readthedocs.io/en/latest/user_guide/messages/convention/import-outside-toplevel.html?ref=blog.manugarri.com">import-outside-top-level</a></code> &#xA0;(which again ,you can bypass with a <code>pylint disable</code> statement. </p><h3 id="4containerized-operators-are-great">4.Containerized operators are great</h3><p>My current job is the third one in which I am in charge of managing Airflow environments. This time, I knew I would do something different to avoid dependency conflicts. <br><br>One of the biggest caveats of Airflow in my opinion is that software dependencies (i.e. python packages) are shared. This means if an Airflow environment is used by multiple tenants, the environment will need to support the requirements of every single workflow of every single team using the environment. If a team requires <code>requests&lt;2.0.0</code> and another team requires <code>requests&gt;2.0.0</code> , then both teams cant use the same environment.<br><br>Worse still, when updating the environment, no matter if you are running airflow on premises or using a service like AWS MWAA, the installation does not validate the compatibility between packages, meaning that conflict between packages can bring the whole scheduler down, putting the admin in the uncomfortable position of knowing that every minute the Airflow environment is broken <strong>not a single job</strong> <strong>can run</strong>. I have been there a few times and trust me, its quite stressful.<br><br> Fortunately, there is a very nice way to avoid dependency issues on Airflow, using <strong>container operators!</strong>.<br><br> What are container operators? Simply put, these are airflow operators where the computation doesnt happen in the same environment the airflow scheduler or workers are, but inside a container, whether a pure Docker container (<a href="https://airflow.apache.org/docs/apache-airflow-providers-docker/stable/_api/airflow/providers/docker/operators/docker/index.html?ref=blog.manugarri.com#airflow.providers.docker.operators.docker.DockerOperator">DockerOperator </a>or <a href="https://airflow.apache.org/docs/apache-airflow-providers-cncf-kubernetes/stable/_api/airflow/providers/cncf/kubernetes/operators/pod/index.html?ref=blog.manugarri.com#airflow.providers.cncf.kubernetes.operators.pod.KubernetesPodOperator">KubernetesPodOperator</a>), or a cloud based container (<a href="https://airflow.apache.org/docs/apache-airflow-providers-amazon/stable/_api/airflow/providers/amazon/aws/operators/ecs/index.html?ref=blog.manugarri.com#airflow.providers.amazon.aws.operators.ecs.EcsRunTaskOperator">ECSRunTaskOperator </a>for AWS or <a href="https://airflow.apache.org/docs/apache-airflow-providers-google/stable/_api/airflow/providers/google/cloud/operators/cloud_run/index.html?ref=blog.manugarri.com#airflow.providers.google.cloud.operators.cloud_run.CloudRunExecuteJobOperator">CloudRunExecuteJobOperator </a>for Google Cloud Services).<br><br> Since these containers run docker images, they can be tagged, versioned, and isolated. Even better, they enable airflow to run workflows on any language! The best part of them though, is that their computation requirements are isolated, meaning heavy tasks cant bring down the worker. We had a task at my current company that was run occasionally, but that required a lot of memory. It would sometimes bring the worker down. Moving it to ECS allowed us to define specific memory requirements for the task. <br><br>Thats it, I hope you liked the article! </p>]]></content:encoded></item><item><title><![CDATA[Thing i learned migrating from Digital Ocean to AWS Lightsail]]></title><description><![CDATA[<p>Its been 9 years since I spin up my Digital Ocean instance. It has been hosting my personal site, blog, random personal projects, and even faced one or two Hacker news front page traffic spikes.</p><p></p><figure class="kg-card kg-image-card"><img src="https://blog.manugarri.com/content/images/2023/06/image.png" class="kg-image" alt loading="lazy" width="1621" height="452" srcset="https://blog.manugarri.com/content/images/size/w600/2023/06/image.png 600w, https://blog.manugarri.com/content/images/size/w1000/2023/06/image.png 1000w, https://blog.manugarri.com/content/images/size/w1600/2023/06/image.png 1600w, https://blog.manugarri.com/content/images/2023/06/image.png 1621w" sizes="(min-width: 720px) 720px"></figure><h1></h1><p>Why named <code>dokku</code> you wonder, oh young reader? Well, back before kubernetes was a thing,</p>]]></description><link>https://blog.manugarri.com/bye-bye-digital-ocean/</link><guid isPermaLink="false">648d8cc58b973903b48a4892</guid><dc:creator><![CDATA[Manuel Garrido]]></dc:creator><pubDate>Sat, 17 Jun 2023 15:14:45 GMT</pubDate><content:encoded><![CDATA[<p>Its been 9 years since I spin up my Digital Ocean instance. It has been hosting my personal site, blog, random personal projects, and even faced one or two Hacker news front page traffic spikes.</p><p></p><figure class="kg-card kg-image-card"><img src="https://blog.manugarri.com/content/images/2023/06/image.png" class="kg-image" alt loading="lazy" width="1621" height="452" srcset="https://blog.manugarri.com/content/images/size/w600/2023/06/image.png 600w, https://blog.manugarri.com/content/images/size/w1000/2023/06/image.png 1000w, https://blog.manugarri.com/content/images/size/w1600/2023/06/image.png 1600w, https://blog.manugarri.com/content/images/2023/06/image.png 1621w" sizes="(min-width: 720px) 720px"></figure><h1></h1><p>Why named <code>dokku</code> you wonder, oh young reader? Well, back before kubernetes was a thing, and right when docker was starting to become popular, a tool came up that promised an easy way to maintain your own Platform as a Service, <a href="https://github.com/dokku/dokku?ref=blog.manugarri.com">dokku</a>. When I set up my machine I planned to use dokku for every project. Reality showed me that deploying to dokku was just too much effort when I owned the whole infrastructure ( a single instance).</p><p>That actually meant, my tiny Digital Ocean became a dumpster fire, full of random folders I did not know what they were doing, different database engines installed (and their daemons still kicking) and random broken python virtual environments. </p><p>Additionally, since my domain ended up becoming semi popular for a while, it was somehow added to botlists of domains. So it has been constantly under attack of bots, bringing down some of the services I personally use. Due to my incompetence when I set up the instance, things like https/cloudflare were not an option.</p><p>So my plan was to migrate to a new instance. In particular, I wanted to use AWS Lightsail, mostly because thats the cloud platform im most comfortable with and it opened the door to potentially more complex projects. </p><p>It&apos;s been a while since I had time to nerd out and work on some side projects (having kids is the ultimate time sink). But after postponing the migration for a while, I finally killed my trusty DO instance today.</p><p>Here are some random thoughts I wrote while I was painstainkingly migrating the services and hopefully, setting them up on a more structured way.</p><ul><li><strong>https is hard</strong>! not extremely hard, but i could see how some less technical folks have a hard time getting it setup. <a href="letsencrypt.org">Letsencrypt</a> seems to be the only free way to spin up certificates currently, and there are a few magical commands that need to be run in order to set up certificates the right way. Related to this:</li></ul><p>&#x2003;&#x2003;- &#xA0;You need to enable 443 for https on light sail, I found no mention of this when googling <em>&apos;lightsail setup https&apos;</em>. I understand most people that use lightsail just want a prepackaged wordpress, but that is not always the case.</p><p>&#x2003;- certbot autodefault nginx settings do not work if you use a custom subdomain (my blog is hosted at blog.manugarri.com). certbot is awesome nonetheless.</p><ul><li>I use Ghost as my blogging platform. ghost blog is tremendously unhelpful when you want to migrate content. I tried importing the content from the old blog and i just got the message:</li></ul><!--kg-card-begin: markdown--><p><code>&quot;Please install Ghost 1.0, import the file and then update your blog to the latest Ghost version.\nVisit https://ghost.org/docs/update/ or ask for help in our https://forum.ghost.org.&quot;IncorrectUsageError: Detected unsupported file structure.</code></p>
<!--kg-card-end: markdown--><p>which i understand, but i fail to see how hard it would be to keep backwards compatibility for what is basically a simple json structure like this:</p><pre><code>{
  &quot;id&quot;: 2,
  &quot;uuid&quot;: &quot;de30db4d-fdde-48b8-8548-fd3c9804cfb0&quot;,
  &quot;title&quot;: &quot;How to easily set up Subdomain routing in Nginx&quot;,
  &quot;slug&quot;: &quot;how-to-easily-set-up-subdomain-routing-in-nginx&quot;,
  &quot;markdown&quot;: &quot;ARTICLE MARKDOWN HERE&quot;,
  &quot;image&quot;: null,
  &quot;featured&quot;: 0,
  &quot;page&quot;: 0,
  &quot;status&quot;: &quot;published&quot;,
  &quot;language&quot;: &quot;en_US&quot;,
  &quot;meta_title&quot;: null,
  &quot;meta_description&quot;: null,
  &quot;author_id&quot;: 1,
  &quot;created_at&quot;: &quot;2014-09-30 00:42:27&quot;,
  &quot;created_by&quot;: 1,
  &quot;updated_at&quot;: &quot;2014-09-30 03:21:56&quot;,
  &quot;updated_by&quot;: 1,
  &quot;published_at&quot;: &quot;2014-09-30 00:42:27&quot;,
  &quot;published_by&quot;: 1,
  &quot;visibility&quot;: &quot;public&quot;,
  &quot;mobiledoc&quot;: null,
  &quot;amp&quot;: null
}</code></pre><p>like, which killer feature was so extremely awesome yet so critically different that it forced people go to the hoops of spinning back an old ghost instance, fight with the updates, then dump then migrate? the content is the same for fucks sake.</p><p>I had to build my own shitty script to take a valid (empty) dump from my new blog instance, then make the old dump compatible by adding the missing fields: </p><pre><code>import json
from copy import deepcopy
from itertools import islice
def batched(iterable, chunk_size):
    iterator = iter(iterable)
    while chunk := tuple(islice(iterator, chunk_size)):
        yield chunk


valid_file = &quot;manugarris-blog.ghost.2023-05-27-15-13-43.pretty.json&quot;
posts_file = &quot;manuel-garridos-blog.ghost.2023-05-27.pretty.json&quot;

with open(valid_file) as fname: valid_file_data = json.load(fname)

with open(posts_file) as fname: valid_posts_data = json.load(fname)

posts = valid_posts_data[&quot;db&quot;][0][&quot;data&quot;][&quot;posts&quot;]

n_posts_per_batch = 10

i = 0
for posts_batch in batched(posts, n_posts_per_batch):
    posts_authors = [
          {
            &quot;id&quot;: post[&quot;id&quot;],
            &quot;post_id&quot;: post[&quot;id&quot;],
            &quot;author_id&quot;: &quot;1&quot;,
            &quot;sort_order&quot;: 0
          }
          for post in posts_batch
    ]

    valid_file_data[&quot;db&quot;][0][&quot;data&quot;][&quot;posts&quot;] = posts_batch
    valid_file_data[&quot;db&quot;][0][&quot;data&quot;][&quot;posts_authors&quot;] = posts_authors
    with open(f&quot;batch_dump.{i}.json&quot;, &quot;w&quot;) as fname:
        print(f&quot;batch_dump.{i}.json&quot;)
        json.dump(valid_file_data, fname)</code></pre><p>Any way, the migration took 3 saturdays, so it wasnt the end of the world. Im amazed that I have been able to run so many side projects/sites/blogs on a 5USD/month instance for 9 years without updating it. &#xA0;Having my own machine allowed me to grow significantly as an engineer, and the cost was totally worth it.</p>]]></content:encoded></item><item><title><![CDATA[NOTES: Setting up git after a fresh install.]]></title><description><![CDATA[<p>Recently I did a fresh install of Ubuntu 20.04 via WSL2 (which i don&apos;t love yet but its growing on me), and I had to do the following steps to set git up:</p><p><strong>1.Install git (duh!)</strong>Im just putting it here for completion sake</p><pre><code>sudo apt-get</code></pre>]]></description><link>https://blog.manugarri.com/notes-setting-up-git-after-a-fresh-install/</link><guid isPermaLink="false">64723d6550473e03b42726c8</guid><dc:creator><![CDATA[Manuel Garrido]]></dc:creator><pubDate>Mon, 30 May 2022 20:07:45 GMT</pubDate><content:encoded><![CDATA[<p>Recently I did a fresh install of Ubuntu 20.04 via WSL2 (which i don&apos;t love yet but its growing on me), and I had to do the following steps to set git up:</p><p><strong>1.Install git (duh!)</strong>Im just putting it here for completion sake</p><pre><code>sudo apt-get update  
sudo apt-get install git  
</code></pre><p><strong>2. Add ssh keys</strong></p><p>You have to add your desired keys to your ssh agent, found this on <a href="https://stackoverflow.com/questions/10032461/git-keeps-asking-me-for-my-ssh-key-passphrase?ref=blog.manugarri.com">Stack overflow and many other places</a>.</p><pre><code>eval $(ssh-agent)  
ssh-add  
</code></pre><p>These commands permanently add your ssh keys to your keychain and will skip having to ask the passphrase any time you want to clone a repository via git.</p><p><strong>3.Update git config.</strong></p><p>Due to recent updates to github&apos;s git protocol implementation (<a href="https://github.blog/2021-09-01-improving-git-protocol-security-github/?ref=blog.manugarri.com#no-more-unauthenticated-git">implemented</a> as of January 11 2022) , it is not enough to add ssh keys (RSA, DSA are deprecated), you have to change your local git configuration (nice explanation on <a href="https://stackoverflow.com/questions/70663523/the-unauthenticated-git-protocol-on-port-9418-is-no-longer-supported?ref=blog.manugarri.com">Stack Overflow</a>):</p><pre><code>git config --global url.&quot;git@github.com:&quot;.insteadOf git://github.com/  
</code></pre>]]></content:encoded></item><item><title><![CDATA[Making a simple, better weather and traffic conditions map for Spain's roads]]></title><description><![CDATA[<h2 id="tldr">TL;DR</h2><p>I made a map displaying weather and traffic road conditions for Spain that is easier to use, nicer and faster than the official Spanish Government map. <br>As usual (<a href="https://blog.manugarri.com/where-the-f-can-i-park/">1</a>, <a href="https://blog.manugarri.com/the-sorry-state-of-transparency-in-spain-where-is-my-certificate/">2</a>), I keep being disappointed with the Spanish Government Open Data policies.</p><p>You can check out the map <a href="http://dgt.manugarri.com/?ref=blog.manugarri.com">here</a></p>]]></description><link>https://blog.manugarri.com/making-a-simple-better-road-conditions-map-for-spain/</link><guid isPermaLink="false">64723d6550473e03b42726c7</guid><dc:creator><![CDATA[Manuel Garrido]]></dc:creator><pubDate>Sat, 23 Jan 2021 10:42:29 GMT</pubDate><content:encoded><![CDATA[<h2 id="tldr">TL;DR</h2><p>I made a map displaying weather and traffic road conditions for Spain that is easier to use, nicer and faster than the official Spanish Government map. <br>As usual (<a href="https://blog.manugarri.com/where-the-f-can-i-park/">1</a>, <a href="https://blog.manugarri.com/the-sorry-state-of-transparency-in-spain-where-is-my-certificate/">2</a>), I keep being disappointed with the Spanish Government Open Data policies.</p><p>You can check out the map <a href="http://dgt.manugarri.com/?ref=blog.manugarri.com">here</a>. I also shared the required code on <a href="https://github.com/manugarri/dgt_map?ref=blog.manugarri.com">Github</a>.</p><h2 id="an-introduction-and-a-bit-of-ranting">An introduction, and a bit of ranting.</h2><p>It was January 2021, and I was spending the holidays with my family in my awesome hometown of <a href="https://blog.manugarri.com/plotting-100k-tweets-from-my-home-town/">Murcia, Spain</a>.</p><p>For you future travelers, this year was the COVID pandemic year, so things were a bit weird and traveling wasnt as easy as you whipersnappers are used to. Me and my family (two kids at the moment of writing this) would be driving back from Murcia to Lisbon, Portugal where I reside.</p><p>Additionally, this year saw record breaking snowstorms in Spain thanks to (<a href="https://www.bbc.com/news/world-europe-55612955?ref=blog.manugarri.com">Storm Filomena</a>).</p><p>These two reasons meant that going back home to Lisbon from my hometown required planning, since there was a real risk of getting stuck in the car with 2 crying babies (omg Im shivering just thinking about it).</p><p>So a couple days before the travel day, I checked online to see any information on the roads.</p><p>The best resource I could find (<strong>and if there is a better resource, the fact that it cant be easily found defeats its purpose</strong>) was the official Spain Traffic Authority (DGT, <em>Direccion General de Trafico</em>) Map. You can check it <a href="http://infocar.dgt.es/etraffic/?ref=blog.manugarri.com">here</a></p><p>Here is a screenshot of how the map looks like: <br></p><figure class="kg-card kg-image-card"><img src="https://i.imgur.com/wAbVIe9.png" class="kg-image" alt="ugh" loading="lazy"></figure><p>There are a few things that trigger me when I see this map:</p><ul><li><em>Slow</em>, this map seems to be an embedded map from an internal GIS system or something, plus it has a ton of features that makes it pretty slow.</li><li><em>Confusing</em> no legend regarding event types</li><li><em>Overall Ugliness</em>, you can tell icons just jam up next to each other, and they are mostly gray, with a tiny hint of color indicating the road circulation level.</li></ul><p>But the worst thing of all, <em>there is no navigation search!</em>. &#xA0;This means the official traffic map forces you to know the actual code of the road you are planning to drive on in order to see if there are any weather events affecting the road&apos;s state. <strong>Im not a truck driver! I don&apos;t know these names!</strong></p><p>What I needed at that uncertain time was to be able <strong>to find out if driving from point A to Point B would go through any road that was blocked for any reason</strong>. The only way to do so with the official map was to search in google maps for driving directions and then check the DGT map for any road event.</p><h2 id="building-a-better-map">Building a better map</h2><p>Here is my version (<a href="http://dgt.manugarri.com/?ref=blog.manugarri.com">link</a>)</p><figure class="kg-card kg-image-card"><img src="https://i.imgur.com/2uMnyqi.png" class="kg-image" alt="tadaa!" loading="lazy"></figure><p>You may notice a few differences between my map and DGT map:</p><ul><li><em>fast</em>, my map consist of a simple map, so the only loading consists of the map tiles themselves</li><li><em>easy to read</em> , my map has an actual legend that indicates what each icon means. This is Dataviz 101</li><li><em>pretty</em>, this is more of a personal opinion, but using brighter colors for the event icons makes the map more appealing, and visual appeal increases user engagement.</li></ul><p>And most important of all, <strong>you can get see the road conditions for the trip you are planning!</strong>. Just type the origin and destination and click the button, and the map will plot the route.</p><figure class="kg-card kg-image-card"><img src="https://i.imgur.com/PHk7NjD.png" class="kg-image" alt="it aint hard" loading="lazy"></figure><h2 id="application-details">Application Details</h2><p>You can see the code powering the map on <a href="https://github.com/manugarri/dgt_map?ref=blog.manugarri.com">Github</a>.</p><p>My initial idea was to implement the map as a 100% frontend solution, since that would keep the site from exploding if it becomes too popular, &#xA0;but due to <a href="https://developer.mozilla.org/en-US/docs/Web/HTTP/CORS?ref=blog.manugarri.com">CORS</a> limitations I had to implement a simple backend to fetch the DGT road condition events.</p><p>The backend app is a <a href="https://fastapi.tiangolo.com/?ref=blog.manugarri.com">FastAPI</a> web application in charge of rendering the index page, fetching the road condition events, and geocoding the navigation search terms. It is the first time I use FastAPI, and its super easy to use and faster than other similar microframeworks (like Flask)</p><p>The map itself is a simple Leaflet map with overlaid events, when the user loads the map, an HTTP GET request fetches the official DGT road conditions data from <a href="http://infocar.dgt.es/etraffic/BuscarElementos?latNS=44&amp;longNS=5&amp;latSW=27&amp;longSW=-19&amp;zoom=6&amp;accion=getElementos&amp;Camaras=true&amp;SensoresTrafico=true&amp;SensoresMeteorologico=true&amp;Paneles=true&amp;Radares=true&amp;IncidenciasRETENCION=true&amp;IncidenciasOBRAS=false&amp;IncidenciasMETEOROLOGICA=true&amp;IncidenciasPUERTOS=true&amp;IncidenciasOTROS=true&amp;IncidenciasEVENTOS=true&amp;IncidenciasRESTRICCIONES=true&amp;niveles=true&amp;caracter=acontecimiento&amp;ref=blog.manugarri.com">this url</a> , the same one the official map uses (you can check using the developer network tools on your browser).</p><p>I used <a href="https://www.python-httpx.org/?ref=blog.manugarri.com">httpx</a> to perform the GET request, for no reason besides testing it, its supposed to be the next Requests.</p><p>Leaflet provides basic icons out of the box, but since I wanted to display a few different event types, I used erikflower&apos;s awesome <a href="https://erikflowers.github.io/weather-icons/?ref=blog.manugarri.com">Weather icons</a>. These icons not only are beautiful (particularly compared to DGT&apos;s <a href="http://infocar.dgt.es/etraffic/img/iconosIncitar/?ref=blog.manugarri.com">ugly ones</a>), but also render very fast since they are not bitmaps.</p><p>I found it a bit complicated how to add custom icons, but <a href="(https://www.geoapify.com/create-custom-map-marker-icon)">this doc</a> explains it nicely.</p><p>Finally, I used <a href="https://openrouteservice.org/?ref=blog.manugarri.com">OpenRouteService</a> as a geocoding package to translate the Navigation search terms into geocoordinates. It doesnt work as well as Google Maps, but its open source and Google Maps has turned a bit evil in recent times. OpenRouteService has a nice <a href="https://openrouteservice-py.readthedocs.io/?ref=blog.manugarri.com">python package</a>.</p><h2 id="notes">Notes</h2><ol><li>Leaflet keeps getting better and better!, now its super easy to add custom tile providers. there are even a ton of tile providers now thanks to the awesome <a href="https://github.com/leaflet-extras/leaflet-providers?ref=blog.manugarri.com">leaflet-providers</a> project. For example, here is how the map looks like with a different tile provider (Stadia)</li></ol><figure class="kg-card kg-image-card"><img src="https://i.imgur.com/lLif5Ff.png" class="kg-image" alt="stadia" loading="lazy"></figure><ol><li>Again and again, I come up with a nice frontend project idea that I have to implement with a backend just because of <a href="https://developer.mozilla.org/en-US/docs/Web/HTTP/CORS?ref=blog.manugarri.com">CORS</a>. There should be a way to disable CORS for well spirited applications. Maybe prompting the user to disable CORS for a site?</li></ol>]]></content:encoded></item><item><title><![CDATA[Airflow UI: How to trigger a DAG with custom parameters?]]></title><description><![CDATA[<p><a href="https://airflow.apache.org/?ref=blog.manugarri.com">Airflow</a> is one of the most widely used Schedulers currently in the tech industry. Initially developed at Airbnb, a few years ago it became an Apache foundation project, quickly becoming one of the foundation top projects.</p><p>It is a direct competitor of other schedulers such as Spotify&apos;s <a href="https://github.com/spotify/luigi?ref=blog.manugarri.com">Luigi</a></p>]]></description><link>https://blog.manugarri.com/how-to-trigger-a-dag-with-custom-parameters-on-airflow-ui/</link><guid isPermaLink="false">64723d6550473e03b42726c6</guid><dc:creator><![CDATA[Manuel Garrido]]></dc:creator><pubDate>Tue, 28 Jul 2020 09:33:07 GMT</pubDate><content:encoded><![CDATA[<p><a href="https://airflow.apache.org/?ref=blog.manugarri.com">Airflow</a> is one of the most widely used Schedulers currently in the tech industry. Initially developed at Airbnb, a few years ago it became an Apache foundation project, quickly becoming one of the foundation top projects.</p><p>It is a direct competitor of other schedulers such as Spotify&apos;s <a href="https://github.com/spotify/luigi?ref=blog.manugarri.com">Luigi</a> or newer solutions such as <a href="https://www.digdag.io/?ref=blog.manugarri.com">DigDag</a> or <a href="https://www.prefect.io/?ref=blog.manugarri.com">Prefect</a> (created by core Airflow developers, I&apos;m keeping this one on my list for future projects when it matures a bit).</p><p>At my current company, <a href="www.daltix.com">Daltix</a>, we are moving away from an older tool, <a href="https://www.jenkins.io/?ref=blog.manugarri.com">Jenkins</a>, a CI/CD tool we hacked so it can act as a job scheduler, to Airflow. The improvements we gained by using an actual job scheduler are great (dag visualization, dynamic dag setup, specific task triggering among others),</p><p><strong>BUT</strong></p><p>There is a feature that Jenkins has that most schedulers do not. I will explain with an example.</p><p>Lets say I have a DAG (we can call it a job) that performs some sql queries to generate a Persistent Derived Table <a href="https://mode.com/blog/persistent-derived-tables/?ref=blog.manugarri.com">PDT</a> for a customer.</p><p>This job will be a templated job, meaning that in order to run it we need to specify which customer database (as a parameter <code>customer_code</code> for example) to run it for. We can do so easily by passing <a href>configuration parameters</a> when we trigger the airflow DAG.</p><p>Here is what the Airflow DAG (named <code>navigator_pdt_supplier</code> in this example) would look like:</p><figure class="kg-card kg-image-card"><img src="https://blog.manugarri.com/content/images/2020/07/pdt.png" class="kg-image" alt="pdt.png" loading="lazy"></figure><p>So basically we have a first step where we parse the configuration parameters, then we run the actual PDT, and if something goes wrong, we get a Slack notification.</p><p>The first step, <code>parse_job_args_task</code> is a simple PythonOperator that parses the configuration parameter <code>customer_code</code> provided in the DAG run configuration (a DAG run is a specific trigger of the DAG):</p><pre><code>dag = DAG(  
    dag_id=&quot;navigator_pdt_supplier&quot;,
    tags=[&quot;data_augmentation&quot;],
    schedule_interval=NONE,
)

dag.trigger_arguments = {&quot;customer_code&quot;: &quot;string&quot;} # these are the arguments we would like to trigger manually

def parse_job_args_fn(**kwargs):  
    dag_run_conf = kwargs[&quot;dag_run&quot;].conf #  here we get the parameters we specify when triggering
    kwargs[&quot;ti&quot;].xcom_push(key=&quot;customer_code&quot;, value=dag_run_conf[&quot;customer_code&quot;]) # push it as an airflow xcom

parse_job_args_task = PythonOperator(  
    task_id=&quot;parse_job_args_task&quot;,
    python_callable=parse_job_args_fn,
    provide_context=True,
    dag=dag
)
</code></pre><p>After this step, we can reference the customer_code parameter in the PDT just by doing (this is an example):</p><pre><code>run_pdt = SQLOperator(  
  query=f&quot;USE DATABASE {{ task_instance.xcom_pull(key=&apos;customer_code&apos;) }}&quot;
</code></pre><p>Great! Only question though, how do we actually run this DAG? We can&apos;t run it on a cron basis, since we need to provide additional parameters to the DAG when we trigger it. We can&apos;t trigger it manually via the <em>trigger dag</em> UI button either.</p><p>We can trigger it via Airflow&apos;s API, with a simple call like this:</p><pre><code>import requests:

AIRFLOW_API_ENDPOINT = &quot;http://.....//api/experimental&quot;  
DAG_ID = &quot;navigator_pdt_supplier&quot; # dag to trigger

# these are the custom parameters
parameters = {&quot;customer_code&quot;: &quot;ACME&quot;}

result = requests.post(f&quot;{AIRFLOW_API_ENDPOINT}/dags/{DAG_ID}/dag_runs&quot;, json={&quot;conf&quot;: parameters})  
</code></pre><p>This is great, but not only requires an additional security step (opening Airflow API), but it restricts Airflow usage only to technical people who know how to do api calls.</p><p>Here comes Jenkins&apos; killer feature! which is, you can define parameters using a simple interface when triggering a Job!</p><figure class="kg-card kg-image-card"><img src="https://blog.manugarri.com/content/images/2020/07/jenkins.png" class="kg-image" alt="jenkins UI custom trigger" loading="lazy"></figure><p>This is a feature that is not available on Airflow. Which brings us to the meat of this post:</p><h2 id="how-to-add-a-custom-trigger-option-on-airflow">How to add a &quot;custom trigger&quot; option on Airflow:</h2><p>Airflow&apos;s interface and functionality can be expanded by the use of <a href="https://airflow.apache.org/docs/stable/plugins.html?ref=blog.manugarri.com">plugins</a>. We can update or create Operators easily, and we can also create web views to add additional features.</p><p>Plugins need to be saved on the Airflow plugins folder, usually <code>$AIRFLOW_HOME/plugins</code></p><p>Airflow UI can be run using 2 different Flask-based packages. By default it uses <a href="https://flask-admin.readthedocs.io/?ref=blog.manugarri.com">Flask-Admin</a> to render the UI, however if the new Role Based Access Control flag is enabled <a href="https://airflow.apache.org/docs/stable/security.html?highlight=ldap&amp;ref=blog.manugarri.com#rbac-ui-security">RBAC</a>, Airflow uses <a href="https://flask-appbuilder.readthedocs.io/?ref=blog.manugarri.com">Flask-appbuilder</a> to manage the UI.</p><p>We can create a plugin called <code>trigger_view.py</code> and save it in the Airflow plugins directory with the following contents:</p><pre><code>from airflow.api.common.experimental.trigger_dag import trigger_dag  
from airflow import configuration as conf  
from airflow.plugins_manager import AirflowPlugin  
from airflow.models import DagBag  
from flask import render_template_string, request, Markup  
from airflow.utils import timezone


trigger_template = &quot;&quot;&quot;  
&lt;head&gt;&lt;/head&gt;  
&lt;body&gt;  
    &lt;a href=&quot;/home&quot;&gt;Home&lt;/a&gt;
      {% if messages %}
        &lt;ul class=flashes&gt;
        {% for message in messages %}
          &lt;li&gt;{{ message }}&lt;/li&gt;
        {% endfor %}
        &lt;/ul&gt;
      {% endif %}
    &lt;h1&gt;Manual Trigger&lt;/h1&gt;
    &lt;div class=&quot;widget-content&quot;&gt;
       &lt;form id=&quot;triggerForm&quot; method=&quot;post&quot;&gt;
          &lt;label for=&quot;dag&quot;&gt;Select a dag:&lt;/label&gt;
          &lt;select name=&quot;dag&quot; id=&quot;selected_dag&quot;&gt;
              &lt;option value=&quot;&quot;&gt;&lt;/option&gt;
              {%- for dag_id, dag_arguments in dag_data.items() %}
              &lt;option value=&quot;{{ dag_id }}&quot; {% if dag_id in selected %}selected=&quot;selected&quot;{% endif %}&gt;{{ dag_id }}&lt;/option&gt;
              {%- endfor %}
          &lt;/select&gt;
          &lt;div id=&quot;dag_options&quot;&gt;
              {%- for dag_id, dag_arguments in dag_data.items() %}
                  &lt;div id=&quot;{{ dag_id }}&quot; style=&apos;display:none&apos;&gt;
                    {% if dag_arguments %}
                        &lt;b&gt;Arguments to trigger dag {{dag_id}}:&lt;/b&gt;&lt;br&gt;
                    {% endif %}
                    {% for dag_argument_name, _ in dag_arguments.items() %}
                        &lt;input type=&quot;text&quot; id=&quot;{{ dag_argument_name }}&quot; name=&quot;{{dag_id}}-{{ dag_argument_name }}&quot; placeholder=&quot;{{ dag_argument_name }}&quot; &gt;&lt;br&gt;
                    {% endfor %}
                  &lt;/div&gt;
              {%- endfor %}
          &lt;/div&gt;
          &lt;br&gt;
          &lt;input type=&quot;submit&quot; value=&quot;Trigger&quot; class=&quot;btn btn-secondary&quot;&gt;
        {% if csrf_token %}
            &lt;input type=&quot;hidden&quot; name=&quot;csrf_token&quot; value=&quot;{{ csrf_token() }}&quot;/&gt;
        {% endif %}
       &lt;/form&gt;
    &lt;/div&gt;
&lt;/body&gt;  
&lt;script type=&quot;text/javascript&quot;&gt;  
var selectedDag = document.getElementById(&quot;selected_dag&quot;);  
var previous;  
selectedDag.addEventListener(&quot;change&quot;, function() {  
    if (previous) previous.style.display = &quot;none&quot;
    var dagOptions = document.getElementById(selectedDag.value);
    dagOptions.style.display = &quot;block&quot;;
    previous = dagOptions;
});
&lt;/script&gt;  
&quot;&quot;&quot;


def trigger(dag_id, trigger_dag_conf):  
    &quot;&quot;&quot;Function that triggers the dag with the custom conf&quot;&quot;&quot;
    execution_date = timezone.utcnow()

    dagrun_job = {
        &quot;dag_id&quot;: dag_id,
        &quot;run_id&quot;: f&quot;manual__{execution_date.isoformat()}&quot;,
        &quot;execution_date&quot;: execution_date,
        &quot;replace_microseconds&quot;: False,
        &quot;conf&quot;: trigger_dag_conf
    }
    r = trigger_dag(**dagrun_job)
    return r


# if we dont have RBAC enabled, we setup a flask admin View
from flask_admin import BaseView, expose  
class FlaskAdminTriggerView(BaseView):  
    @expose(&quot;/&quot;, methods=[&quot;GET&quot;, &quot;POST&quot;])
    def list(self):
        if request.method == &quot;POST&quot;:
            print(request.form)
            trigger_dag_id = request.form[&quot;dag&quot;]
            trigger_dag_conf = {k.replace(trigger_dag_id, &quot;&quot;).lstrip(&quot;-&quot;): v for k, v in request.form.items() if k.startswith(trigger_dag_id)}
            dag_run = trigger(trigger_dag_id, trigger_dag_conf)
            messages = [f&quot;Dag {trigger_dag_id} triggered with configuration: {trigger_dag_conf}&quot;]
            dag_run_url = DAG_RUN_URL_TMPL.format(dag_id=dag_run.dag_id, run_id=dag_run.run_id)
            messages.append(Markup(f&apos;&lt;a href=&quot;{dag_run_url}&quot; target=&quot;_blank&quot;&gt;Dag Run url&lt;/a&gt;&apos;))
            dag_data = {dag.dag_id: getattr(dag, &quot;trigger_arguments&quot;, {}) for dag in DagBag().dags.values()}
            return render_template_string(trigger_template, dag_data=dag_data, messages=messages)
        else:
            dag_data = {dag.dag_id: getattr(dag, &quot;trigger_arguments&quot;, {}) for dag in DagBag().dags.values()}
            return render_template_string(trigger_template, dag_data=dag_data)
v = FlaskAdminTriggerView(category=&quot;Extra&quot;, name=&quot;Manual Trigger&quot;)



# If we have RBAC, airflow uses flask-appbuilder, if not it uses flask-admin
from flask_appbuilder import BaseView as AppBuilderBaseView, expose  
class AppBuilderTriggerView(AppBuilderBaseView):  
    @expose(&quot;/&quot;, methods=[&quot;GET&quot;, &quot;POST&quot;])
    def list(self):
        if request.method == &quot;POST&quot;:
            print(request.form)
            trigger_dag_id = request.form[&quot;dag&quot;]
            trigger_dag_conf = {k.replace(trigger_dag_id, &quot;&quot;).lstrip(&quot;-&quot;): v for k, v in request.form.items() if k.startswith(trigger_dag_id)}
            dag_run = trigger(trigger_dag_id, trigger_dag_conf)
            messages = [f&quot;Dag {trigger_dag_id} triggered with configuration: {trigger_dag_conf}&quot;]
            dag_run_url = DAG_RUN_URL_TMPL.format(dag_id=dag_run.dag_id, run_id=dag_run.run_id)
            messages.append(Markup(f&apos;&lt;a href=&quot;{dag_run_url}&quot; target=&quot;_blank&quot;&gt;Dag Run url&lt;/a&gt;&apos;))
            dag_data = {dag.dag_id: getattr(dag, &quot;trigger_arguments&quot;, {}) for dag in DagBag().dags.values()}
            return render_template_string(trigger_template, dag_data=dag_data, messages=messages)
        else:
            dag_data = {dag.dag_id: getattr(dag, &quot;trigger_arguments&quot;, {}) for dag in DagBag().dags.values()}
            return render_template_string(trigger_template, dag_data=dag_data)


v_appbuilder_view = AppBuilderTriggerView()  
v_appbuilder_package = {&quot;name&quot;: &quot;Manual Trigger&quot;,  
                        &quot;category&quot;: &quot;Extra&quot;,
                        &quot;view&quot;: v_appbuilder_view}



# Defining the plugin class
class TriggerViewPlugin(AirflowPlugin):  
    name = &quot;triggerview_plugin&quot;
    admin_views = [v] # if we dont have RBAC we use this view and can comment the next line
    appbuilder_views = [v_appbuilder_package] # if we use RBAC we use this view and can comment the previous line
</code></pre><p>After setting up the plugin and restarting the airflow UI, we get an additional menu link on the top bar, clicking on it will lead us to this glorious interface:</p><figure class="kg-card kg-image-card"><img src="https://blog.manugarri.com/content/images/2020/07/trigger_view.png" class="kg-image" alt="trigger-view.png" loading="lazy"></figure><p>On this new menu we will be able to manually trigger a dag, and if that dag has an additional parameter <code>trigger_arguments</code> , the trigger menu will allow us to trigger the dag with the custom parameter!</p><figure class="kg-card kg-image-card"><img src="https://blog.manugarri.com/content/images/2020/07/trigger_view_2.png" class="kg-image" alt="trigger arguments" loading="lazy"></figure><p>After we select the customer_code parameter and click the trigger button, we get a confirmation message and a link to the specific dag run so we can monitor it.</p><figure class="kg-card kg-image-card"><img src="https://blog.manugarri.com/content/images/2020/07/trigger_view3.png" class="kg-image" alt="trigger view result" loading="lazy"></figure><p><strong>Neat right?</strong> There are many ways to improve this simple plugin (adding an execution_date datepicker, or different UI forms depending on the argument type), would love to hear how you would update them!</p>]]></content:encoded></item><item><title><![CDATA[Airflow UI: How to trigger a DAG with custom parameters?]]></title><description><![CDATA[<p><a href="https://airflow.apache.org/?ref=blog.manugarri.com">Airflow</a> is one of the most widely used Schedulers currently in the tech industry. Initially developed at Airbnb, a few years ago it became an Apache foundation project, quickly becoming one of the foundation top projects.</p><p>It is a direct competitor of other schedulers such as Spotify&apos;s <a href="https://github.com/spotify/luigi?ref=blog.manugarri.com">Luigi</a></p>]]></description><link>https://blog.manugarri.com/how-to-trigger-a-dag-with-custom-parameters-on-airflow-ui-2/</link><guid isPermaLink="false">6472421550473e03b4272701</guid><dc:creator><![CDATA[Manuel Garrido]]></dc:creator><pubDate>Tue, 28 Jul 2020 09:33:07 GMT</pubDate><content:encoded><![CDATA[<p><a href="https://airflow.apache.org/?ref=blog.manugarri.com">Airflow</a> is one of the most widely used Schedulers currently in the tech industry. Initially developed at Airbnb, a few years ago it became an Apache foundation project, quickly becoming one of the foundation top projects.</p><p>It is a direct competitor of other schedulers such as Spotify&apos;s <a href="https://github.com/spotify/luigi?ref=blog.manugarri.com">Luigi</a> or newer solutions such as <a href="https://www.digdag.io/?ref=blog.manugarri.com">DigDag</a> or <a href="https://www.prefect.io/?ref=blog.manugarri.com">Prefect</a> (created by core Airflow developers, I&apos;m keeping this one on my list for future projects when it matures a bit).</p><p>At my current company, <a href="www.daltix.com">Daltix</a>, we are moving away from an older tool, <a href="https://www.jenkins.io/?ref=blog.manugarri.com">Jenkins</a>, a CI/CD tool we hacked so it can act as a job scheduler, to Airflow. The improvements we gained by using an actual job scheduler are great (dag visualization, dynamic dag setup, specific task triggering among others),</p><p><strong>BUT</strong></p><p>There is a feature that Jenkins has that most schedulers do not. I will explain with an example.</p><p>Lets say I have a DAG (we can call it a job) that performs some sql queries to generate a Persistent Derived Table <a href="https://mode.com/blog/persistent-derived-tables/?ref=blog.manugarri.com">PDT</a> for a customer.</p><p>This job will be a templated job, meaning that in order to run it we need to specify which customer database (as a parameter <code>customer_code</code> for example) to run it for. We can do so easily by passing <a href>configuration parameters</a> when we trigger the airflow DAG.</p><p>Here is what the Airflow DAG (named <code>navigator_pdt_supplier</code> in this example) would look like:</p><figure class="kg-card kg-image-card"><img src="https://blog.manugarri.com/content/images/2020/07/pdt.png" class="kg-image" alt="pdt.png" loading="lazy"></figure><p>So basically we have a first step where we parse the configuration parameters, then we run the actual PDT, and if something goes wrong, we get a Slack notification.</p><p>The first step, <code>parse_job_args_task</code> is a simple PythonOperator that parses the configuration parameter <code>customer_code</code> provided in the DAG run configuration (a DAG run is a specific trigger of the DAG):</p><pre><code>dag = DAG(  
    dag_id=&quot;navigator_pdt_supplier&quot;,
    tags=[&quot;data_augmentation&quot;],
    schedule_interval=NONE,
)

dag.trigger_arguments = {&quot;customer_code&quot;: &quot;string&quot;} # these are the arguments we would like to trigger manually

def parse_job_args_fn(**kwargs):  
    dag_run_conf = kwargs[&quot;dag_run&quot;].conf #  here we get the parameters we specify when triggering
    kwargs[&quot;ti&quot;].xcom_push(key=&quot;customer_code&quot;, value=dag_run_conf[&quot;customer_code&quot;]) # push it as an airflow xcom

parse_job_args_task = PythonOperator(  
    task_id=&quot;parse_job_args_task&quot;,
    python_callable=parse_job_args_fn,
    provide_context=True,
    dag=dag
)
</code></pre><p>After this step, we can reference the customer_code parameter in the PDT just by doing (this is an example):</p><pre><code>run_pdt = SQLOperator(  
  query=f&quot;USE DATABASE {{ task_instance.xcom_pull(key=&apos;customer_code&apos;) }}&quot;
</code></pre><p>Great! Only question though, how do we actually run this DAG? We can&apos;t run it on a cron basis, since we need to provide additional parameters to the DAG when we trigger it. We can&apos;t trigger it manually via the <em>trigger dag</em> UI button either.</p><p>We can trigger it via Airflow&apos;s API, with a simple call like this:</p><pre><code>import requests:

AIRFLOW_API_ENDPOINT = &quot;http://.....//api/experimental&quot;  
DAG_ID = &quot;navigator_pdt_supplier&quot; # dag to trigger

# these are the custom parameters
parameters = {&quot;customer_code&quot;: &quot;ACME&quot;}

result = requests.post(f&quot;{AIRFLOW_API_ENDPOINT}/dags/{DAG_ID}/dag_runs&quot;, json={&quot;conf&quot;: parameters})  
</code></pre><p>This is great, but not only requires an additional security step (opening Airflow API), but it restricts Airflow usage only to technical people who know how to do api calls.</p><p>Here comes Jenkins&apos; killer feature! which is, you can define parameters using a simple interface when triggering a Job!</p><figure class="kg-card kg-image-card"><img src="https://blog.manugarri.com/content/images/2020/07/jenkins.png" class="kg-image" alt="jenkins UI custom trigger" loading="lazy"></figure><p>This is a feature that is not available on Airflow. Which brings us to the meat of this post:</p><h2 id="how-to-add-a-custom-trigger-option-on-airflow">How to add a &quot;custom trigger&quot; option on Airflow:</h2><p>Airflow&apos;s interface and functionality can be expanded by the use of <a href="https://airflow.apache.org/docs/stable/plugins.html?ref=blog.manugarri.com">plugins</a>. We can update or create Operators easily, and we can also create web views to add additional features.</p><p>Plugins need to be saved on the Airflow plugins folder, usually <code>$AIRFLOW_HOME/plugins</code></p><p>Airflow UI can be run using 2 different Flask-based packages. By default it uses <a href="https://flask-admin.readthedocs.io/?ref=blog.manugarri.com">Flask-Admin</a> to render the UI, however if the new Role Based Access Control flag is enabled <a href="https://airflow.apache.org/docs/stable/security.html?highlight=ldap&amp;ref=blog.manugarri.com#rbac-ui-security">RBAC</a>, Airflow uses <a href="https://flask-appbuilder.readthedocs.io/?ref=blog.manugarri.com">Flask-appbuilder</a> to manage the UI.</p><p>We can create a plugin called <code>trigger_view.py</code> and save it in the Airflow plugins directory with the following contents:</p><pre><code>from airflow.api.common.experimental.trigger_dag import trigger_dag  
from airflow import configuration as conf  
from airflow.plugins_manager import AirflowPlugin  
from airflow.models import DagBag  
from flask import render_template_string, request, Markup  
from airflow.utils import timezone


trigger_template = &quot;&quot;&quot;  
&lt;head&gt;&lt;/head&gt;  
&lt;body&gt;  
    &lt;a href=&quot;/home&quot;&gt;Home&lt;/a&gt;
      {% if messages %}
        &lt;ul class=flashes&gt;
        {% for message in messages %}
          &lt;li&gt;{{ message }}&lt;/li&gt;
        {% endfor %}
        &lt;/ul&gt;
      {% endif %}
    &lt;h1&gt;Manual Trigger&lt;/h1&gt;
    &lt;div class=&quot;widget-content&quot;&gt;
       &lt;form id=&quot;triggerForm&quot; method=&quot;post&quot;&gt;
          &lt;label for=&quot;dag&quot;&gt;Select a dag:&lt;/label&gt;
          &lt;select name=&quot;dag&quot; id=&quot;selected_dag&quot;&gt;
              &lt;option value=&quot;&quot;&gt;&lt;/option&gt;
              {%- for dag_id, dag_arguments in dag_data.items() %}
              &lt;option value=&quot;{{ dag_id }}&quot; {% if dag_id in selected %}selected=&quot;selected&quot;{% endif %}&gt;{{ dag_id }}&lt;/option&gt;
              {%- endfor %}
          &lt;/select&gt;
          &lt;div id=&quot;dag_options&quot;&gt;
              {%- for dag_id, dag_arguments in dag_data.items() %}
                  &lt;div id=&quot;{{ dag_id }}&quot; style=&apos;display:none&apos;&gt;
                    {% if dag_arguments %}
                        &lt;b&gt;Arguments to trigger dag {{dag_id}}:&lt;/b&gt;&lt;br&gt;
                    {% endif %}
                    {% for dag_argument_name, _ in dag_arguments.items() %}
                        &lt;input type=&quot;text&quot; id=&quot;{{ dag_argument_name }}&quot; name=&quot;{{dag_id}}-{{ dag_argument_name }}&quot; placeholder=&quot;{{ dag_argument_name }}&quot; &gt;&lt;br&gt;
                    {% endfor %}
                  &lt;/div&gt;
              {%- endfor %}
          &lt;/div&gt;
          &lt;br&gt;
          &lt;input type=&quot;submit&quot; value=&quot;Trigger&quot; class=&quot;btn btn-secondary&quot;&gt;
        {% if csrf_token %}
            &lt;input type=&quot;hidden&quot; name=&quot;csrf_token&quot; value=&quot;{{ csrf_token() }}&quot;/&gt;
        {% endif %}
       &lt;/form&gt;
    &lt;/div&gt;
&lt;/body&gt;  
&lt;script type=&quot;text/javascript&quot;&gt;  
var selectedDag = document.getElementById(&quot;selected_dag&quot;);  
var previous;  
selectedDag.addEventListener(&quot;change&quot;, function() {  
    if (previous) previous.style.display = &quot;none&quot;
    var dagOptions = document.getElementById(selectedDag.value);
    dagOptions.style.display = &quot;block&quot;;
    previous = dagOptions;
});
&lt;/script&gt;  
&quot;&quot;&quot;


def trigger(dag_id, trigger_dag_conf):  
    &quot;&quot;&quot;Function that triggers the dag with the custom conf&quot;&quot;&quot;
    execution_date = timezone.utcnow()

    dagrun_job = {
        &quot;dag_id&quot;: dag_id,
        &quot;run_id&quot;: f&quot;manual__{execution_date.isoformat()}&quot;,
        &quot;execution_date&quot;: execution_date,
        &quot;replace_microseconds&quot;: False,
        &quot;conf&quot;: trigger_dag_conf
    }
    r = trigger_dag(**dagrun_job)
    return r


# if we dont have RBAC enabled, we setup a flask admin View
from flask_admin import BaseView, expose  
class FlaskAdminTriggerView(BaseView):  
    @expose(&quot;/&quot;, methods=[&quot;GET&quot;, &quot;POST&quot;])
    def list(self):
        if request.method == &quot;POST&quot;:
            print(request.form)
            trigger_dag_id = request.form[&quot;dag&quot;]
            trigger_dag_conf = {k.replace(trigger_dag_id, &quot;&quot;).lstrip(&quot;-&quot;): v for k, v in request.form.items() if k.startswith(trigger_dag_id)}
            dag_run = trigger(trigger_dag_id, trigger_dag_conf)
            messages = [f&quot;Dag {trigger_dag_id} triggered with configuration: {trigger_dag_conf}&quot;]
            dag_run_url = DAG_RUN_URL_TMPL.format(dag_id=dag_run.dag_id, run_id=dag_run.run_id)
            messages.append(Markup(f&apos;&lt;a href=&quot;{dag_run_url}&quot; target=&quot;_blank&quot;&gt;Dag Run url&lt;/a&gt;&apos;))
            dag_data = {dag.dag_id: getattr(dag, &quot;trigger_arguments&quot;, {}) for dag in DagBag().dags.values()}
            return render_template_string(trigger_template, dag_data=dag_data, messages=messages)
        else:
            dag_data = {dag.dag_id: getattr(dag, &quot;trigger_arguments&quot;, {}) for dag in DagBag().dags.values()}
            return render_template_string(trigger_template, dag_data=dag_data)
v = FlaskAdminTriggerView(category=&quot;Extra&quot;, name=&quot;Manual Trigger&quot;)



# If we have RBAC, airflow uses flask-appbuilder, if not it uses flask-admin
from flask_appbuilder import BaseView as AppBuilderBaseView, expose  
class AppBuilderTriggerView(AppBuilderBaseView):  
    @expose(&quot;/&quot;, methods=[&quot;GET&quot;, &quot;POST&quot;])
    def list(self):
        if request.method == &quot;POST&quot;:
            print(request.form)
            trigger_dag_id = request.form[&quot;dag&quot;]
            trigger_dag_conf = {k.replace(trigger_dag_id, &quot;&quot;).lstrip(&quot;-&quot;): v for k, v in request.form.items() if k.startswith(trigger_dag_id)}
            dag_run = trigger(trigger_dag_id, trigger_dag_conf)
            messages = [f&quot;Dag {trigger_dag_id} triggered with configuration: {trigger_dag_conf}&quot;]
            dag_run_url = DAG_RUN_URL_TMPL.format(dag_id=dag_run.dag_id, run_id=dag_run.run_id)
            messages.append(Markup(f&apos;&lt;a href=&quot;{dag_run_url}&quot; target=&quot;_blank&quot;&gt;Dag Run url&lt;/a&gt;&apos;))
            dag_data = {dag.dag_id: getattr(dag, &quot;trigger_arguments&quot;, {}) for dag in DagBag().dags.values()}
            return render_template_string(trigger_template, dag_data=dag_data, messages=messages)
        else:
            dag_data = {dag.dag_id: getattr(dag, &quot;trigger_arguments&quot;, {}) for dag in DagBag().dags.values()}
            return render_template_string(trigger_template, dag_data=dag_data)


v_appbuilder_view = AppBuilderTriggerView()  
v_appbuilder_package = {&quot;name&quot;: &quot;Manual Trigger&quot;,  
                        &quot;category&quot;: &quot;Extra&quot;,
                        &quot;view&quot;: v_appbuilder_view}



# Defining the plugin class
class TriggerViewPlugin(AirflowPlugin):  
    name = &quot;triggerview_plugin&quot;
    admin_views = [v] # if we dont have RBAC we use this view and can comment the next line
    appbuilder_views = [v_appbuilder_package] # if we use RBAC we use this view and can comment the previous line
</code></pre><p>After setting up the plugin and restarting the airflow UI, we get an additional menu link on the top bar, clicking on it will lead us to this glorious interface:</p><figure class="kg-card kg-image-card"><img src="https://blog.manugarri.com/content/images/2020/07/trigger_view.png" class="kg-image" alt="trigger-view.png" loading="lazy"></figure><p>On this new menu we will be able to manually trigger a dag, and if that dag has an additional parameter <code>trigger_arguments</code> , the trigger menu will allow us to trigger the dag with the custom parameter!</p><figure class="kg-card kg-image-card"><img src="https://blog.manugarri.com/content/images/2020/07/trigger_view_2.png" class="kg-image" alt="trigger arguments" loading="lazy"></figure><p>After we select the customer_code parameter and click the trigger button, we get a confirmation message and a link to the specific dag run so we can monitor it.</p><figure class="kg-card kg-image-card"><img src="https://blog.manugarri.com/content/images/2020/07/trigger_view3.png" class="kg-image" alt="trigger view result" loading="lazy"></figure><p><strong>Neat right?</strong> There are many ways to improve this simple plugin (adding an execution_date datepicker, or different UI forms depending on the argument type), would love to hear how you would update them!</p>]]></content:encoded></item><item><title><![CDATA[Note to Self. Installing LightGBM in Ubuntu 18.04]]></title><description><![CDATA[<p>These are the steps I took to install Microsoft&apos;s cool Gradient Boosted Models library, <a href="https://github.com/Microsoft/LightGBM?ref=blog.manugarri.com">LightGBM</a></p><h2 id="step-1-install-cuda">Step 1. Install CUDA</h2><p>I am not going to explain this step because it is easy to find.</p><h2 id="step-2-install-boost">Step 2. Install Boost</h2><pre><code>sudo apt-get install libboost-all-dev  
</code></pre><h3 id="step-3-clone-lightgbm-and-build-with-cuda-enabled">Step 3. Clone LightGBM and build with</h3>]]></description><link>https://blog.manugarri.com/note-to-self-installing-lightgbm-in-ubuntu-18-04/</link><guid isPermaLink="false">64723d6550473e03b42726c4</guid><dc:creator><![CDATA[Manuel Garrido]]></dc:creator><pubDate>Fri, 13 Jul 2018 16:50:00 GMT</pubDate><content:encoded><![CDATA[<p>These are the steps I took to install Microsoft&apos;s cool Gradient Boosted Models library, <a href="https://github.com/Microsoft/LightGBM?ref=blog.manugarri.com">LightGBM</a></p><h2 id="step-1-install-cuda">Step 1. Install CUDA</h2><p>I am not going to explain this step because it is easy to find.</p><h2 id="step-2-install-boost">Step 2. Install Boost</h2><pre><code>sudo apt-get install libboost-all-dev  
</code></pre><h3 id="step-3-clone-lightgbm-and-build-with-cuda-enabled">Step 3. Clone LightGBM and build with CUDA enabled</h3><pre><code>git clone --recursive https://github.com/Microsoft/LightGBM &amp;&amp; cd LightGBM  
export CXX=g++-7 CC=gcc-7  # replace 7 with version of gcc installed on your machine  
mkdir build &amp;&amp; cd build  
cmake .. -DUSE_GPU=1  
make -j4  
</code></pre><h2 id="step-4-install-python-bindings">Step 4. Install python bindings</h2><pre><code>cd ..  
pip install setuptools numpy scipy scikit-learn -U  
cd python-package/  
python setup.py install --precompile  
</code></pre><p>Now you just need to add the argument <code>device=&quot;gpu&quot;</code> when creatting your LightGBMModel.</p>]]></content:encoded></item><item><title><![CDATA[Note to self. Pyspark failling with "Error while instantiating ‘org.apache.spark.sql.hive.HiveSessionState’"]]></title><description><![CDATA[<p>If you run pyspark and see this error (it happens in scala-shell as well):</p><pre><code>Error while instantiating &#x2018;org.apache.spark.sql.hive.HiveSessionState&#x2019;  
</code></pre><p>The solution is easy, yet ridiculous. <br>1. Create the folder <code>/tmp/hive</code> <br>2. Give it chmod permissions <code>sudo chmod -R 777 /tmp/hive</code></p><p>Found <a href="https://myawsjourney.wordpress.com/2018/02/14/pyspark-sql-utils-illegalargumentexception-uerror-while-instantiating-org-apache-spark-sql-hive-hivesessionstate/?ref=blog.manugarri.com">here</a></p>]]></description><link>https://blog.manugarri.com/note-to-self-pyspark-failling-with-error-while-instantiating-org-apache-spark-sql-hive-hivesessionstate/</link><guid isPermaLink="false">64723d6550473e03b42726c2</guid><dc:creator><![CDATA[Manuel Garrido]]></dc:creator><pubDate>Tue, 22 May 2018 10:12:48 GMT</pubDate><content:encoded><![CDATA[<p>If you run pyspark and see this error (it happens in scala-shell as well):</p><pre><code>Error while instantiating &#x2018;org.apache.spark.sql.hive.HiveSessionState&#x2019;  
</code></pre><p>The solution is easy, yet ridiculous. <br>1. Create the folder <code>/tmp/hive</code> <br>2. Give it chmod permissions <code>sudo chmod -R 777 /tmp/hive</code></p><p>Found <a href="https://myawsjourney.wordpress.com/2018/02/14/pyspark-sql-utils-illegalargumentexception-uerror-while-instantiating-org-apache-spark-sql-hive-hivesessionstate/?ref=blog.manugarri.com">here</a></p>]]></content:encoded></item><item><title><![CDATA[Note to self: Fixing encoding in Golang ascii85]]></title><description><![CDATA[<p>Yesterday I spent a few hours dealing with what I like to call <strong>&quot;the edges of StackOverflow&quot;</strong>. By that I mean those situations in which you are trying to solve a programming problem (mostly a bug) and you have no idea why its happening, and even worse, no</p>]]></description><link>https://blog.manugarri.com/note-to-self-fixing-encoding-in-golang-ascii85/</link><guid isPermaLink="false">64723d6550473e03b42726c1</guid><dc:creator><![CDATA[Manuel Garrido]]></dc:creator><pubDate>Thu, 19 Apr 2018 08:36:22 GMT</pubDate><content:encoded><![CDATA[<p>Yesterday I spent a few hours dealing with what I like to call <strong>&quot;the edges of StackOverflow&quot;</strong>. By that I mean those situations in which you are trying to solve a programming problem (mostly a bug) and you have no idea why its happening, and even worse, no amount of search (in StackOverflow or Github) yield any information that might seem somewhat related to the issue.</p><p>I think this xkcd strip puts it quite clearly:</p><figure class="kg-card kg-image-card"><img src="https://imgs.xkcd.com/comics/wisdom_of_the_ancients.png" class="kg-image" alt="xkcd" loading="lazy"></figure><p>The issue in question was this. I am working on working on a project involving cookies. The standard procedure in programmatic media buying (i.e., online ads) is to codify the cookie data in <a href="https://en.wikipedia.org/wiki/Ascii85?ref=blog.manugarri.com">ascii85 (or base85)</a>.</p><p>So I was implementing the encoding/decoding package using Golang&apos;s <a href="https://golang.org/pkg/encoding/ascii85?ref=blog.manugarri.com">ascii85</a> package as follows:</p><pre><code>package main

import (  
    &quot;fmt&quot;
    &quot;encoding/json&quot;
)




type User struct {  
   Age int
   Interests []string
}

func decodeCookie(cookieValue string) string {  
    cookieEncodedBytes := []byte(cookieValue)
    cookieDecodedBytes := make([]byte, len(cookieEncodedBytes))
    nCookieDecodedBytes, _, _ := ascii85.Decode(cookieDecodedBytes, cookieEncodedBytes, true)
    cookieDecodedBytes = cookieDecodedBytes[:nCookieDecodedBytes]
    return string(cookieDecodedBytes)
}

func encodeCookie(cookieValue string) string {  
    cookieBytes := []byte(cookieValue)
    cookieEncodedb85Bytes := make([]byte, ascii85.MaxEncodedLen(len(cookieBytes)))
    _ = ascii85.Encode(cookieEncodedb85Bytes, cookieBytes)
    cookieEncodedString := string(cookieEncodedb85Bytes)
    return cookieEncodedString
}


func main() {  
    user := User{
          25, 
          []string{&quot;music&quot;, &quot;football&quot;},
    }

    userJson, _ := json.Marshal(user) 
    fmt.Println(&quot;User as json&quot;, string(userJson))

    userB85Encoded := encodeCookie(string(userJson))
    fmt.Println(&quot;User as jsonB85&quot;, userB85Encoded)


    userB85Decoded := decodeCookie(userB85Encoded)
    fmt.Println(&quot;User as json&quot;, userB85Encoded)

    decodedUser := User{}
    err := json.Unmarshal([]byte(userB85Decoded), &amp;decodedUser)
    if err != nil {
        fmt.Println(&quot;Error deserializing json bytes&quot;, err)
    }

   fmt.Println(fmt.Sprintf(&quot;Deserialized User:%v&quot;, decodedUser))
}
</code></pre><p>This code will print the following output :</p><pre><code>User as json {&quot;Age&quot;:25,&quot;Interests&quot;:[&quot;music&quot;,&quot;football&quot;]}  
User as jsonB85 HQkagAKj/j2(TqCDKKH1ATMs7,!&amp;pPD09o6@j3HJAoDU0@UX(h,$fTs  
User as json {&quot;Age&quot;:25,&quot;Interests&quot;:[&quot;music&quot;,&quot;football&quot;]}  
Error deserializing json bytes invalid character &apos;\x00&apos; after top-level value  
Deserialized User:{0 []}  
</code></pre><p>So we see that, what we thought would be an easy encoding/decoding (easy encoding, HA!) implementation is failing for some reason. The error says:</p><p><code>Error deserializing json bytes invalid character &apos;\x00&apos; after top-level value</code></p><p>But where is that character? The character <code>\x00</code> is the null byte, so when printed it does not show up in the output.</p><p>We can go further by checking the length of the encoded/encoded strings to see if there is a mismatch by adding a few lines:</p><pre><code>package main

import (  
    &quot;fmt&quot;
    &quot;encoding/json&quot;
)




type User struct {  
   Age int
   Interests []string
}

func decodeCookie(cookieValue string) string {  
    cookieEncodedBytes := []byte(cookieValue)
    cookieDecodedBytes := make([]byte, len(cookieEncodedBytes))
    nCookieDecodedBytes, _, _ := ascii85.Decode(cookieDecodedBytes, cookieEncodedBytes, true)
    cookieDecodedBytes = cookieDecodedBytes[:nCookieDecodedBytes]
    return string(cookieDecodedBytes)
}

func encodeCookie(cookieValue string) string {  
    cookieBytes := []byte(cookieValue)
    cookieEncodedb85Bytes := make([]byte, ascii85.MaxEncodedLen(len(cookieBytes)))
    _ = ascii85.Encode(cookieEncodedb85Bytes, cookieBytes)
    cookieEncodedString := string(cookieEncodedb85Bytes)
    return cookieEncodedString
}


func main() {  
    user := User{
          25, 
          []string{&quot;music&quot;, &quot;football&quot;},
    }

    userOriginalJson, _ := json.Marshal(user) 
    fmt.Println(&quot;User as json&quot;, string(userOriginalJson))

    userB85Encoded := encodeCookie(string(userOriginalJson))
    fmt.Println(&quot;User as jsonB85&quot;, userB85Encoded)


    userB85DecodedJson := decodeCookie(userB85Encoded)
    fmt.Println(&quot;User as json&quot;, userB85DecodedJson)

    decodedUser := User{}
    err := json.Unmarshal([]byte(userB85DecodedJson), &amp;decodedUser)
    if err != nil {
        fmt.Println(&quot;Error deserializing json bytes&quot;, err)
    }

   fmt.Println(fmt.Sprintf(&quot;Deserialized User:%v&quot;, decodedUser))

   //NOW WE ADD THESE LINES

   fmt.Println(&quot;length of original json string&quot;, len(userOriginalJson))
   fmt.Println(&quot;length of decoded json string&quot;, len(userB85DecodedJson))
}
</code></pre><p>Now the two last lines of output will show:</p><pre><code>length of original json string 43  
length of decoded json string 44  
</code></pre><p>So we see that <strong>there is a difference between the original and the decoded string!</strong> How is that possible?</p><p>The only hint I found about why this might be happening is in the ridiculously succint (as usual) ascii85 go documentation:</p><p>|[...] The encoding handles 4-byte chunks, using a special encoding for the last fragment[...]</p><p>So what if the issue is that because the input length to decodeCookie (the json string) is not a multiple of 4 <code>ascii85</code> adds null values to the nearest multiple, turning a 43 length byte array into a 44 length byte array?</p><p>We can fix this by removing the null bytes from the output byte array, using the convenient <code>bytes.trim</code> function:</p><pre><code>package main

import (  
    &quot;fmt&quot;
    &quot;bytes&quot;
    &quot;encoding/json&quot;
    &quot;encoding/ascii85&quot;
)




type User struct {  
   Age int
   Interests []string
}

func decodeCookie(cookieValue string) string {  
    cookieEncodedBytes := []byte(cookieValue)
    cookieDecodedBytes := make([]byte, len(cookieEncodedBytes))
    nCookieDecodedBytes, _, _ := ascii85.Decode(cookieDecodedBytes, cookieEncodedBytes, true)
    cookieDecodedBytes = cookieDecodedBytes[:nCookieDecodedBytes]

        //ascii85 adds /x00 null bytes at the end
    cookieDecodedBytes = bytes.Trim(cookieDecodedBytes, &quot;\x00&quot;)
    return string(cookieDecodedBytes)
}

func encodeCookie(cookieValue string) string {  
    cookieBytes := []byte(cookieValue)
    cookieEncodedb85Bytes := make([]byte, ascii85.MaxEncodedLen(len(cookieBytes)))
    _ = ascii85.Encode(cookieEncodedb85Bytes, cookieBytes)
    cookieEncodedString := string(cookieEncodedb85Bytes)
    return cookieEncodedString
}

func main() {  
    user := User{
          25, 
          []string{&quot;music&quot;, &quot;football&quot;},
    }

    userOriginalJson, _ := json.Marshal(user) 
    fmt.Println(&quot;User as json&quot;, string(userOriginalJson))

    userB85Encoded := encodeCookie(string(userOriginalJson))
    fmt.Println(&quot;User as jsonB85&quot;, userB85Encoded)


    userB85DecodedJson := decodeCookie(userB85Encoded)
    fmt.Println(&quot;User as json&quot;, userB85DecodedJson)

    decodedUser := User{}
    err := json.Unmarshal([]byte(userB85DecodedJson), &amp;decodedUser)
    if err != nil {
        fmt.Println(&quot;Error deserializing json bytes&quot;, err)
    }

   fmt.Println(fmt.Sprintf(&quot;Deserialized User:%v&quot;, decodedUser))

   //NOW WE ADD THESE LINES

   fmt.Println(&quot;length of original json string&quot;, len(userOriginalJson))
   fmt.Println(&quot;length of decoded json string&quot;, len(userB85DecodedJson))
</code></pre><p><em><a href="https://play.golang.org/p/o_RZVT6JLj-?ref=blog.manugarri.com">here</a> is a go playground link to the code above.</em></p><p>Now the output is as expected:</p><pre><code>User as json {&quot;Age&quot;:25,&quot;Interests&quot;:[&quot;music&quot;,&quot;football&quot;]}  
User as jsonB85 HQkagAKj/j2(TqCDKKH1ATMs7,!&amp;pPD09o6@j3HJAoDU0@UX(h,$fTs  
User as json {&quot;Age&quot;:25,&quot;Interests&quot;:[&quot;music&quot;,&quot;football&quot;]}  
Deserialized User:{25 [music football]}  
length of original json string 43  
length of decoded json string 43  
</code></pre><p>And that fixes the issue! I hope that in the future the Golang community will focus a bit more on documentation and examples.</p><p>Thats all, thanks for reading!</p>]]></content:encoded></item><item><title><![CDATA[Note to self:Print statements not showing up on systemd logs? Do this]]></title><description><![CDATA[<p>Let&apos;s assume we have a service set up as follows:</p><pre><code>[Unit]
Description=systemd_microservice

[Service]
User=USER  
Group=GROUP  
WorkingDirectory=systemd_working_directory  
ExecStart=/usr/bin/python python_scripts.py  
SuccessExitStatus=143  
TimeoutStopSec=10  
Restart=on-failure  
RestartSec=10

[Install]
WantedBy=multi-user.target  
</code></pre><p>And inside <code>python_script.py</code> you</p>]]></description><link>https://blog.manugarri.com/note-to-self-print-statements-not-showing-up-on-systemd-logs-do-this/</link><guid isPermaLink="false">64723b0d50473e03b427268b</guid><dc:creator><![CDATA[Manuel Garrido]]></dc:creator><pubDate>Wed, 31 Jan 2018 14:24:33 GMT</pubDate><content:encoded><![CDATA[<p>Let&apos;s assume we have a service set up as follows:</p><pre><code>[Unit]
Description=systemd_microservice

[Service]
User=USER  
Group=GROUP  
WorkingDirectory=systemd_working_directory  
ExecStart=/usr/bin/python python_scripts.py  
SuccessExitStatus=143  
TimeoutStopSec=10  
Restart=on-failure  
RestartSec=10

[Install]
WantedBy=multi-user.target  
</code></pre><p>And inside <code>python_script.py</code> you have a bunch of print statements.</p><p>You set up your service and your surprise when you do</p><p><code>sudo journalctl -f -u python_service.service</code></p><p>The logs dont show up!</p><p>The reason is <strong>python stdout is being buffered when redirected to journal, and thus it only shows up in blocks</strong></p><p><strong>How to avoid this?</strong> Easy! Just set up your parameter <code>ExecStart</code> in the service file like this:</p><pre><code>[Unit]
Description=systemd_microservice, now with logs!

[Service]
User=USER  
Group=GROUP  
WorkingDirectory=systemd_working_directory  
ExecStart=/usr/bin/python -u python_scripts.py  
SuccessExitStatus=143  
TimeoutStopSec=10  
Restart=on-failure  
RestartSec=10

[Install]
WantedBy=multi-user.target  
</code></pre><p>Did you notice? the parameter <code>-u</code> makes forces the stdout and stderr to be unbuffered! Alternatively you can set the environment variable <code>PYTHONUNBUFFERED</code> to anything and will have the same effect. You can see the rest of the options for the python command line interface <a href="https://docs.python.org/3/using/cmdline.html?ref=blog.manugarri.com">here</a></p>]]></content:encoded></item><item><title><![CDATA[Note to self: Disable caps lock in Ubuntu 16.04]]></title><description><![CDATA[<p>Sources: <a href="https://era86.github.io/2017/01/14/remapping-capslock-to-escape-in-ubuntu-1604.html?ref=blog.manugarri.com">here</a> and <a href="https://www.linux.com/learn/hacking-your-linux-keyboard-xkb?ref=blog.manugarri.com">here</a></p><p>This post shows how to disable the caps lock key and enables it only by pressing both shift keys together.</p><h4 id="1-install-dconf">1. Install DCONF</h4><p><code>$ sudo apt-get install dconf-tools</code></p><h4 id="2-disable-caps-lock-and-reenable-it-as-pressing-both-shift-keys-at-once">2. Disable caps lock and reenable it as pressing both shift keys at once:</h4><p><code>$ setxkbmap -option &quot;caps:none&</code></p>]]></description><link>https://blog.manugarri.com/note-to-self-disable-caps-lock-in-ubuntu-16-04/</link><guid isPermaLink="false">64723b0d50473e03b427268a</guid><dc:creator><![CDATA[Manuel Garrido]]></dc:creator><pubDate>Wed, 25 Oct 2017 09:08:11 GMT</pubDate><content:encoded><![CDATA[<p>Sources: <a href="https://era86.github.io/2017/01/14/remapping-capslock-to-escape-in-ubuntu-1604.html?ref=blog.manugarri.com">here</a> and <a href="https://www.linux.com/learn/hacking-your-linux-keyboard-xkb?ref=blog.manugarri.com">here</a></p><p>This post shows how to disable the caps lock key and enables it only by pressing both shift keys together.</p><h4 id="1-install-dconf">1. Install DCONF</h4><p><code>$ sudo apt-get install dconf-tools</code></p><h4 id="2-disable-caps-lock-and-reenable-it-as-pressing-both-shift-keys-at-once">2. Disable caps lock and reenable it as pressing both shift keys at once:</h4><p><code>$ setxkbmap -option &quot;caps:none&quot; $ setxkbmap -option &quot;shift:both_capslock&quot;</code></p>]]></content:encoded></item><item><title><![CDATA[What is it to work in a Startup - the good and bad]]></title><description><![CDATA[<p>Nowadays, everyone seems to be fascinated about startups. Media bombard us with success story after success story, displaying incredible offices featuring slides instead of stairs and in house chefs preparing home made dinners.</p><p>I started working in November 2013 in a NYC based Startup named <a href="www.namely.com">Namely</a>. I was the 18th</p>]]></description><link>https://blog.manugarri.com/what-is-it-to-work-in-a-startup-the-good-and-bad/</link><guid isPermaLink="false">647234c750473e03b42725c8</guid><dc:creator><![CDATA[Manuel Garrido]]></dc:creator><pubDate>Wed, 25 Oct 2017 09:05:16 GMT</pubDate><content:encoded><![CDATA[<p>Nowadays, everyone seems to be fascinated about startups. Media bombard us with success story after success story, displaying incredible offices featuring slides instead of stairs and in house chefs preparing home made dinners.</p><p>I started working in November 2013 in a NYC based Startup named <a href="www.namely.com">Namely</a>. I was the 18th employee joining the company. As of now, 4 years later, Namely has more than 300 employees.</p><p>Back when I joined, we had two offices. One, in Manhattan, where the Account Management team (now called Client success), the Sales team (now called Inside Sales), and Operations team worked off. The other office, in Greenpoint, Brooklyn, where the Engineering and design teams were based off. &#xA0;This last office was where I was based off, but would go to Manhattan from time to time for meetings.</p><p>I can say without a doubt, that working at Namely is the best job I&apos;have ever had.</p><p>This post is a personal account of what it means working in one of those startups. I will talk about the good things - there are a lot - , but also about the bad things.</p><p><strong>DISCLAIMER</strong>: I left Namely in 2015. All opinions written here are based on the 2013-2015 period.</p><hr><h3 id="the-good">The Good</h3><figure class="kg-card kg-image-card"><img src="http://i.imgur.com/9XvDmYI.jpg" class="kg-image" alt="Namely Labs" loading="lazy"></figure><p>![]()</p><p><em>Namely Labs Lounge</em></p><h5 id="perks">Perks</h5><p>Perks are one of the aspects that are more representative of Startups. Things like unlimited vacation (which interestingly enough means that <a href="http://nymag.com/scienceofus/2014/12/when-an-unlimited-vacation-policy-backfires.html?ref=blog.manugarri.com">people take less vacation</a> than when they have limited number of days off), ping pong table, unlimited snacks and beer... Coming from the corporate world, where you need to pay to get a bottle of water, all these perks made me feel much more appreciated and more willing to put extra effort in.</p><figure class="kg-card kg-image-card"><img src="http://i.imgur.com/KnKD80d.jpg" class="kg-image" alt="Spanish Rules!" loading="lazy"></figure><p><em>Namely Labs Lounge</em></p><h4 id="growth">Growth</h4><p>Since your tasks and deliverables wont be very clear defined, and they will change as the company finds its own path, you will learn way more than you would if you were filling a hole in a Corporation, where your position would be clearly defined and you would continue to do the same until you changed your position.</p><p>In Namely, every employee gets to spend 3000$/year to spend in education, <strong>however he/she wants to</strong>. For example, I chose to go to Strata 2015, probably the most important Data Conference in the planet. Other employees prepare to register in Online MOOCs.</p><h4 id="its-feels-like-a-family">It&apos;s feels like a family</h4><p>When a startup is small, everybody knows each other. More important, every helps each other, working long hours not because you have to, but because everyone is on the same boat together. Thus, you get to know each other better than you would if you were part of a big team on a big company. You get beers together, celebrate birthdays, have internal jokes, <em>on a company level</em>.</p><p>I remember that time I went to our Manhattan office in December 2014, and I realized <strong>I didn&apos;t know everybody there!</strong>. It felt like something had changed, something had been lost.</p><figure class="kg-card kg-image-card"><img src="http://i.imgur.com/Akx7szU.jpg" class="kg-image" alt="Daily Standup, this was ALL the Engineering team" loading="lazy"></figure><p><em>Daily Standup, this was ALL the Engineering/Design team back in 2013. Now there are more than 50 people in both teams.</em></p><h4 id="you-have-an-impact">You have an impact</h4><p>One of the things I realized first when I started working at Namely was: <strong>&quot;If I don&apos;t do something that I believe needs to be done, nobody will.&quot;</strong></p><p>When you are working on a small company trying to become a succesful, big company, everybody has a lot on their plate, and the list of things pending to be done is huge.</p><p>So what do you do? You <em>build</em> those things. And then those things become your baby, and if they have an impact in the company <em>(sometimes they don&apos;t)</em>, that impact will have been <em>because of you</em>. Not because of some director somewhere thinking of <em>strategy</em> or other marketing buzzwords. It was <strong>you</strong> who built that. That feeling is priceless.</p><figure class="kg-card kg-image-card"><img src="http://i.imgur.com/qX8XYR8.png" class="kg-image" alt="Namely Cleaner, my first real project" loading="lazy"></figure><p><em>Namely Data Cleaner, my first web application</em></p><p>So that would be the good things. Now let&apos;s move to...</p><hr><h3 id="the-bad">The Bad</h3><p><br></p><h4 id="limited-resources">Limited Resources</h4><p>I remember when we were using <a href="trello.com">Trello</a> and the backlog list would have so many cards on it, it was painful to see.</p><p>In a successful startup, you realize very soon that <strong>time is the most scarce resource</strong>. It takes time to close a deal, it takes time to implement a feature, it takes time to wait for the Engineering team to deploy a feature that will allow you to get some metrics. If you are as impatient as I am, waiting for things beyond your control to happen so you can do other things can feel like torture.</p><h3 id="changes-changes">Changes Changes</h3><p>This one is a good/bad thing. Being on a small team on a young company means that culture can change very quickly (the institutional knowledge is very small), and also that teams embrace new tools and procedures all the time.</p><p>However, being able to change sometimes means that there is no stability to complete long term plans, and one can see how the efforts put into a specific project are washed away when the need for that project disappear.</p><h4 id="politics-rule">Politics rule</h4><p>Being a small team means that everybody means everybody.</p><p>And while that means that free riders are spotted very quickly, it also means that inter-personal relationships carry more weight when deciding what everyone is worth. That can affect career growth, and those that are either working remotely or just not good in makin g their voice heard can see how other people&apos;s careers grow faster than theirs.</p><h4 id="and-most-important-of-all-it-wont-last-forever">And most important of all... it won&apos;t last forever</h4><p>This is the reason why I&apos;m writing this article. <strong>ALL OF WHAT I WROTE DOES NOT APPLY TO THE COMPANY THAT NAMELY IS NOW</strong>.</p><p>By any measure, Namely is still a Startup, but it&apos;s on its way to become a medium sized business..</p><p>But most of all those good, and bad things I wrote about and that I loved/hated are gone.</p><p>As we grew, it was clear the need to start adding more structure to processes and teams.</p><p>Suddenly, you weren&apos;t able to work on a project that was very crucial for your department. It had to be scoped, and prioritized, meaning that for the majority of the time, you wouldn&apos;t do what you thought should have been done, but what the teams agreed had to be done.</p><p>And all of those changes happened because well, you just <em>can&apos;t</em> manage a 200 people company the same way as you manage a 20 people company.</p><p>So, if you are a part of a small startup, remember:</p><ul><li><em>Enjoy as much as you can, because it won&apos;t last forever</em></li><li><em>if things go well the company will grow and things will change</em></li><li><em>if things go bad, well, that will be the end of it</em></li></ul>]]></content:encoded></item></channel></rss>