{"id":13651894,"url":"https://github.com/ryanchao2012/airfly","last_synced_at":"2025-05-05T11:31:46.221Z","repository":{"id":57409311,"uuid":"387187107","full_name":"ryanchao2012/airfly","owner":"ryanchao2012","description":"Auto Generate Airflow's dag.py On The Fly","archived":false,"fork":false,"pushed_at":"2024-04-14T08:59:37.000Z","size":1082,"stargazers_count":8,"open_issues_count":1,"forks_count":2,"subscribers_count":2,"default_branch":"main","last_synced_at":"2024-04-14T13:10:55.732Z","etag":null,"topics":["airflow","airfly","ast","automation","codegen","dag-automation","gutt","python"],"latest_commit_sha":null,"homepage":"","language":"Python","has_issues":true,"has_wiki":null,"has_pages":null,"mirror_url":null,"source_name":null,"license":"mit","status":null,"scm":"git","pull_requests_enabled":true,"icon_url":"https://github.com/ryanchao2012.png","metadata":{"files":{"readme":"README.md","changelog":null,"contributing":null,"funding":null,"license":"LICENSE","code_of_conduct":null,"threat_model":null,"audit":null,"citation":null,"codeowners":null,"security":null,"support":null,"governance":null,"roadmap":null,"authors":null,"dei":null}},"created_at":"2021-07-18T13:50:58.000Z","updated_at":"2024-04-16T17:30:33.052Z","dependencies_parsed_at":"2023-12-10T07:19:49.925Z","dependency_job_id":"dbce7467-c5b0-420c-b5e2-f9a8f350fb8f","html_url":"https://github.com/ryanchao2012/airfly","commit_stats":{"total_commits":61,"total_committers":1,"mean_commits":61.0,"dds":0.0,"last_synced_commit":"fa27ee71128148902b75516b47f9d14d055c3200"},"previous_names":[],"tags_count":1,"template":false,"template_full_name":null,"repository_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/ryanchao2012%2Fairfly","tags_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/ryanchao2012%2Fairfly/tags","releases_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/ryanchao2012%2Fairfly/releases","manifests_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/ryanchao2012%2Fairfly/manifests","owner_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners/ryanchao2012","download_url":"https://codeload.github.com/ryanchao2012/airfly/tar.gz/refs/heads/main","host":{"name":"GitHub","url":"https://github.com","kind":"github","repositories_count":252489054,"owners_count":21756256,"icon_url":"https://github.com/github.png","version":null,"created_at":"2022-05-30T11:31:42.601Z","updated_at":"2022-07-04T15:15:14.044Z","host_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub","repositories_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories","repository_names_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repository_names","owners_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners"}},"keywords":["airflow","airfly","ast","automation","codegen","dag-automation","gutt","python"],"created_at":"2024-08-02T02:00:53.257Z","updated_at":"2025-05-05T11:31:46.214Z","avatar_url":"https://github.com/ryanchao2012.png","language":"Python","funding_links":[],"categories":["Libraries, Hooks, Utilities"],"sub_categories":[],"readme":"![](https://github.com/ryanchao2012/airfly/actions/workflows/airfly-run-unittests.yml/badge.svg)\n![](https://img.shields.io/pypi/v/airfly.svg)\n![](https://img.shields.io/pypi/pyversions/airfly)\n![](https://img.shields.io/github/license/ryanchao2012/airfly)\n\n\n# AirFly: Auto Generate Airflow's `dag.py` On The Fly\n\nEffective data pipeline management is essential for a company's data operations. Many engineering teams rely on tools like Airflow to organize various batch processing tasks, such as ETL/ELT workflows, data reporting pipelines, machine learning projects and so on.\n\nAirflow is a powerful tool for task scheduling and orchestration, allowing users to define workflows as \"DAGs\". A typical DAG represents a data pipeline and includes a series of tasks along with their dependencies.\n\nAs a pipeline grows in complexity, more tasks and interdependencies are added, which often lead to confusion and disrupt the structure of the DAG, resulting in lower code quality and making it more difficult to maintain and update, especially in collaborative environments.\n\n`airfly` aims to alleviate such challenges and streamline the development lifecycle. It assumes that tasks are encapsulated in a specific structure, with dependencies defined as part of the task attributes. During deployment, `airfly` recursively collects all tasks, resolves the dependency tree, and automatically generates the DAG.\n\n\u003cimg src=\"https://github.com/ryanchao2012/airfly/blob/main/assets/graph-view.png?raw=true\" width=\"800\"\u003e\u003c/img\u003e\n***airfly** helps you build complex dags*\n\n\n\n## Key Features\n\n* `dag.py` Automation: focus on your tasks and let airfly handle the rest.\n* No Airflow Installation Required: keep your environment lean without the need for Airflow.\n* Task Group Support: a nice feature from Airflow 2.0+.\n* Duck Typing Support: flexible class inheritance for greater customization.\n\n\n## Install\n\nDownload `airfly` from PyPI\n\n```\n$ pip install airfly\n\n$ airfly --help\nUsage: airfly [OPTIONS]\n\nOptions:\n  --version                   Show version and exit.\n  -n, --name TEXT             Assign to DAG id.\n  -m, --modname TEXT          Name of the module to search tasks for building\n                              the task dependency tree and using it to\n                              generate the airflow DAG file.\n  -p, --path TEXT             Insert into \"sys.path\" to include certain\n                              modules, multi-value is allowed.\n  -e, --exclude-pattern TEXT  Exclude the tasks from the dependency tree if\n                              their __qualname__ get matched with this regex\n                              pattern.\n  -i, --includes TEXT         Paths of python files, the code within will be\n                              included in the output DAG file, multi-value is\n                              allowed.\n  -d, --dag-params TEXT       Parameters to construct DAG object, defined by a\n                              dictionary in a python file. Pass this option\n                              with \u003cpython-file\u003e:\u003cvariable\u003e form, the\n                              \u003cvariable\u003e should be the dictionary which will\n                              be passed to DAG as keyword arguments.\n  -t, --task-class TEXT       Target task class to search, default:\n                              'airfly.model.v1.AirFly'\n  -g, --task-group BOOLEAN    Whether to enable TaskGroup, default: True\n  --help                      Show this message and exit.\n```\n\n## How It Works\n\n`airfly` assumes that tasks are defined within a Python module (or package, such as `main_dag` in the example below). Each task holds attributes corresponding to an Airflow operator, and the dependencies are declared by assigning `upstream` or `downstream`. As `airfly` walks through the module, it discovers and collects all tasks, resolves the dependency tree, and generates the `DAG` in Python code, which can then be saved as `dag.py`.\n\n\n```sh\nmain_dag\n├── __init__.py\n├── mod_a.py\n│   ├── task_a1\n│   └── task_a2\n│       └── upstream: task_a1\n├── mod_b.py\n│   └── task_b1\n│       └── downstream: task_a1, task_a2\n├── sub_dag\n│   ├── __init__.py\n│   ├── mod_c.py\n:   :\n```\n\n\n### Define your task with `AirFly`\n\nDeclare a task as follows(see [demo](https://github.com/ryanchao2012/airfly/blob/main/examples/tutorial/demo.py)):\n\n```python\n# in demo.py\nfrom airfly.model import AirFly\n\n\nclass print_date(AirFly):\n    op_class = \"BashOperator\"\n    op_params = dict(bash_command=\"date\")\n\n\n# During DAG generation,\n# This class will be auto-converted to the following code:\n# examples_tutorial_demo_print_date = BashOperator(\n#     task_id='examples.tutorial.demo.print_date',\n#     bash_command='date',\n#     task_group=group_examples_tutorial_demo\n# )\n\n```\n\n* `op_class (str)`: specifies the airflow operator to this task.\n* `op_params`: keyword arguments which will be passed to the operator(`op_class`), a parameter (i.e., value in the dictionary) could be one of the [primitive types](https://docs.python.org/3/library/stdtypes.html), a function or a class.\n\n\n#### Built-in Operators\n\nOperators defined in the official Airflow package, such as `BashOperator`, `PythonOperator`, and `KubernetesPodOperator`, are considered built-in, including those contributed by the community through various providers (e.g., Google, Facebook, OpenAI).\n\nTo use a built-in operator, assign `op_class` to its name and specify corresponding parameters using `op_params`:\n\n```python\nfrom airfly.model import AirFly\n\ndef log_sql(**kwargs):\n    print(\"Python task decorator query: %s\", str(kwargs[\"templates_dict\"][\"query\"]))\n\nclass Task1(AirFly):\n\n    op_class = \"KubernetesPodOperator\"\n    op_params = dict(\n        image=\"debian\",\n        cmds=[\"bash\", \"-cx\"],\n        arguments=[\"echo\", \"10\"],\n        labels={\"foo\": \"bar\"},\n    )\n\n\nclass Task2(AirFly):\n\n    op_class = \"PythonOperator\"\n    op_params = dict(\n        python_callable=log_sql,\n        templates_dict={\"query\": \"sql/sample.sql\"},\n        templates_exts=[\".sql\"],\n    )\n\n```\n\nSometimes, operators may have a naming ambiguity. For instance, `EmailOperator` could refer to either [`airflow.operators.email.EmailOperator`](https://github.com/apache/airflow/blob/2.10.4/airflow/operators/email.py#L29) or [`airflow.providers.smtp.operators.smtp.EmailOperator`](https://github.com/apache/airflow/blob/2.10.4/airflow/providers/smtp/operators/smtp.py#L29). To resolve such ambiguities, specify the correct module using `op_module`:  \n\n\n```python\n\nfrom airfly.model import AirFly\n\nclass Task3(AirFly):\n\n    op_class = \"EmailOperator\"\n    op_module = \"airflow.providers.smtp.operators.smtp\"\n    op_params = dict(\n        subject=\"Hello World\",\n        from_email=\"me@mail.com\",\n        to=\"you@mail.com\",\n    )\n\n```\n\nThis approach ensures that `Task3` explicitly references the `EmailOperator` from the `airflow.providers.smtp.operators.smtp` module, avoiding conflicts with similarly named operators.\n\n\n#### Private Operators\n\nOperators not included in the official Airflow package are considered private. Developers often create custom operators by extending existing built-in ones to meet their use cases. Since these custom operators are not registered within Airflow, `airfly` cannot automatically infer them by name.\n\nTo use a private operator, provide its class definition directly in `op_class`:\n\n```python\n# Assume the customized operator is defined in my_package/operators.py\nfrom airflow.operators.bash import BashOperator\n\nclass EchoOperator(BashOperator):\n    \"\"\"A customized example extending from BashOperator\"\"\"\n\n    def __init__(self, text: str, **kwargs):\n        cmd = f\"echo {text}\"\n        super().__init__(bash_command=cmd, **kwargs)\n\n# import the operator in the other file, e.g., my_package/tasks.py\nfrom airfly.model import AirFly\nfrom my_package.operators import EchoOperator\n\nclass Task4(AirFly):\n    op_class = EchoOperator\n    op_params = dict(text=\"Hello World\")\n\n```\n\nThis approach enables seamless integration of private, custom-built operators with `airfly`.\n\n\nYou can also define the attributes using `property`:\n\n```python\nfrom airfly.model import AirFly\n\n\nclass print_date(AirFly):\n\n    @property\n    def op_class(self):\n        return \"BashOperator\"\n\n    def greeting(self) -\u003e str:\n        return \"Hello World\"\n\n    @property\n    def op_params(self):\n        return dict(bash_command=f\"echo {self.greeting()}\")\n\n# Corresponding generated code:\n# examples_tutorial_demo_print_date = BashOperator(\n#     task_id='examples.tutorial.demo.print_date',\n#     bash_command='echo Hello World',\n#     task_group=group_examples_tutorial_demo\n# )\n\n```\n\nBy default, the class name (`print_date`) is used as the `task_id` for the applied operator after DAG generation. You can change this behavior by overriding `_get_taskid` as a class method. Make sure that the `task_id` is globally unique:\n\n```python\nimport re\nfrom airfly.model import AirFly\n\n\nclass PrintDate(AirFly):\n    @classmethod\n    def _get_taskid(cls):\n        # customize the task id\n        return \"task_\" + re.sub(r'(?\u003c!^)(?=[A-Z])', '_', cls.__name__).lower()\n\n    op_class = \"BashOperator\" \n    op_params = dict(bash_command=\"date\")\n\n\n# Corresponding generated code:\n# task_print_date = BashOperator(\n#     task_id='task_print_date',\n#     bash_command='date',\n#     task_group=group_task_print_date\n# )\n\n```\n\n\n### Define task dependency\n\nSpecifying task dependency with `upstream` or `downstream`.\n\n```python\n# in demo.py\n\nfrom textwrap import dedent\n\n\ntemplated_command = dedent(\n    \"\"\"\n{% for i in range(5) %}\n    echo \"{{ ds }}\"\n    echo \"{{ macros.ds_add(ds, 7)}}\"\n    echo \"{{ params.my_param }}\"\n{% endfor %}\n\"\"\"\n)\n\nclass templated(AirFly):\n    op_class = \"BashOperator\"\n    op_params = dict(depends_on_past=False,\n                  bash_command=templated_command,\n                  params={\"my_param\": \"Parameter I passed in\"})\n\n\nclass sleep(AirFly):\n    op_class = \"BashOperator\"\n    op_params = dict(depends_on_past=False, \n                  bash_command=\"sleep 5\",\n                  retries=3)\n\n    upstream = print_date\n\n    @property   # property also works\n    def downstream(self):\n        return (templated,)\n```\n\n`upstream`/`downstream`: return a task class or a iterable such as list or tuple.\n\n\n### Generate `dag.py`\nGenerate the dag by the command:\n```sh\n$ airfly --name demo_dag --modname demo \u003e dag.py\n```\n\nOutput in `dag.py`:\n\n```python\n# This file is auto-generated by airfly 1.0.0\nfrom airflow.models import DAG\nfrom airflow.utils.task_group import TaskGroup\n\nwith DAG(\"demo_dag\") as dag:\n    from airflow.operators.bash import BashOperator\n\n    group_demo = TaskGroup(group_id=\"demo\", prefix_group_id=False)\n    demo_print_date = BashOperator(\n        task_id=\"demo.print_date\", bash_command=\"date\", task_group=group_demo\n    )\n    demo_sleep = BashOperator(\n        task_id=\"demo.sleep\",\n        depends_on_past=False,\n        bash_command=\"sleep 5\",\n        retries=3,\n        task_group=group_demo,\n    )\n    demo_templated = BashOperator(\n        task_id=\"demo.templated\",\n        depends_on_past=False,\n        bash_command='\\n{% for i in range(5) %}\\n    echo \"{{ ds }}\"\\n    echo \"{{ macros.ds_add(ds, 7)}}\"\\n    echo \"{{ params.my_param }}\"\\n{% endfor %}\\n',\n        params={\"my_param\": \"Parameter I passed in\"},\n        task_group=group_demo,\n    )\n    demo_print_date \u003e\u003e demo_sleep\n    demo_sleep \u003e\u003e demo_templated\n```\n\nMake sure the `demo` module is in the current environment so that `airfly` can find it.\nIf it's not the case, you can use `--path/-p` to add the location of the module into `sys.path`, e.g.,\n\n```sh\n.\n├── folder\n│   └── subfolder\n│       └── demo.py   # Assume this is the target module\n:\n\n$ airfly --name demo_dag --path folder/subfolder --modname demo \u003e dag.py\n```\n\nThe target module can be a package as well, e.g.,\n\n```sh\n.\n├── folder\n│   └── subfolder\n│       └── demo   # Assume this is the target package\n│           ├── __init__.py\n│           ├── module_a.py\n:           :\n\n$ airfly --name demo_dag --path folder/subfolder --modname demo \u003e dag.py\n```\n\n\n## Inject parameters to `DAG`\n\nManage the DAG arguments in a python file(see [demo](https://github.com/ryanchao2012/airfly/blob/main/examples/tutorial/params.py)), then pass them to `airfly`.\n\n```python\n# in params.py\n\nfrom datetime import timedelta\n\nfrom airflow.utils.dates import days_ago\n\ndefault_args = {\n    \"owner\": \"airflow\",\n    \"depends_on_past\": False,\n    \"email\": [\"airflow@example.com\"],\n    \"email_on_failure\": False,\n    \"email_on_retry\": False,\n    \"retries\": 1,\n    \"retry_delay\": timedelta(minutes=5),\n    # 'queue': 'bash_queue',\n    # 'pool': 'backfill',\n    # 'priority_weight': 10,\n    # 'end_date': datetime(2016, 1, 1),\n    # 'wait_for_downstream': False,\n    # 'dag': dag,\n    # 'sla': timedelta(hours=2),\n    # 'execution_timeout': timedelta(seconds=300),\n    # 'on_failure_callback': some_function,\n    # 'on_success_callback': some_other_function,\n    # 'on_retry_callback': another_function,\n    # 'sla_miss_callback': yet_another_function,\n    # 'trigger_rule': 'all_success'\n}\n\n# Assume this is the argument we would like to pass to the DAG.\ndag_kwargs = dict(\n    default_args=default_args,\n    description=\"A simple tutorial DAG\",\n    schedule_interval=timedelta(days=1),\n    start_date=days_ago(2),\n    tags=[\"example\"],\n)\n```\n\nInject the arguments by passing `--dag-params` option, with the format of `\u003cpython-file\u003e:\u003cvariable\u003e`:\n```\n$ airfly --name demo_dag --modname demo --dag-params params.py:dag_kwargs \u003e dag.py\n```\n\nOutput in `dag.py`:\n\n```python\n# This file is auto-generated by airfly 1.0.0\nfrom datetime import timedelta\n\nfrom airflow.models import DAG\nfrom airflow.utils.dates import days_ago\nfrom airflow.utils.task_group import TaskGroup\n\n# \u003e\u003e\u003e\u003e\u003e\u003e\u003e\u003e\u003e\u003e Include from 'params.py'\ndefault_args = {\n    \"owner\": \"airflow\",\n    \"depends_on_past\": False,\n    \"email\": [\"airflow@example.com\"],\n    \"email_on_failure\": False,\n    \"email_on_retry\": False,\n    \"retries\": 1,\n    \"retry_delay\": timedelta(minutes=5),\n}\ndag_kwargs = dict(\n    default_args=default_args,\n    description=\"A simple tutorial DAG\",\n    schedule_interval=timedelta(days=1),\n    start_date=days_ago(2),\n    tags=[\"example\"],\n)\n# \u003c\u003c\u003c\u003c\u003c\u003c\u003c\u003c\u003c\u003c End of code insertion\nwith DAG(\"demo_dag\", **dag_kwargs) as dag:\n    from airflow.operators.bash import BashOperator\n\n    group_demo = TaskGroup(group_id=\"demo\", prefix_group_id=False)\n    demo_print_date = BashOperator(\n        task_id=\"demo.print_date\", bash_command=\"date\", task_group=group_demo\n    )\n    demo_sleep = BashOperator(\n        task_id=\"demo.sleep\",\n        depends_on_past=False,\n        bash_command=\"sleep 5\",\n        retries=3,\n        task_group=group_demo,\n    )\n    demo_templated = BashOperator(\n        task_id=\"demo.templated\",\n        depends_on_past=False,\n        bash_command='\\n{% for i in range(5) %}\\n    echo \"{{ ds }}\"\\n    echo \"{{ macros.ds_add(ds, 7)}}\"\\n    echo \"{{ params.my_param }}\"\\n{% endfor %}\\n',\n        params={\"my_param\": \"Parameter I passed in\"},\n        task_group=group_demo,\n    )\n    demo_print_date \u003e\u003e demo_sleep\n    demo_sleep \u003e\u003e demo_templated\n\n```\n\nAs you can see, `airfly` wraps required information including variables and import dependencies into output code, and pass the specified value to `DAG` object.\n\n\n## Exclude tasks from codegen\n\nBy passing `--exclude-pattern` to match any unwanted objects with their `__qualname__`. then filter them out.\n\n```\n$ airfly --name demo_dag --modname demo --exclude-pattern templated \u003e dag.py\n```\n\nOutput in `dag.py`:\n\n```python\n# This file is auto-generated by airfly 1.0.0\nfrom airflow.models import DAG\nfrom airflow.utils.task_group import TaskGroup\n\nwith DAG(\"demo_dag\") as dag:\n    from airflow.operators.bash import BashOperator\n\n    group_demo = TaskGroup(group_id=\"demo\", prefix_group_id=False)\n    demo_print_date = BashOperator(\n        task_id=\"demo.print_date\", bash_command=\"date\", task_group=group_demo\n    )\n    demo_sleep = BashOperator(\n        task_id=\"demo.sleep\",\n        depends_on_past=False,\n        bash_command=\"sleep 5\",\n        retries=3,\n        task_group=group_demo,\n    )\n    demo_print_date \u003e\u003e demo_sleep\n\n```\n\nThe `templated` task is gone.\n\n\n### Task Group\n\n`airfly`  defines `TaskGroup` in the DAG context and assigns `task_group` to each operator for you.\nIt maps the module hierarchy to the nested group structure,\nso the tasks in the same python module will be grouped closer.\nIf you don't like this feature, pass `--task-group`/`-g` with `0` to disable it. \n\n\n## Duck Typing\n\nIn fact, there's no need to inherite from `AirFly`, you can have your own task class definition, as long as it provides certain attributes, `airfly` can still work for you.\n\n\n```python\n# my_task_model.py\nfrom typing import Any, Dict, Iterable, Type, Union\n\nTaskClass = Type[\"MyTask\"]\n\n\nclass MyTask:\n    # airfly assumes these attributes exist\n    op_class: str = \"BashOperator\"\n    op_params: Dict[str, Any] = None\n    op_module: str = None\n    upstream: Union[TaskClass, Iterable[TaskClass]] = None\n    downstream: Union[TaskClass, Iterable[TaskClass]] = None\n\n    # other stuffs\n\n\n# in demo2.py\nfrom textwrap import dedent\n\nfrom my_task_model import MyTask\n\n\nclass print_date(MyTask):\n    op_params = dict(bash_command=\"date\")\n\n\ntemplated_command = dedent(\n    \"\"\"\n{% for i in range(5) %}\n    echo \"{{ ds }}\"\n    echo \"{{ macros.ds_add(ds, 7)}}\"\n    echo \"{{ params.my_param }}\"\n{% endfor %}\n\"\"\"\n)\n\n\nclass templated(MyTask):\n    op_params = dict(\n        depends_on_past=False,\n        bash_command=templated_command,\n        params={\"my_param\": \"Parameter I passed in\"},\n    )\n\n\nclass sleep(MyTask):\n    op_params = dict(depends_on_past=False, bash_command=\"sleep 5\", retries=3)\n\n    upstream = print_date\n    downstream = (templated,)\n```\n\nPass the task definition with `--task-class`\n\n```\n$ airfly --name demo_dag --modname demo2 --task-class my_task_model.MyTask \u003e dag.py\n```\n\nOutput in `dag.py`:\n\n```python\n# This file is auto-generated by airfly 1.0.0\nfrom airflow.models import DAG\nfrom airflow.utils.task_group import TaskGroup\n\nwith DAG(\"demo_dag\") as dag:\n    from airflow.operators.bash import BashOperator\n\n    group_demo2 = TaskGroup(group_id=\"demo2\", prefix_group_id=False)\n    demo2_print_date = BashOperator(\n        task_id=\"demo2.print_date\", bash_command=\"date\", task_group=group_demo2\n    )\n    demo2_sleep = BashOperator(\n        task_id=\"demo2.sleep\",\n        depends_on_past=False,\n        bash_command=\"sleep 5\",\n        retries=3,\n        task_group=group_demo2,\n    )\n    demo2_templated = BashOperator(\n        task_id=\"demo2.templated\",\n        depends_on_past=False,\n        bash_command='\\n{% for i in range(5) %}\\n    echo \"{{ ds }}\"\\n    echo \"{{ macros.ds_add(ds, 7)}}\"\\n    echo \"{{ params.my_param }}\"\\n{% endfor %}\\n',\n        params={\"my_param\": \"Parameter I passed in\"},\n        task_group=group_demo2,\n    )\n    demo2_print_date \u003e\u003e demo2_sleep\n    demo2_sleep \u003e\u003e demo2_templated\n```\n\n\n## Examples\n\nPlease explore more examples [here](https://github.com/ryanchao2012/airfly/blob/main/examples).\n\n","project_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Fryanchao2012%2Fairfly","html_url":"https://awesome.ecosyste.ms/projects/github.com%2Fryanchao2012%2Fairfly","lists_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Fryanchao2012%2Fairfly/lists"}