From d448b18161efea4afd5829175285bda5422c3b85 Mon Sep 17 00:00:00 2001 From: "sid.gupta" Date: Fri, 13 Oct 2017 13:12:48 -0700 Subject: [PATCH 1/3] [AIRFLOW-1571] Add AWS Lambda Hook --- airflow/contrib/hooks/__init__.py | 3 +- airflow/contrib/hooks/aws_lambda_hook.py | 62 ++++++++++++++++ tests/contrib/hooks/test_aws_lambda_hook.py | 82 +++++++++++++++++++++ 3 files changed, 146 insertions(+), 1 deletion(-) create mode 100644 airflow/contrib/hooks/aws_lambda_hook.py create mode 100644 tests/contrib/hooks/test_aws_lambda_hook.py diff --git a/airflow/contrib/hooks/__init__.py b/airflow/contrib/hooks/__init__.py index 977c2cee2eef4..2891980d11f79 100644 --- a/airflow/contrib/hooks/__init__.py +++ b/airflow/contrib/hooks/__init__.py @@ -47,7 +47,8 @@ 'cloudant_hook': ['CloudantHook'], 'fs_hook': ['FSHook'], 'wasb_hook': ['WasbHook'], - 'gcp_pubsub_hook': ['PubSubHook'] + 'gcp_pubsub_hook': ['PubSubHook'], + 'aws_dynamodb_hook': ['AwsDynamoDBHook'] } import os as _os diff --git a/airflow/contrib/hooks/aws_lambda_hook.py b/airflow/contrib/hooks/aws_lambda_hook.py new file mode 100644 index 0000000000000..c4f5fcd4efd41 --- /dev/null +++ b/airflow/contrib/hooks/aws_lambda_hook.py @@ -0,0 +1,62 @@ +# -*- coding: utf-8 -*- +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from airflow.contrib.hooks.aws_hook import AwsHook + + +class AwsLambdaHook(AwsHook): + """ + Interact with AWS Lambda + + :param function_name: AWS Lambda Function Name + :type function_name: str + :param region_name: AWS Region Name (example: us-west-2) + :type region_name: str + :param log_type: Tail Invocation Request + :type log_type: str + :param qualifier: AWS Lambda Function Version or Alias Name + :type qualifier: str + :param invocation_type: AWS Lambda Invocation Type (RequestResponse, Event etc) + :type invocation_type: str + """ + + def __init__(self, function_name, region_name=None, log_type='None', qualifier='$LATEST', + invocation_type='RequestResponse', *args, **kwargs): + self.function_name = function_name + self.region_name = region_name + self.log_type = log_type + self.invocation_type = invocation_type + self.qualifier = qualifier + super(AwsLambdaHook, self).__init__(*args, **kwargs) + + def get_conn(self): + self.conn = self.get_client_type('lambda', self.region_name) + return self.conn + + def invoke_lambda(self, payload): + """ + Invoke Lambda function + """ + + awslambda_conn = self.get_conn() + + response = awslambda_conn.invoke( + FunctionName=self.function_name, + InvocationType=self.invocation_type, + LogType=self.log_type, + Payload=payload, + Qualifier=self.qualifier + ) + + return response diff --git a/tests/contrib/hooks/test_aws_lambda_hook.py b/tests/contrib/hooks/test_aws_lambda_hook.py new file mode 100644 index 0000000000000..818bdbaf70f81 --- /dev/null +++ b/tests/contrib/hooks/test_aws_lambda_hook.py @@ -0,0 +1,82 @@ +# -*- coding: utf-8 -*- +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +import unittest +import io +import json +import textwrap +import zipfile +import base64 + +from airflow.contrib.hooks.aws_lambda_hook import AwsLambdaHook + +try: + from moto import mock_lambda +except ImportError: + mock_lambda = None + + +class TestAwsLambdaHook(unittest.TestCase): + + @unittest.skipIf(mock_lambda is None, 'mock_lambda package not present') + @mock_lambda + def test_get_conn_returns_a_boto3_connection(self): + hook = AwsLambdaHook(aws_conn_id='aws_default', + function_name="test_function", region_name="us-east-1") + self.assertIsNotNone(hook.get_conn()) + + def lambda_function(self): + code = textwrap.dedent(""" +def lambda_handler(event, context): + return event + """) + zip_output = io.BytesIO() + zip_file = zipfile.ZipFile(zip_output, 'w', zipfile.ZIP_DEFLATED) + zip_file.writestr('lambda_function.zip', code) + zip_file.close() + zip_output.seek(0) + return zip_output.read() + + @unittest.skipIf(mock_lambda is None, 'mock_lambda package not present') + @mock_lambda + def test_invoke_lambda_function(self): + + hook = AwsLambdaHook(aws_conn_id='aws_default', + function_name="test_function", region_name="us-east-1") + + hook.get_conn().create_function( + FunctionName='test_function', + Runtime='python2.7', + Role='test-iam-role', + Handler='lambda_function.lambda_handler', + Code={ + 'ZipFile': self.lambda_function(), + }, + Description='test lambda function', + Timeout=3, + MemorySize=128, + Publish=True, + ) + + input = {'hello': 'airflow'} + response = hook.invoke_lambda(payload=json.dumps(input)) + + self.assertEquals(response["StatusCode"], 202) + self.assertEquals(base64.b64decode(response["LogResult"]).decode( + 'utf-8'), json.dumps(input)) + + +if __name__ == '__main__': + unittest.main() From 42c85e08967332b3d08287c46aec7c7d4208a713 Mon Sep 17 00:00:00 2001 From: "sid.gupta" Date: Sun, 22 Oct 2017 13:18:51 -0700 Subject: [PATCH 2/3] [AIRFLOW-1571] Add AWS Lambda Hook --- airflow/contrib/hooks/aws_lambda_hook.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/airflow/contrib/hooks/aws_lambda_hook.py b/airflow/contrib/hooks/aws_lambda_hook.py index c4f5fcd4efd41..bcd1c7fd7091a 100644 --- a/airflow/contrib/hooks/aws_lambda_hook.py +++ b/airflow/contrib/hooks/aws_lambda_hook.py @@ -46,7 +46,7 @@ def get_conn(self): def invoke_lambda(self, payload): """ - Invoke Lambda function + Invoke Lambda Function """ awslambda_conn = self.get_conn() From 3b7a9eeade1b99e4ef22300ec04be85b695e105e Mon Sep 17 00:00:00 2001 From: "sid.gupta" Date: Sun, 22 Oct 2017 21:54:49 -0700 Subject: [PATCH 3/3] [AIRFLOW-1571] Add AWS Lambda Hook --- .codecov.yml | 14 + .coveragerc | 20 + .editorconfig | 45 + .github/PULL_REQUEST_TEMPLATE.md | 27 + .gitignore | 126 + .rat-excludes | 37 + .readthedocs.yml | 20 + .travis.yml | 112 + CHANGELOG.txt | 678 + CONTRIBUTING.md | 197 + DISCLAIMER | 1 + INSTALL | 9 + LICENSE | 238 + MANIFEST.in | 24 + NOTICE | 6 + README.md | 205 + TODO.md | 30 + UPDATING.md | 319 + airflow/__init__.py | 86 + airflow/alembic.ini | 80 + airflow/api/__init__.py | 42 + airflow/api/auth/__init__.py | 13 + airflow/api/auth/backend/__init__.py | 13 + airflow/api/auth/backend/default.py | 29 + airflow/api/auth/backend/kerberos_auth.py | 132 + airflow/api/client/__init__.py | 14 + airflow/api/client/api_client.py | 60 + airflow/api/client/json_client.py | 80 + airflow/api/client/local_client.py | 43 + airflow/api/common/__init__.py | 13 + airflow/api/common/experimental/__init__.py | 13 + airflow/api/common/experimental/get_task.py | 35 + .../common/experimental/get_task_instance.py | 49 + airflow/api/common/experimental/mark_tasks.py | 218 + airflow/api/common/experimental/pool.py | 85 + .../api/common/experimental/trigger_dag.py | 59 + airflow/bin/__init__.py | 14 + airflow/bin/airflow | 27 + airflow/bin/airflow_scheduler_autorestart.sh | 19 + airflow/bin/cli.py | 1638 ++ airflow/config_templates/__init__.py | 13 + .../airflow_local_settings.py | 86 + airflow/config_templates/default_airflow.cfg | 438 + airflow/config_templates/default_celery.py | 58 + airflow/config_templates/default_test.cfg | 94 + airflow/configuration.py | 438 + airflow/contrib/__init__.py | 14 + airflow/contrib/auth/__init__.py | 14 + airflow/contrib/auth/backends/__init__.py | 14 + .../auth/backends/github_enterprise_auth.py | 236 + airflow/contrib/auth/backends/google_auth.py | 189 + .../contrib/auth/backends/kerberos_auth.py | 148 + airflow/contrib/auth/backends/ldap_auth.py | 321 + .../contrib/auth/backends/password_auth.py | 154 + .../example_databricks_operator.py | 82 + .../example_emr_job_flow_automatic_steps.py | 72 + .../example_emr_job_flow_manual_steps.py | 93 + .../example_dags/example_qubole_operator.py | 194 + .../example_dags/example_twitter_README.md | 36 + .../example_dags/example_twitter_dag.py | 185 + airflow/contrib/executors/__init__.py | 14 + airflow/contrib/executors/mesos_executor.py | 290 + airflow/contrib/hooks/__init__.py | 57 + airflow/contrib/hooks/aws_dynamodb_hook.py | 60 + airflow/contrib/hooks/aws_hook.py | 139 + airflow/contrib/hooks/aws_lambda_hook.py | 62 + airflow/contrib/hooks/bigquery_hook.py | 1087 + airflow/contrib/hooks/cloudant_hook.py | 78 + airflow/contrib/hooks/databricks_hook.py | 222 + airflow/contrib/hooks/datadog_hook.py | 135 + airflow/contrib/hooks/datastore_hook.py | 177 + airflow/contrib/hooks/emr_hook.py | 62 + airflow/contrib/hooks/fs_hook.py | 41 + airflow/contrib/hooks/ftp_hook.py | 253 + airflow/contrib/hooks/gcp_api_base_hook.py | 140 + airflow/contrib/hooks/gcp_dataflow_hook.py | 165 + airflow/contrib/hooks/gcp_dataproc_hook.py | 164 + airflow/contrib/hooks/gcp_mlengine_hook.py | 267 + airflow/contrib/hooks/gcp_pubsub_hook.py | 93 + airflow/contrib/hooks/gcs_hook.py | 265 + airflow/contrib/hooks/jira_hook.py | 79 + airflow/contrib/hooks/qubole_hook.py | 210 + airflow/contrib/hooks/redis_hook.py | 88 + airflow/contrib/hooks/salesforce_hook.py | 318 + airflow/contrib/hooks/spark_sql_hook.py | 156 + airflow/contrib/hooks/spark_submit_hook.py | 257 + airflow/contrib/hooks/sqoop_hook.py | 320 + airflow/contrib/hooks/ssh_hook.py | 212 + airflow/contrib/hooks/vertica_hook.py | 48 + airflow/contrib/hooks/wasb_hook.py | 112 + airflow/contrib/operators/__init__.py | 48 + .../operators/bigquery_check_operator.py | 125 + .../contrib/operators/bigquery_operator.py | 107 + .../bigquery_table_delete_operator.py | 61 + .../contrib/operators/bigquery_to_bigquery.py | 83 + airflow/contrib/operators/bigquery_to_gcs.py | 95 + .../contrib/operators/databricks_operator.py | 254 + .../contrib/operators/dataflow_operator.py | 251 + .../contrib/operators/dataproc_operator.py | 942 + .../operators/datastore_export_operator.py | 103 + .../operators/datastore_import_operator.py | 93 + airflow/contrib/operators/druid_operator.py | 47 + airflow/contrib/operators/ecs_operator.py | 127 + .../operators/emr_add_steps_operator.py | 58 + .../operators/emr_create_job_flow_operator.py | 63 + .../emr_terminate_job_flow_operator.py | 52 + airflow/contrib/operators/file_to_gcs.py | 70 + airflow/contrib/operators/file_to_wasb.py | 57 + airflow/contrib/operators/fs_operator.py | 56 + .../operators/gcs_download_operator.py | 78 + airflow/contrib/operators/gcs_to_bq.py | 196 + airflow/contrib/operators/hipchat_operator.py | 130 + airflow/contrib/operators/hive_to_dynamodb.py | 101 + airflow/contrib/operators/jira_operator.py | 89 + .../contrib/operators/mlengine_operator.py | 562 + .../operators/mlengine_operator_utils.py | 245 + .../operators/mlengine_prediction_summary.py | 175 + airflow/contrib/operators/mysql_to_gcs.py | 223 + airflow/contrib/operators/pubsub_operator.py | 170 + airflow/contrib/operators/qubole_operator.py | 171 + airflow/contrib/operators/sftp_operator.py | 96 + .../contrib/operators/spark_sql_operator.py | 103 + .../operators/spark_submit_operator.py | 137 + airflow/contrib/operators/sqoop_operator.py | 186 + airflow/contrib/operators/ssh_operator.py | 113 + airflow/contrib/operators/vertica_operator.py | 44 + airflow/contrib/operators/vertica_to_hive.py | 130 + .../contrib/plugins/metastore_browser/main.py | 173 + .../templates/metastore_browser/base.html | 55 + .../templates/metastore_browser/db.html | 45 + .../templates/metastore_browser/dbs.html | 25 + .../templates/metastore_browser/table.html | 151 + airflow/contrib/sensors/__init__.py | 13 + airflow/contrib/sensors/bigquery_sensor.py | 66 + airflow/contrib/sensors/datadog_sensor.py | 78 + airflow/contrib/sensors/emr_base_sensor.py | 51 + .../contrib/sensors/emr_job_flow_sensor.py | 48 + airflow/contrib/sensors/emr_step_sensor.py | 52 + airflow/contrib/sensors/ftp_sensor.py | 62 + airflow/contrib/sensors/gcs_sensor.py | 123 + airflow/contrib/sensors/hdfs_sensors.py | 66 + airflow/contrib/sensors/jira_sensor.py | 141 + airflow/contrib/sensors/redis_key_sensor.py | 42 + airflow/contrib/sensors/wasb_sensor.py | 94 + airflow/contrib/task_runner/__init__.py | 13 + .../contrib/task_runner/cgroup_task_runner.py | 203 + airflow/dag/__init__.py | 13 + airflow/dag/base_dag.py | 96 + airflow/default_login.py | 88 + airflow/example_dags/__init__.py | 14 + airflow/example_dags/docker_copy_data.py | 97 + airflow/example_dags/entrypoint.sh | 14 + airflow/example_dags/example_bash_operator.py | 55 + .../example_dags/example_branch_operator.py | 54 + .../example_branch_python_dop_operator_3.py | 57 + .../example_dags/example_docker_operator.py | 63 + airflow/example_dags/example_http_operator.py | 93 + airflow/example_dags/example_latest_only.py | 34 + .../example_latest_only_with_trigger.py | 43 + ...example_passing_params_via_test_command.py | 58 + .../example_dags/example_python_operator.py | 57 + .../example_short_circuit_operator.py | 38 + airflow/example_dags/example_skip_dag.py | 54 + .../example_dags/example_subdag_operator.py | 71 + .../example_trigger_controller_dag.py | 63 + .../example_trigger_target_dag.py | 63 + airflow/example_dags/example_xcom.py | 69 + airflow/example_dags/subdags/__init__.py | 14 + airflow/example_dags/subdags/subdag.py | 33 + airflow/example_dags/test_utils.py | 30 + airflow/example_dags/tutorial.py | 96 + airflow/exceptions.py | 36 + airflow/executors/__init__.py | 81 + airflow/executors/base_executor.py | 170 + airflow/executors/celery_executor.py | 110 + airflow/executors/dask_executor.py | 76 + airflow/executors/local_executor.py | 226 + airflow/executors/sequential_executor.py | 52 + airflow/hooks/S3_hook.py | 253 + airflow/hooks/__init__.py | 88 + airflow/hooks/base_hook.py | 98 + airflow/hooks/dbapi_hook.py | 266 + airflow/hooks/druid_hook.py | 92 + airflow/hooks/hdfs_hook.py | 95 + airflow/hooks/hive_hooks.py | 763 + airflow/hooks/http_hook.py | 118 + airflow/hooks/jdbc_hook.py | 68 + airflow/hooks/mssql_hook.py | 47 + airflow/hooks/mysql_hook.py | 101 + airflow/hooks/oracle_hook.py | 141 + airflow/hooks/pig_hook.py | 88 + airflow/hooks/postgres_hook.py | 83 + airflow/hooks/presto_hook.py | 113 + airflow/hooks/samba_hook.py | 47 + airflow/hooks/sqlite_hook.py | 36 + airflow/hooks/webhdfs_hook.py | 104 + airflow/hooks/zendesk_hook.py | 98 + airflow/jobs.py | 2580 +++ airflow/logging_config.py | 64 + airflow/macros/__init__.py | 87 + airflow/macros/hive.py | 110 + airflow/migrations/__init__.py | 14 + airflow/migrations/env.py | 86 + airflow/migrations/script.py.mako | 37 + ...add_dag_id_state_index_on_dag_run_table.py | 37 + .../13eb55f81627_for_compatibility.py | 35 + .../1507a7289a2f_create_is_encrypted.py | 66 + ...e3_add_is_encrypted_column_to_variable_.py | 38 + .../versions/1b38cef5b76e_add_dagrun.py | 49 + .../211e584da130_add_ti_state_index.py | 38 + .../versions/2e541a1dcfed_task_duration.py | 44 + .../2e82aab8ef20_rename_user_table.py | 39 + ...0f54d61_more_logging_into_task_isntance.py | 44 + .../versions/40e67319e3a9_dagrun_config.py | 39 + .../versions/4446e08588_dagrun_start_end.py | 40 + ..._add_fractional_seconds_to_mysql_tables.py | 111 + .../502898887f84_adding_extra_to_log.py | 38 + .../versions/52d714495f0_job_id_indices.py | 39 + ...61833c1c74b_add_password_column_to_user.py | 38 + ...d17757c7a_add_pid_field_to_taskinstance.py | 37 + ...de9cddf6c9_add_task_fails_journal_table.py | 46 + .../8504051e801b_xcom_dag_task_indices.py | 38 + .../947454bf1dff_add_ti_job_id_index.py | 38 + airflow/migrations/versions/__init__.py | 14 + ...6_add_a_column_to_track_the_encryption_.py | 38 + ...dd_notification_sent_column_to_sla_miss.py | 38 + ...6_make_xcom_value_column_a_large_binary.py | 45 + ...7_add_max_tries_column_to_task_instance.py | 109 + ...ae31099d61_increase_text_size_for_mysql.py | 41 + .../versions/e3a246e0dc1_current_schema.py | 277 + .../f2ca10b85618_add_dag_stats_table.py | 42 + airflow/minihivecluster.py | 46 + airflow/models.py | 4773 ++++ airflow/operators/__init__.py | 123 + airflow/operators/bash_operator.py | 117 + airflow/operators/check_operator.py | 249 + airflow/operators/dagrun_operator.py | 77 + airflow/operators/docker_operator.py | 206 + airflow/operators/dummy_operator.py | 33 + airflow/operators/email_operator.py | 64 + airflow/operators/generic_transfer.py | 75 + airflow/operators/hive_operator.py | 106 + airflow/operators/hive_stats_operator.py | 187 + airflow/operators/hive_to_druid.py | 227 + airflow/operators/hive_to_mysql.py | 106 + airflow/operators/hive_to_samba_operator.py | 59 + airflow/operators/http_operator.py | 87 + airflow/operators/jdbc_operator.py | 60 + airflow/operators/latest_only_operator.py | 60 + airflow/operators/mssql_operator.py | 51 + airflow/operators/mssql_to_hive.py | 130 + airflow/operators/mysql_operator.py | 55 + airflow/operators/mysql_to_hive.py | 135 + airflow/operators/oracle_operator.py | 50 + airflow/operators/pig_operator.py | 67 + airflow/operators/postgres_operator.py | 55 + airflow/operators/presto_check_operator.py | 121 + airflow/operators/presto_to_mysql.py | 74 + airflow/operators/python_operator.py | 358 + airflow/operators/redshift_to_s3_operator.py | 104 + .../operators/s3_file_transform_operator.py | 109 + airflow/operators/s3_to_hive_operator.py | 250 + airflow/operators/sensors.py | 696 + airflow/operators/slack_operator.py | 119 + airflow/operators/sqlite_operator.py | 46 + airflow/operators/subdag_operator.py | 92 + airflow/plugins_manager.py | 125 + airflow/security/__init__.py | 14 + airflow/security/kerberos.py | 113 + airflow/security/utils.py | 80 + airflow/settings.py | 157 + airflow/task_runner/__init__.py | 38 + airflow/task_runner/base_task_runner.py | 157 + airflow/task_runner/bash_task_runner.py | 39 + airflow/ti_deps/__init__.py | 13 + airflow/ti_deps/dep_context.py | 117 + airflow/ti_deps/deps/__init__.py | 13 + airflow/ti_deps/deps/base_ti_dep.py | 147 + .../deps/dag_ti_slots_available_dep.py | 28 + airflow/ti_deps/deps/dag_unpaused_dep.py | 26 + airflow/ti_deps/deps/dagrun_exists_dep.py | 50 + .../deps/exec_date_after_start_date_dep.py | 37 + .../ti_deps/deps/not_in_retry_period_dep.py | 48 + airflow/ti_deps/deps/not_running_dep.py | 32 + airflow/ti_deps/deps/not_skipped_dep.py | 26 + airflow/ti_deps/deps/prev_dagrun_dep.py | 80 + .../ti_deps/deps/runnable_exec_date_dep.py | 48 + airflow/ti_deps/deps/task_concurrency_dep.py | 37 + airflow/ti_deps/deps/trigger_rule_dep.py | 193 + airflow/ti_deps/deps/valid_state_dep.py | 59 + airflow/utils/__init__.py | 35 + airflow/utils/asciiart.py | 55 + airflow/utils/compression.py | 38 + airflow/utils/dag_processing.py | 689 + airflow/utils/dates.py | 227 + airflow/utils/db.py | 341 + airflow/utils/decorators.py | 92 + airflow/utils/email.py | 169 + airflow/utils/file.py | 57 + airflow/utils/helpers.py | 381 + airflow/utils/json.py | 55 + airflow/utils/log/__init__.py | 13 + airflow/utils/log/file_task_handler.py | 195 + airflow/utils/log/gcs_task_handler.py | 191 + airflow/utils/log/logging_mixin.py | 61 + airflow/utils/log/s3_task_handler.py | 164 + airflow/utils/module_loading.py | 35 + airflow/utils/operator_helpers.py | 44 + airflow/utils/operator_resources.py | 121 + airflow/utils/state.py | 113 + airflow/utils/tests.py | 26 + airflow/utils/timeout.py | 52 + airflow/utils/trigger_rule.py | 36 + airflow/version.py | 16 + airflow/www/__init__.py | 14 + airflow/www/api/__init__.py | 14 + airflow/www/api/experimental/__init__.py | 14 + airflow/www/api/experimental/endpoints.py | 234 + airflow/www/app.py | 165 + airflow/www/blueprints.py | 32 + airflow/www/forms.py | 44 + airflow/www/gunicorn_config.py | 23 + airflow/www/static/ace.js | 18297 ++++++++++++++++ airflow/www/static/airflow.gif | Bin 0 -> 622963 bytes airflow/www/static/bootstrap-theme.css | 6494 ++++++ airflow/www/static/bootstrap-toggle.min.css | 28 + airflow/www/static/bootstrap-toggle.min.js | 9 + .../www/static/bootstrap3-typeahead.min.js | 21 + airflow/www/static/connection_form.js | 73 + airflow/www/static/d3.tip.v0.6.3.js | 302 + airflow/www/static/d3.v3.min.js | 5 + airflow/www/static/dagre-d3.js | 5007 +++++ airflow/www/static/dagre-d3.min.js | 2 + airflow/www/static/dagre.css | 38 + airflow/www/static/dataTables.bootstrap.css | 333 + airflow/www/static/docs | 1 + airflow/www/static/favicon.ico | Bin 0 -> 1406 bytes airflow/www/static/gantt-chart-d3v2.js | 267 + airflow/www/static/gantt.css | 57 + airflow/www/static/graph.css | 72 + airflow/www/static/jqClock.min.js | 27 + airflow/www/static/jquery.dataTables.css | 495 + airflow/www/static/jquery.dataTables.min.js | 189 + airflow/www/static/loading.gif | Bin 0 -> 16671 bytes airflow/www/static/main.css | 263 + airflow/www/static/mode-sql.js | 111 + airflow/www/static/nv.d3.css | 788 + airflow/www/static/nv.d3.js | 14260 ++++++++++++ airflow/www/static/para/parallel.css | 260 + airflow/www/static/para/parallel.js | 760 + airflow/www/static/para/webgl-2d.js | 1298 ++ airflow/www/static/pin.svg | 129 + airflow/www/static/pin_100.jpg | Bin 0 -> 6780 bytes airflow/www/static/pin_100.png | Bin 0 -> 10977 bytes airflow/www/static/pin_25.png | Bin 0 -> 1442 bytes airflow/www/static/pin_30.png | Bin 0 -> 2015 bytes airflow/www/static/pin_35.png | Bin 0 -> 2171 bytes airflow/www/static/pin_40.png | Bin 0 -> 2685 bytes airflow/www/static/pin_large.jpg | Bin 0 -> 399613 bytes airflow/www/static/pin_large.png | Bin 0 -> 358276 bytes airflow/www/static/screenshots/gantt.png | Bin 0 -> 31140 bytes airflow/www/static/screenshots/graph.png | Bin 0 -> 38282 bytes airflow/www/static/screenshots/tree.png | Bin 0 -> 35259 bytes airflow/www/static/sort_asc.png | Bin 0 -> 160 bytes airflow/www/static/sort_both.png | Bin 0 -> 201 bytes airflow/www/static/sort_desc.png | Bin 0 -> 158 bytes airflow/www/static/theme-crimson_editor.js | 137 + airflow/www/static/tree.css | 96 + airflow/www/static/underscore.js | 1257 ++ airflow/www/templates/admin/master.html | 109 + airflow/www/templates/airflow/chart.html | 58 + .../www/templates/airflow/chart/create.html | 42 + airflow/www/templates/airflow/chart/edit.html | 51 + airflow/www/templates/airflow/circles.html | 144 + airflow/www/templates/airflow/code.html | 43 + airflow/www/templates/airflow/config.html | 69 + airflow/www/templates/airflow/confirm.html | 35 + .../www/templates/airflow/conn_create.html | 23 + airflow/www/templates/airflow/conn_edit.html | 23 + airflow/www/templates/airflow/conn_list.html | 35 + airflow/www/templates/airflow/dag.html | 431 + airflow/www/templates/airflow/dag_code.html | 58 + .../www/templates/airflow/dag_details.html | 72 + airflow/www/templates/airflow/dags.html | 473 + .../www/templates/airflow/duration_chart.html | 77 + airflow/www/templates/airflow/gantt.html | 67 + airflow/www/templates/airflow/graph.html | 371 + airflow/www/templates/airflow/list_dags.html | 303 + airflow/www/templates/airflow/login.html | 46 + airflow/www/templates/airflow/master.html | 18 + .../www/templates/airflow/model_create.html | 29 + airflow/www/templates/airflow/model_edit.html | 29 + airflow/www/templates/airflow/model_list.html | 26 + airflow/www/templates/airflow/modelchart.html | 48 + airflow/www/templates/airflow/noaccess.html | 24 + airflow/www/templates/airflow/nvd3.html | 192 + airflow/www/templates/airflow/para/para.html | 99 + airflow/www/templates/airflow/query.html | 86 + airflow/www/templates/airflow/task.html | 75 + .../www/templates/airflow/task_instance.html | 75 + airflow/www/templates/airflow/ti_code.html | 43 + airflow/www/templates/airflow/ti_log.html | 40 + airflow/www/templates/airflow/traceback.html | 33 + airflow/www/templates/airflow/tree.html | 381 + .../www/templates/airflow/variable_list.html | 33 + .../www/templates/airflow/variables/README.md | 37 + airflow/www/templates/airflow/version.html | 30 + airflow/www/templates/airflow/xcom.html | 37 + airflow/www/utils.py | 387 + airflow/www/validators.py | 54 + airflow/www/views.py | 2787 +++ dags/test_dag.py | 36 + dev/README.md | 85 + dev/airflow-jira | 158 + dev/airflow-pr | 1087 + dev/requirements.txt | 5 + dev/sign.sh | 29 + docs/Makefile | 177 + docs/api.rst | 43 + docs/build.sh | 17 + docs/cli.rst | 11 + docs/code.rst | 258 + docs/concepts.rst | 836 + docs/conf.py | 286 + docs/configuration.rst | 310 + docs/faq.rst | 147 + docs/img/adhoc.png | Bin 0 -> 182773 bytes docs/img/airflow.gif | Bin 0 -> 622963 bytes docs/img/apache.jpg | Bin 0 -> 43364 bytes docs/img/branch_bad.png | Bin 0 -> 8825 bytes docs/img/branch_good.png | Bin 0 -> 12035 bytes docs/img/chart.png | Bin 0 -> 169382 bytes docs/img/chart_form.png | Bin 0 -> 203224 bytes docs/img/code.png | Bin 0 -> 218215 bytes docs/img/connections.png | Bin 0 -> 93057 bytes docs/img/context.png | Bin 0 -> 238360 bytes docs/img/dags.png | Bin 0 -> 115068 bytes docs/img/duration.png | Bin 0 -> 254497 bytes docs/img/gantt.png | Bin 0 -> 213845 bytes docs/img/graph.png | Bin 0 -> 333294 bytes docs/img/incubator.jpg | Bin 0 -> 91227 bytes docs/img/latest_only_with_trigger.png | Bin 0 -> 40034 bytes docs/img/pin_large.png | Bin 0 -> 358276 bytes docs/img/subdag_after.png | Bin 0 -> 30245 bytes docs/img/subdag_before.png | Bin 0 -> 70382 bytes docs/img/subdag_zoom.png | Bin 0 -> 150185 bytes docs/img/tree.png | Bin 0 -> 163147 bytes docs/img/variable_hidden.png | Bin 0 -> 154299 bytes docs/index.rst | 89 + docs/installation.rst | 92 + docs/integration.rst | 540 + docs/license.rst | 211 + docs/plugins.rst | 144 + docs/profiling.rst | 39 + docs/project.rst | 49 + docs/scheduler.rst | 165 + docs/security.rst | 364 + docs/start.rst | 49 + docs/start_doc_server.sh | 14 + docs/tutorial.rst | 439 + docs/ui.rst | 102 + init.sh | 16 + licenses/LICENSE-ace.txt | 24 + licenses/LICENSE-bootstrap.txt | 48 + licenses/LICENSE-d3-tip.txt | 17 + licenses/LICENSE-d3js.txt | 30 + licenses/LICENSE-dagre-d3.txt | 21 + licenses/LICENSE-datatables.txt | 17 + licenses/LICENSE-flask-kerberos.txt | 23 + licenses/LICENSE-jquery.txt | 69 + licenses/LICENSE-jsclockplugin.txt | 7 + licenses/LICENSE-typeahead.txt | 13 + licenses/LICENSE-underscorejs.txt | 23 + licenses/LICENSE-webgl-2d.txt | 23 + migrations.sql | 24 + run_tox.sh | 16 + run_unit_tests.sh | 85 + scripts/ci/airflow_travis.cfg | 58 + scripts/ci/check-license.sh | 107 + scripts/ci/data/baby_names.csv | 54 + scripts/ci/data/mysql_schema.sql | 27 + scripts/ci/kadm5.acl | 13 + scripts/ci/kdc.conf | 25 + scripts/ci/krb5.conf | 30 + scripts/ci/ldap.sh | 31 + scripts/ci/ldif/example.com.ldif | 19 + scripts/ci/ldif/groups.example.com.ldif | 39 + scripts/ci/ldif/manager.example.com.ldif | 16 + scripts/ci/ldif/users.example.com.ldif | 43 + scripts/ci/load_data.sh | 28 + scripts/ci/load_fixtures.sh | 29 + scripts/ci/minikdc.properties | 23 + scripts/ci/requirements.txt | 94 + scripts/ci/run_tests.sh | 42 + scripts/ci/setup_env.sh | 161 + scripts/ci/setup_kdc.sh | 51 + scripts/ci/slapd.conf | 60 + scripts/perf/dags/perf_dag_1.py | 44 + scripts/perf/dags/perf_dag_2.py | 45 + scripts/perf/scheduler_ops_metrics.py | 187 + scripts/systemd/README | 11 + scripts/systemd/airflow | 19 + scripts/systemd/airflow-flower.service | 29 + scripts/systemd/airflow-kerberos.service | 29 + scripts/systemd/airflow-scheduler.service | 29 + scripts/systemd/airflow-webserver.service | 30 + scripts/systemd/airflow-worker.service | 29 + scripts/systemd/airflow.conf | 14 + scripts/upstart/README | 33 + scripts/upstart/airflow-flower.conf | 29 + scripts/upstart/airflow-scheduler.conf | 33 + scripts/upstart/airflow-webserver.conf | 29 + scripts/upstart/airflow-worker.conf | 29 + setup.cfg | 31 + setup.py | 310 + tests/__init__.py | 27 + tests/api/__init__.py | 13 + tests/api/client/__init__.py | 13 + tests/api/client/test_local_client.py | 144 + tests/api/common/__init__.py | 13 + tests/api/common/experimental/__init__.py | 13 + tests/api/common/experimental/mark_tasks.py | 396 + tests/api/common/experimental/test_pool.py | 132 + tests/configuration.py | 54 + tests/contrib/__init__.py | 17 + tests/contrib/hooks/__init__.py | 13 + tests/contrib/hooks/gcp_pubsub_hook.py | 102 + tests/contrib/hooks/test_aws_dynamodb_hook.py | 76 + tests/contrib/hooks/test_aws_hook.py | 82 + tests/contrib/hooks/test_aws_lambda_hook.py | 82 + tests/contrib/hooks/test_bigquery_hook.py | 219 + tests/contrib/hooks/test_databricks_hook.py | 260 + tests/contrib/hooks/test_emr_hook.py | 53 + tests/contrib/hooks/test_ftp_hook.py | 101 + tests/contrib/hooks/test_gcp_dataflow_hook.py | 56 + tests/contrib/hooks/test_gcp_dataproc_hook.py | 48 + tests/contrib/hooks/test_gcp_mlengine_hook.py | 413 + tests/contrib/hooks/test_jdbc_hook.py | 51 + tests/contrib/hooks/test_jira_hook.py | 51 + tests/contrib/hooks/test_redis_hook.py | 46 + tests/contrib/hooks/test_spark_sql_hook.py | 112 + tests/contrib/hooks/test_spark_submit_hook.py | 336 + tests/contrib/hooks/test_sqoop_hook.py | 276 + tests/contrib/hooks/test_ssh_hook.py | 70 + tests/contrib/hooks/test_wasb_hook.py | 123 + tests/contrib/hooks/test_zendesk_hook.py | 89 + tests/contrib/operators/__init__.py | 15 + tests/contrib/operators/pubsub_operator.py | 77 + .../operators/test_databricks_operator.py | 238 + .../operators/test_dataflow_operator.py | 84 + .../operators/test_dataproc_operator.py | 292 + .../contrib/operators/test_druid_operator.py | 47 + tests/contrib/operators/test_ecs_operator.py | 214 + .../operators/test_emr_add_steps_operator.py | 53 + .../test_emr_create_job_flow_operator.py | 53 + .../test_emr_terminate_job_flow_operator.py | 52 + tests/contrib/operators/test_file_to_wasb.py | 88 + tests/contrib/operators/test_fs_operator.py | 64 + .../operators/test_hipchat_operator.py | 74 + .../test_hive_to_dynamodb_operator.py | 144 + .../operators/test_jira_operator_test.py | 101 + .../operators/test_mlengine_operator.py | 373 + .../operators/test_mlengine_operator_utils.py | 183 + .../contrib/operators/test_qubole_operator.py | 94 + tests/contrib/operators/test_sftp_operator.py | 238 + .../operators/test_spark_sql_operator.py | 73 + .../operators/test_spark_submit_operator.py | 149 + .../contrib/operators/test_sqoop_operator.py | 104 + tests/contrib/operators/test_ssh_operator.py | 111 + tests/contrib/sensors/__init__.py | 13 + tests/contrib/sensors/test_datadog_sensor.py | 106 + tests/contrib/sensors/test_emr_base_sensor.py | 126 + .../sensors/test_emr_job_flow_sensor.py | 123 + tests/contrib/sensors/test_emr_step_sensor.py | 119 + tests/contrib/sensors/test_ftp_sensor.py | 66 + tests/contrib/sensors/test_hdfs_sensors.py | 251 + .../contrib/sensors/test_jira_sensor_test.py | 85 + tests/contrib/sensors/test_redis_sensor.py | 64 + tests/contrib/sensors/test_wasb_sensor.py | 138 + tests/core.py | 2534 +++ tests/dags/README.md | 10 + tests/dags/no_dags.py | 14 + tests/dags/test_backfill_pooled_tasks.py | 32 + tests/dags/test_cli_triggered_dags.py | 48 + tests/dags/test_default_impersonation.py | 44 + tests/dags/test_double_trigger.py | 29 + tests/dags/test_example_bash_operator.py | 55 + tests/dags/test_impersonation.py | 45 + tests/dags/test_issue_1225.py | 144 + tests/dags/test_latest_runs.py | 27 + tests/dags/test_mark_success.py | 30 + tests/dags/test_no_impersonation.py | 43 + tests/dags/test_retry_handling_job.py | 36 + tests/dags/test_scheduler_dags.py | 29 + tests/dags/test_zip.zip | Bin 0 -> 1676 bytes tests/dags_with_system_exit/a_system_exit.py | 29 + .../b_test_scheduler_dags.py | 29 + tests/dags_with_system_exit/c_system_exit.py | 29 + tests/executors/__init__.py | 15 + tests/executors/dask_executor.py | 114 + tests/executors/test_celery_executor.py | 55 + tests/executors/test_executor.py | 56 + tests/executors/test_local_executor.py | 74 + tests/hooks/__init__.py | 13 + tests/hooks/test_dbapi_hook.py | 75 + tests/hooks/test_druid_hook.py | 116 + tests/hooks/test_postgres_hook.py | 54 + tests/impersonation.py | 111 + tests/jobs.py | 2971 +++ tests/macros/__init__.py | 14 + tests/macros/test_hive.py | 41 + tests/models.py | 1542 ++ tests/operators/__init__.py | 23 + tests/operators/docker_operator.py | 163 + tests/operators/hive_operator.py | 354 + tests/operators/latest_only_operator.py | 175 + tests/operators/operators.py | 301 + tests/operators/python_operator.py | 264 + tests/operators/s3_to_hive_operator.py | 247 + tests/operators/sensors.py | 363 + tests/operators/subdag_operator.py | 139 + tests/operators/test_hive_to_druid.py | 132 + tests/operators/test_virtualenv_operator.py | 188 + tests/plugins/test_plugin.py | 72 + tests/plugins_manager.py | 78 + tests/security/__init__.py | 15 + tests/security/kerberos.py | 39 + tests/test_logging_config.py | 216 + tests/test_utils/README.md | 1 + tests/test_utils/__init__.py | 13 + tests/test_utils/fake_datetime.py | 24 + tests/test_utils/reset_warning_registry.py | 82 + tests/testconfig/conf/core-with-trash.xml | 35 + tests/testconfig/conf/emr-core-site.xml | 25 + tests/testconfig/conf/ha-core-site.xml | 25 + tests/testconfig/conf/ha-noport-hdfs-site.xml | 51 + .../conf/ha-noport-trash-hdfs-site.xml | 60 + tests/testconfig/conf/ha-port-hdfs-site.xml | 51 + .../testconfig/conf/non-ha-port-core-site.xml | 25 + tests/ti_deps/__init__.py | 13 + tests/ti_deps/contexts/__init__.py | 13 + tests/ti_deps/deps/__init__.py | 13 + tests/ti_deps/deps/fake_models.py | 57 + .../deps/test_dag_ti_slots_available_dep.py | 42 + tests/ti_deps/deps/test_dag_unpaused_dep.py | 42 + tests/ti_deps/deps/test_dagrun_exists_dep.py | 40 + .../deps/test_not_in_retry_period_dep.py | 59 + tests/ti_deps/deps/test_not_running_dep.py | 37 + tests/ti_deps/deps/test_not_skipped_dep.py | 36 + tests/ti_deps/deps/test_prev_dagrun_dep.py | 123 + .../deps/test_runnable_exec_date_dep.py | 76 + tests/ti_deps/deps/test_task_concurrency.py | 51 + tests/ti_deps/deps/test_trigger_rule_dep.py | 252 + tests/ti_deps/deps/test_valid_state_dep.py | 46 + tests/utils.py | 94 + tests/utils/__init__.py | 13 + tests/utils/log/test_logging.py | 113 + tests/utils/test_compression.py | 100 + tests/utils/test_dates.py | 42 + tests/utils/test_email.py | 51 + tests/utils/test_helpers.py | 89 + tests/utils/test_json.py | 82 + tests/utils/test_log_handlers.py | 96 + tests/utils/test_logging_mixin.py | 50 + tests/utils/test_module_loading.py | 30 + tests/utils/test_operator_helpers.py | 60 + tests/www/__init__.py | 14 + tests/www/api/__init__.py | 14 + tests/www/api/experimental/__init__.py | 14 + tests/www/api/experimental/test_endpoints.py | 301 + .../experimental/test_kerberos_endpoints.py | 97 + .../2017-09-01T00.00.00/1.log | 1 + tests/www/test_utils.py | 109 + tests/www/test_validators.py | 92 + tests/www/test_views.py | 382 + tox.ini | 71 + 676 files changed, 132549 insertions(+) create mode 100644 .codecov.yml create mode 100644 .coveragerc create mode 100644 .editorconfig create mode 100644 .github/PULL_REQUEST_TEMPLATE.md create mode 100644 .gitignore create mode 100644 .rat-excludes create mode 100644 .readthedocs.yml create mode 100644 .travis.yml create mode 100644 CHANGELOG.txt create mode 100644 CONTRIBUTING.md create mode 100644 DISCLAIMER create mode 100644 INSTALL create mode 100644 LICENSE create mode 100644 MANIFEST.in create mode 100644 NOTICE create mode 100644 README.md create mode 100644 TODO.md create mode 100644 UPDATING.md create mode 100644 airflow/__init__.py create mode 100644 airflow/alembic.ini create mode 100644 airflow/api/__init__.py create mode 100644 airflow/api/auth/__init__.py create mode 100644 airflow/api/auth/backend/__init__.py create mode 100644 airflow/api/auth/backend/default.py create mode 100644 airflow/api/auth/backend/kerberos_auth.py create mode 100644 airflow/api/client/__init__.py create mode 100644 airflow/api/client/api_client.py create mode 100644 airflow/api/client/json_client.py create mode 100644 airflow/api/client/local_client.py create mode 100644 airflow/api/common/__init__.py create mode 100644 airflow/api/common/experimental/__init__.py create mode 100644 airflow/api/common/experimental/get_task.py create mode 100644 airflow/api/common/experimental/get_task_instance.py create mode 100644 airflow/api/common/experimental/mark_tasks.py create mode 100644 airflow/api/common/experimental/pool.py create mode 100644 airflow/api/common/experimental/trigger_dag.py create mode 100644 airflow/bin/__init__.py create mode 100755 airflow/bin/airflow create mode 100755 airflow/bin/airflow_scheduler_autorestart.sh create mode 100755 airflow/bin/cli.py create mode 100644 airflow/config_templates/__init__.py create mode 100644 airflow/config_templates/airflow_local_settings.py create mode 100644 airflow/config_templates/default_airflow.cfg create mode 100644 airflow/config_templates/default_celery.py create mode 100644 airflow/config_templates/default_test.cfg create mode 100644 airflow/configuration.py create mode 100644 airflow/contrib/__init__.py create mode 100644 airflow/contrib/auth/__init__.py create mode 100644 airflow/contrib/auth/backends/__init__.py create mode 100644 airflow/contrib/auth/backends/github_enterprise_auth.py create mode 100644 airflow/contrib/auth/backends/google_auth.py create mode 100644 airflow/contrib/auth/backends/kerberos_auth.py create mode 100644 airflow/contrib/auth/backends/ldap_auth.py create mode 100644 airflow/contrib/auth/backends/password_auth.py create mode 100644 airflow/contrib/example_dags/example_databricks_operator.py create mode 100644 airflow/contrib/example_dags/example_emr_job_flow_automatic_steps.py create mode 100644 airflow/contrib/example_dags/example_emr_job_flow_manual_steps.py create mode 100644 airflow/contrib/example_dags/example_qubole_operator.py create mode 100644 airflow/contrib/example_dags/example_twitter_README.md create mode 100644 airflow/contrib/example_dags/example_twitter_dag.py create mode 100644 airflow/contrib/executors/__init__.py create mode 100644 airflow/contrib/executors/mesos_executor.py create mode 100644 airflow/contrib/hooks/__init__.py create mode 100644 airflow/contrib/hooks/aws_dynamodb_hook.py create mode 100644 airflow/contrib/hooks/aws_hook.py create mode 100644 airflow/contrib/hooks/aws_lambda_hook.py create mode 100644 airflow/contrib/hooks/bigquery_hook.py create mode 100644 airflow/contrib/hooks/cloudant_hook.py create mode 100644 airflow/contrib/hooks/databricks_hook.py create mode 100644 airflow/contrib/hooks/datadog_hook.py create mode 100644 airflow/contrib/hooks/datastore_hook.py create mode 100644 airflow/contrib/hooks/emr_hook.py create mode 100644 airflow/contrib/hooks/fs_hook.py create mode 100644 airflow/contrib/hooks/ftp_hook.py create mode 100644 airflow/contrib/hooks/gcp_api_base_hook.py create mode 100644 airflow/contrib/hooks/gcp_dataflow_hook.py create mode 100644 airflow/contrib/hooks/gcp_dataproc_hook.py create mode 100644 airflow/contrib/hooks/gcp_mlengine_hook.py create mode 100644 airflow/contrib/hooks/gcp_pubsub_hook.py create mode 100644 airflow/contrib/hooks/gcs_hook.py create mode 100644 airflow/contrib/hooks/jira_hook.py create mode 100755 airflow/contrib/hooks/qubole_hook.py create mode 100644 airflow/contrib/hooks/redis_hook.py create mode 100644 airflow/contrib/hooks/salesforce_hook.py create mode 100644 airflow/contrib/hooks/spark_sql_hook.py create mode 100644 airflow/contrib/hooks/spark_submit_hook.py create mode 100644 airflow/contrib/hooks/sqoop_hook.py create mode 100755 airflow/contrib/hooks/ssh_hook.py create mode 100644 airflow/contrib/hooks/vertica_hook.py create mode 100644 airflow/contrib/hooks/wasb_hook.py create mode 100644 airflow/contrib/operators/__init__.py create mode 100644 airflow/contrib/operators/bigquery_check_operator.py create mode 100644 airflow/contrib/operators/bigquery_operator.py create mode 100644 airflow/contrib/operators/bigquery_table_delete_operator.py create mode 100644 airflow/contrib/operators/bigquery_to_bigquery.py create mode 100644 airflow/contrib/operators/bigquery_to_gcs.py create mode 100644 airflow/contrib/operators/databricks_operator.py create mode 100644 airflow/contrib/operators/dataflow_operator.py create mode 100644 airflow/contrib/operators/dataproc_operator.py create mode 100644 airflow/contrib/operators/datastore_export_operator.py create mode 100644 airflow/contrib/operators/datastore_import_operator.py create mode 100644 airflow/contrib/operators/druid_operator.py create mode 100644 airflow/contrib/operators/ecs_operator.py create mode 100644 airflow/contrib/operators/emr_add_steps_operator.py create mode 100644 airflow/contrib/operators/emr_create_job_flow_operator.py create mode 100644 airflow/contrib/operators/emr_terminate_job_flow_operator.py create mode 100644 airflow/contrib/operators/file_to_gcs.py create mode 100644 airflow/contrib/operators/file_to_wasb.py create mode 100644 airflow/contrib/operators/fs_operator.py create mode 100644 airflow/contrib/operators/gcs_download_operator.py create mode 100644 airflow/contrib/operators/gcs_to_bq.py create mode 100644 airflow/contrib/operators/hipchat_operator.py create mode 100644 airflow/contrib/operators/hive_to_dynamodb.py create mode 100644 airflow/contrib/operators/jira_operator.py create mode 100644 airflow/contrib/operators/mlengine_operator.py create mode 100644 airflow/contrib/operators/mlengine_operator_utils.py create mode 100644 airflow/contrib/operators/mlengine_prediction_summary.py create mode 100644 airflow/contrib/operators/mysql_to_gcs.py create mode 100644 airflow/contrib/operators/pubsub_operator.py create mode 100755 airflow/contrib/operators/qubole_operator.py create mode 100644 airflow/contrib/operators/sftp_operator.py create mode 100644 airflow/contrib/operators/spark_sql_operator.py create mode 100644 airflow/contrib/operators/spark_submit_operator.py create mode 100644 airflow/contrib/operators/sqoop_operator.py create mode 100644 airflow/contrib/operators/ssh_operator.py create mode 100644 airflow/contrib/operators/vertica_operator.py create mode 100644 airflow/contrib/operators/vertica_to_hive.py create mode 100644 airflow/contrib/plugins/metastore_browser/main.py create mode 100644 airflow/contrib/plugins/metastore_browser/templates/metastore_browser/base.html create mode 100644 airflow/contrib/plugins/metastore_browser/templates/metastore_browser/db.html create mode 100644 airflow/contrib/plugins/metastore_browser/templates/metastore_browser/dbs.html create mode 100644 airflow/contrib/plugins/metastore_browser/templates/metastore_browser/table.html create mode 100644 airflow/contrib/sensors/__init__.py create mode 100644 airflow/contrib/sensors/bigquery_sensor.py create mode 100644 airflow/contrib/sensors/datadog_sensor.py create mode 100644 airflow/contrib/sensors/emr_base_sensor.py create mode 100644 airflow/contrib/sensors/emr_job_flow_sensor.py create mode 100644 airflow/contrib/sensors/emr_step_sensor.py create mode 100644 airflow/contrib/sensors/ftp_sensor.py create mode 100644 airflow/contrib/sensors/gcs_sensor.py create mode 100644 airflow/contrib/sensors/hdfs_sensors.py create mode 100644 airflow/contrib/sensors/jira_sensor.py create mode 100644 airflow/contrib/sensors/redis_key_sensor.py create mode 100644 airflow/contrib/sensors/wasb_sensor.py create mode 100644 airflow/contrib/task_runner/__init__.py create mode 100644 airflow/contrib/task_runner/cgroup_task_runner.py create mode 100644 airflow/dag/__init__.py create mode 100644 airflow/dag/base_dag.py create mode 100644 airflow/default_login.py create mode 100644 airflow/example_dags/__init__.py create mode 100644 airflow/example_dags/docker_copy_data.py create mode 100644 airflow/example_dags/entrypoint.sh create mode 100644 airflow/example_dags/example_bash_operator.py create mode 100644 airflow/example_dags/example_branch_operator.py create mode 100644 airflow/example_dags/example_branch_python_dop_operator_3.py create mode 100644 airflow/example_dags/example_docker_operator.py create mode 100644 airflow/example_dags/example_http_operator.py create mode 100644 airflow/example_dags/example_latest_only.py create mode 100644 airflow/example_dags/example_latest_only_with_trigger.py create mode 100644 airflow/example_dags/example_passing_params_via_test_command.py create mode 100644 airflow/example_dags/example_python_operator.py create mode 100644 airflow/example_dags/example_short_circuit_operator.py create mode 100644 airflow/example_dags/example_skip_dag.py create mode 100644 airflow/example_dags/example_subdag_operator.py create mode 100644 airflow/example_dags/example_trigger_controller_dag.py create mode 100644 airflow/example_dags/example_trigger_target_dag.py create mode 100644 airflow/example_dags/example_xcom.py create mode 100644 airflow/example_dags/subdags/__init__.py create mode 100644 airflow/example_dags/subdags/subdag.py create mode 100644 airflow/example_dags/test_utils.py create mode 100644 airflow/example_dags/tutorial.py create mode 100644 airflow/exceptions.py create mode 100644 airflow/executors/__init__.py create mode 100644 airflow/executors/base_executor.py create mode 100644 airflow/executors/celery_executor.py create mode 100644 airflow/executors/dask_executor.py create mode 100644 airflow/executors/local_executor.py create mode 100644 airflow/executors/sequential_executor.py create mode 100644 airflow/hooks/S3_hook.py create mode 100644 airflow/hooks/__init__.py create mode 100644 airflow/hooks/base_hook.py create mode 100644 airflow/hooks/dbapi_hook.py create mode 100644 airflow/hooks/druid_hook.py create mode 100644 airflow/hooks/hdfs_hook.py create mode 100644 airflow/hooks/hive_hooks.py create mode 100644 airflow/hooks/http_hook.py create mode 100644 airflow/hooks/jdbc_hook.py create mode 100644 airflow/hooks/mssql_hook.py create mode 100644 airflow/hooks/mysql_hook.py create mode 100644 airflow/hooks/oracle_hook.py create mode 100644 airflow/hooks/pig_hook.py create mode 100644 airflow/hooks/postgres_hook.py create mode 100644 airflow/hooks/presto_hook.py create mode 100644 airflow/hooks/samba_hook.py create mode 100644 airflow/hooks/sqlite_hook.py create mode 100644 airflow/hooks/webhdfs_hook.py create mode 100644 airflow/hooks/zendesk_hook.py create mode 100644 airflow/jobs.py create mode 100644 airflow/logging_config.py create mode 100644 airflow/macros/__init__.py create mode 100644 airflow/macros/hive.py create mode 100644 airflow/migrations/__init__.py create mode 100644 airflow/migrations/env.py create mode 100644 airflow/migrations/script.py.mako create mode 100644 airflow/migrations/versions/127d2bf2dfa7_add_dag_id_state_index_on_dag_run_table.py create mode 100644 airflow/migrations/versions/13eb55f81627_for_compatibility.py create mode 100644 airflow/migrations/versions/1507a7289a2f_create_is_encrypted.py create mode 100644 airflow/migrations/versions/1968acfc09e3_add_is_encrypted_column_to_variable_.py create mode 100644 airflow/migrations/versions/1b38cef5b76e_add_dagrun.py create mode 100644 airflow/migrations/versions/211e584da130_add_ti_state_index.py create mode 100644 airflow/migrations/versions/2e541a1dcfed_task_duration.py create mode 100644 airflow/migrations/versions/2e82aab8ef20_rename_user_table.py create mode 100644 airflow/migrations/versions/338e90f54d61_more_logging_into_task_isntance.py create mode 100644 airflow/migrations/versions/40e67319e3a9_dagrun_config.py create mode 100644 airflow/migrations/versions/4446e08588_dagrun_start_end.py create mode 100644 airflow/migrations/versions/4addfa1236f1_add_fractional_seconds_to_mysql_tables.py create mode 100644 airflow/migrations/versions/502898887f84_adding_extra_to_log.py create mode 100644 airflow/migrations/versions/52d714495f0_job_id_indices.py create mode 100644 airflow/migrations/versions/561833c1c74b_add_password_column_to_user.py create mode 100644 airflow/migrations/versions/5e7d17757c7a_add_pid_field_to_taskinstance.py create mode 100644 airflow/migrations/versions/64de9cddf6c9_add_task_fails_journal_table.py create mode 100644 airflow/migrations/versions/8504051e801b_xcom_dag_task_indices.py create mode 100644 airflow/migrations/versions/947454bf1dff_add_ti_job_id_index.py create mode 100644 airflow/migrations/versions/__init__.py create mode 100644 airflow/migrations/versions/bba5a7cfc896_add_a_column_to_track_the_encryption_.py create mode 100644 airflow/migrations/versions/bbc73705a13e_add_notification_sent_column_to_sla_miss.py create mode 100644 airflow/migrations/versions/bdaa763e6c56_make_xcom_value_column_a_large_binary.py create mode 100644 airflow/migrations/versions/cc1e65623dc7_add_max_tries_column_to_task_instance.py create mode 100644 airflow/migrations/versions/d2ae31099d61_increase_text_size_for_mysql.py create mode 100644 airflow/migrations/versions/e3a246e0dc1_current_schema.py create mode 100644 airflow/migrations/versions/f2ca10b85618_add_dag_stats_table.py create mode 100644 airflow/minihivecluster.py create mode 100755 airflow/models.py create mode 100644 airflow/operators/__init__.py create mode 100644 airflow/operators/bash_operator.py create mode 100644 airflow/operators/check_operator.py create mode 100644 airflow/operators/dagrun_operator.py create mode 100644 airflow/operators/docker_operator.py create mode 100644 airflow/operators/dummy_operator.py create mode 100644 airflow/operators/email_operator.py create mode 100644 airflow/operators/generic_transfer.py create mode 100644 airflow/operators/hive_operator.py create mode 100644 airflow/operators/hive_stats_operator.py create mode 100644 airflow/operators/hive_to_druid.py create mode 100644 airflow/operators/hive_to_mysql.py create mode 100644 airflow/operators/hive_to_samba_operator.py create mode 100644 airflow/operators/http_operator.py create mode 100644 airflow/operators/jdbc_operator.py create mode 100644 airflow/operators/latest_only_operator.py create mode 100644 airflow/operators/mssql_operator.py create mode 100644 airflow/operators/mssql_to_hive.py create mode 100644 airflow/operators/mysql_operator.py create mode 100644 airflow/operators/mysql_to_hive.py create mode 100644 airflow/operators/oracle_operator.py create mode 100644 airflow/operators/pig_operator.py create mode 100644 airflow/operators/postgres_operator.py create mode 100644 airflow/operators/presto_check_operator.py create mode 100644 airflow/operators/presto_to_mysql.py create mode 100644 airflow/operators/python_operator.py create mode 100644 airflow/operators/redshift_to_s3_operator.py create mode 100644 airflow/operators/s3_file_transform_operator.py create mode 100644 airflow/operators/s3_to_hive_operator.py create mode 100644 airflow/operators/sensors.py create mode 100644 airflow/operators/slack_operator.py create mode 100644 airflow/operators/sqlite_operator.py create mode 100644 airflow/operators/subdag_operator.py create mode 100644 airflow/plugins_manager.py create mode 100644 airflow/security/__init__.py create mode 100644 airflow/security/kerberos.py create mode 100644 airflow/security/utils.py create mode 100644 airflow/settings.py create mode 100644 airflow/task_runner/__init__.py create mode 100644 airflow/task_runner/base_task_runner.py create mode 100644 airflow/task_runner/bash_task_runner.py create mode 100644 airflow/ti_deps/__init__.py create mode 100644 airflow/ti_deps/dep_context.py create mode 100644 airflow/ti_deps/deps/__init__.py create mode 100644 airflow/ti_deps/deps/base_ti_dep.py create mode 100644 airflow/ti_deps/deps/dag_ti_slots_available_dep.py create mode 100644 airflow/ti_deps/deps/dag_unpaused_dep.py create mode 100644 airflow/ti_deps/deps/dagrun_exists_dep.py create mode 100644 airflow/ti_deps/deps/exec_date_after_start_date_dep.py create mode 100644 airflow/ti_deps/deps/not_in_retry_period_dep.py create mode 100644 airflow/ti_deps/deps/not_running_dep.py create mode 100644 airflow/ti_deps/deps/not_skipped_dep.py create mode 100644 airflow/ti_deps/deps/prev_dagrun_dep.py create mode 100644 airflow/ti_deps/deps/runnable_exec_date_dep.py create mode 100644 airflow/ti_deps/deps/task_concurrency_dep.py create mode 100644 airflow/ti_deps/deps/trigger_rule_dep.py create mode 100644 airflow/ti_deps/deps/valid_state_dep.py create mode 100644 airflow/utils/__init__.py create mode 100644 airflow/utils/asciiart.py create mode 100644 airflow/utils/compression.py create mode 100644 airflow/utils/dag_processing.py create mode 100644 airflow/utils/dates.py create mode 100644 airflow/utils/db.py create mode 100644 airflow/utils/decorators.py create mode 100644 airflow/utils/email.py create mode 100644 airflow/utils/file.py create mode 100644 airflow/utils/helpers.py create mode 100644 airflow/utils/json.py create mode 100644 airflow/utils/log/__init__.py create mode 100644 airflow/utils/log/file_task_handler.py create mode 100644 airflow/utils/log/gcs_task_handler.py create mode 100644 airflow/utils/log/logging_mixin.py create mode 100644 airflow/utils/log/s3_task_handler.py create mode 100644 airflow/utils/module_loading.py create mode 100644 airflow/utils/operator_helpers.py create mode 100644 airflow/utils/operator_resources.py create mode 100644 airflow/utils/state.py create mode 100644 airflow/utils/tests.py create mode 100644 airflow/utils/timeout.py create mode 100644 airflow/utils/trigger_rule.py create mode 100644 airflow/version.py create mode 100644 airflow/www/__init__.py create mode 100644 airflow/www/api/__init__.py create mode 100644 airflow/www/api/experimental/__init__.py create mode 100644 airflow/www/api/experimental/endpoints.py create mode 100644 airflow/www/app.py create mode 100644 airflow/www/blueprints.py create mode 100644 airflow/www/forms.py create mode 100644 airflow/www/gunicorn_config.py create mode 100644 airflow/www/static/ace.js create mode 100644 airflow/www/static/airflow.gif create mode 100644 airflow/www/static/bootstrap-theme.css create mode 100644 airflow/www/static/bootstrap-toggle.min.css create mode 100644 airflow/www/static/bootstrap-toggle.min.js create mode 100644 airflow/www/static/bootstrap3-typeahead.min.js create mode 100644 airflow/www/static/connection_form.js create mode 100644 airflow/www/static/d3.tip.v0.6.3.js create mode 100644 airflow/www/static/d3.v3.min.js create mode 100644 airflow/www/static/dagre-d3.js create mode 100644 airflow/www/static/dagre-d3.min.js create mode 100644 airflow/www/static/dagre.css create mode 100644 airflow/www/static/dataTables.bootstrap.css create mode 120000 airflow/www/static/docs create mode 100644 airflow/www/static/favicon.ico create mode 100644 airflow/www/static/gantt-chart-d3v2.js create mode 100644 airflow/www/static/gantt.css create mode 100644 airflow/www/static/graph.css create mode 100644 airflow/www/static/jqClock.min.js create mode 100644 airflow/www/static/jquery.dataTables.css create mode 100644 airflow/www/static/jquery.dataTables.min.js create mode 100644 airflow/www/static/loading.gif create mode 100644 airflow/www/static/main.css create mode 100644 airflow/www/static/mode-sql.js create mode 100644 airflow/www/static/nv.d3.css create mode 100644 airflow/www/static/nv.d3.js create mode 100644 airflow/www/static/para/parallel.css create mode 100644 airflow/www/static/para/parallel.js create mode 100644 airflow/www/static/para/webgl-2d.js create mode 100644 airflow/www/static/pin.svg create mode 100644 airflow/www/static/pin_100.jpg create mode 100644 airflow/www/static/pin_100.png create mode 100644 airflow/www/static/pin_25.png create mode 100644 airflow/www/static/pin_30.png create mode 100644 airflow/www/static/pin_35.png create mode 100644 airflow/www/static/pin_40.png create mode 100644 airflow/www/static/pin_large.jpg create mode 100644 airflow/www/static/pin_large.png create mode 100644 airflow/www/static/screenshots/gantt.png create mode 100644 airflow/www/static/screenshots/graph.png create mode 100644 airflow/www/static/screenshots/tree.png create mode 100644 airflow/www/static/sort_asc.png create mode 100644 airflow/www/static/sort_both.png create mode 100644 airflow/www/static/sort_desc.png create mode 100644 airflow/www/static/theme-crimson_editor.js create mode 100644 airflow/www/static/tree.css create mode 100644 airflow/www/static/underscore.js create mode 100644 airflow/www/templates/admin/master.html create mode 100644 airflow/www/templates/airflow/chart.html create mode 100644 airflow/www/templates/airflow/chart/create.html create mode 100644 airflow/www/templates/airflow/chart/edit.html create mode 100644 airflow/www/templates/airflow/circles.html create mode 100644 airflow/www/templates/airflow/code.html create mode 100644 airflow/www/templates/airflow/config.html create mode 100644 airflow/www/templates/airflow/confirm.html create mode 100644 airflow/www/templates/airflow/conn_create.html create mode 100644 airflow/www/templates/airflow/conn_edit.html create mode 100644 airflow/www/templates/airflow/conn_list.html create mode 100644 airflow/www/templates/airflow/dag.html create mode 100644 airflow/www/templates/airflow/dag_code.html create mode 100644 airflow/www/templates/airflow/dag_details.html create mode 100644 airflow/www/templates/airflow/dags.html create mode 100644 airflow/www/templates/airflow/duration_chart.html create mode 100644 airflow/www/templates/airflow/gantt.html create mode 100644 airflow/www/templates/airflow/graph.html create mode 100644 airflow/www/templates/airflow/list_dags.html create mode 100644 airflow/www/templates/airflow/login.html create mode 100644 airflow/www/templates/airflow/master.html create mode 100644 airflow/www/templates/airflow/model_create.html create mode 100644 airflow/www/templates/airflow/model_edit.html create mode 100644 airflow/www/templates/airflow/model_list.html create mode 100644 airflow/www/templates/airflow/modelchart.html create mode 100644 airflow/www/templates/airflow/noaccess.html create mode 100644 airflow/www/templates/airflow/nvd3.html create mode 100644 airflow/www/templates/airflow/para/para.html create mode 100644 airflow/www/templates/airflow/query.html create mode 100644 airflow/www/templates/airflow/task.html create mode 100644 airflow/www/templates/airflow/task_instance.html create mode 100644 airflow/www/templates/airflow/ti_code.html create mode 100644 airflow/www/templates/airflow/ti_log.html create mode 100644 airflow/www/templates/airflow/traceback.html create mode 100644 airflow/www/templates/airflow/tree.html create mode 100644 airflow/www/templates/airflow/variable_list.html create mode 100644 airflow/www/templates/airflow/variables/README.md create mode 100644 airflow/www/templates/airflow/version.html create mode 100644 airflow/www/templates/airflow/xcom.html create mode 100644 airflow/www/utils.py create mode 100644 airflow/www/validators.py create mode 100644 airflow/www/views.py create mode 100644 dags/test_dag.py create mode 100755 dev/README.md create mode 100755 dev/airflow-jira create mode 100755 dev/airflow-pr create mode 100644 dev/requirements.txt create mode 100755 dev/sign.sh create mode 100644 docs/Makefile create mode 100644 docs/api.rst create mode 100755 docs/build.sh create mode 100644 docs/cli.rst create mode 100644 docs/code.rst create mode 100644 docs/concepts.rst create mode 100644 docs/conf.py create mode 100644 docs/configuration.rst create mode 100644 docs/faq.rst create mode 100644 docs/img/adhoc.png create mode 100644 docs/img/airflow.gif create mode 100644 docs/img/apache.jpg create mode 100644 docs/img/branch_bad.png create mode 100644 docs/img/branch_good.png create mode 100644 docs/img/chart.png create mode 100644 docs/img/chart_form.png create mode 100644 docs/img/code.png create mode 100644 docs/img/connections.png create mode 100644 docs/img/context.png create mode 100644 docs/img/dags.png create mode 100644 docs/img/duration.png create mode 100644 docs/img/gantt.png create mode 100644 docs/img/graph.png create mode 100644 docs/img/incubator.jpg create mode 100644 docs/img/latest_only_with_trigger.png create mode 100644 docs/img/pin_large.png create mode 100644 docs/img/subdag_after.png create mode 100644 docs/img/subdag_before.png create mode 100644 docs/img/subdag_zoom.png create mode 100644 docs/img/tree.png create mode 100644 docs/img/variable_hidden.png create mode 100644 docs/index.rst create mode 100644 docs/installation.rst create mode 100644 docs/integration.rst create mode 100644 docs/license.rst create mode 100644 docs/plugins.rst create mode 100644 docs/profiling.rst create mode 100644 docs/project.rst create mode 100644 docs/scheduler.rst create mode 100644 docs/security.rst create mode 100644 docs/start.rst create mode 100755 docs/start_doc_server.sh create mode 100644 docs/tutorial.rst create mode 100644 docs/ui.rst create mode 100644 init.sh create mode 100644 licenses/LICENSE-ace.txt create mode 100644 licenses/LICENSE-bootstrap.txt create mode 100644 licenses/LICENSE-d3-tip.txt create mode 100644 licenses/LICENSE-d3js.txt create mode 100644 licenses/LICENSE-dagre-d3.txt create mode 100644 licenses/LICENSE-datatables.txt create mode 100644 licenses/LICENSE-flask-kerberos.txt create mode 100644 licenses/LICENSE-jquery.txt create mode 100644 licenses/LICENSE-jsclockplugin.txt create mode 100644 licenses/LICENSE-typeahead.txt create mode 100644 licenses/LICENSE-underscorejs.txt create mode 100644 licenses/LICENSE-webgl-2d.txt create mode 100644 migrations.sql create mode 100755 run_tox.sh create mode 100755 run_unit_tests.sh create mode 100644 scripts/ci/airflow_travis.cfg create mode 100755 scripts/ci/check-license.sh create mode 100644 scripts/ci/data/baby_names.csv create mode 100644 scripts/ci/data/mysql_schema.sql create mode 100644 scripts/ci/kadm5.acl create mode 100644 scripts/ci/kdc.conf create mode 100644 scripts/ci/krb5.conf create mode 100755 scripts/ci/ldap.sh create mode 100644 scripts/ci/ldif/example.com.ldif create mode 100644 scripts/ci/ldif/groups.example.com.ldif create mode 100644 scripts/ci/ldif/manager.example.com.ldif create mode 100644 scripts/ci/ldif/users.example.com.ldif create mode 100755 scripts/ci/load_data.sh create mode 100755 scripts/ci/load_fixtures.sh create mode 100644 scripts/ci/minikdc.properties create mode 100644 scripts/ci/requirements.txt create mode 100755 scripts/ci/run_tests.sh create mode 100755 scripts/ci/setup_env.sh create mode 100755 scripts/ci/setup_kdc.sh create mode 100644 scripts/ci/slapd.conf create mode 100644 scripts/perf/dags/perf_dag_1.py create mode 100644 scripts/perf/dags/perf_dag_2.py create mode 100644 scripts/perf/scheduler_ops_metrics.py create mode 100644 scripts/systemd/README create mode 100644 scripts/systemd/airflow create mode 100644 scripts/systemd/airflow-flower.service create mode 100644 scripts/systemd/airflow-kerberos.service create mode 100644 scripts/systemd/airflow-scheduler.service create mode 100644 scripts/systemd/airflow-webserver.service create mode 100644 scripts/systemd/airflow-worker.service create mode 100644 scripts/systemd/airflow.conf create mode 100644 scripts/upstart/README create mode 100644 scripts/upstart/airflow-flower.conf create mode 100644 scripts/upstart/airflow-scheduler.conf create mode 100644 scripts/upstart/airflow-webserver.conf create mode 100644 scripts/upstart/airflow-worker.conf create mode 100644 setup.cfg create mode 100644 setup.py create mode 100644 tests/__init__.py create mode 100644 tests/api/__init__.py create mode 100644 tests/api/client/__init__.py create mode 100644 tests/api/client/test_local_client.py create mode 100644 tests/api/common/__init__.py create mode 100644 tests/api/common/experimental/__init__.py create mode 100644 tests/api/common/experimental/mark_tasks.py create mode 100644 tests/api/common/experimental/test_pool.py create mode 100644 tests/configuration.py create mode 100644 tests/contrib/__init__.py create mode 100644 tests/contrib/hooks/__init__.py create mode 100644 tests/contrib/hooks/gcp_pubsub_hook.py create mode 100644 tests/contrib/hooks/test_aws_dynamodb_hook.py create mode 100644 tests/contrib/hooks/test_aws_hook.py create mode 100644 tests/contrib/hooks/test_aws_lambda_hook.py create mode 100644 tests/contrib/hooks/test_bigquery_hook.py create mode 100644 tests/contrib/hooks/test_databricks_hook.py create mode 100644 tests/contrib/hooks/test_emr_hook.py create mode 100644 tests/contrib/hooks/test_ftp_hook.py create mode 100644 tests/contrib/hooks/test_gcp_dataflow_hook.py create mode 100644 tests/contrib/hooks/test_gcp_dataproc_hook.py create mode 100644 tests/contrib/hooks/test_gcp_mlengine_hook.py create mode 100644 tests/contrib/hooks/test_jdbc_hook.py create mode 100644 tests/contrib/hooks/test_jira_hook.py create mode 100644 tests/contrib/hooks/test_redis_hook.py create mode 100644 tests/contrib/hooks/test_spark_sql_hook.py create mode 100644 tests/contrib/hooks/test_spark_submit_hook.py create mode 100644 tests/contrib/hooks/test_sqoop_hook.py create mode 100644 tests/contrib/hooks/test_ssh_hook.py create mode 100644 tests/contrib/hooks/test_wasb_hook.py create mode 100644 tests/contrib/hooks/test_zendesk_hook.py create mode 100644 tests/contrib/operators/__init__.py create mode 100644 tests/contrib/operators/pubsub_operator.py create mode 100644 tests/contrib/operators/test_databricks_operator.py create mode 100644 tests/contrib/operators/test_dataflow_operator.py create mode 100644 tests/contrib/operators/test_dataproc_operator.py create mode 100644 tests/contrib/operators/test_druid_operator.py create mode 100644 tests/contrib/operators/test_ecs_operator.py create mode 100644 tests/contrib/operators/test_emr_add_steps_operator.py create mode 100644 tests/contrib/operators/test_emr_create_job_flow_operator.py create mode 100644 tests/contrib/operators/test_emr_terminate_job_flow_operator.py create mode 100644 tests/contrib/operators/test_file_to_wasb.py create mode 100644 tests/contrib/operators/test_fs_operator.py create mode 100644 tests/contrib/operators/test_hipchat_operator.py create mode 100644 tests/contrib/operators/test_hive_to_dynamodb_operator.py create mode 100644 tests/contrib/operators/test_jira_operator_test.py create mode 100644 tests/contrib/operators/test_mlengine_operator.py create mode 100644 tests/contrib/operators/test_mlengine_operator_utils.py create mode 100644 tests/contrib/operators/test_qubole_operator.py create mode 100644 tests/contrib/operators/test_sftp_operator.py create mode 100644 tests/contrib/operators/test_spark_sql_operator.py create mode 100644 tests/contrib/operators/test_spark_submit_operator.py create mode 100644 tests/contrib/operators/test_sqoop_operator.py create mode 100644 tests/contrib/operators/test_ssh_operator.py create mode 100644 tests/contrib/sensors/__init__.py create mode 100644 tests/contrib/sensors/test_datadog_sensor.py create mode 100644 tests/contrib/sensors/test_emr_base_sensor.py create mode 100644 tests/contrib/sensors/test_emr_job_flow_sensor.py create mode 100644 tests/contrib/sensors/test_emr_step_sensor.py create mode 100644 tests/contrib/sensors/test_ftp_sensor.py create mode 100644 tests/contrib/sensors/test_hdfs_sensors.py create mode 100644 tests/contrib/sensors/test_jira_sensor_test.py create mode 100644 tests/contrib/sensors/test_redis_sensor.py create mode 100644 tests/contrib/sensors/test_wasb_sensor.py create mode 100644 tests/core.py create mode 100644 tests/dags/README.md create mode 100644 tests/dags/no_dags.py create mode 100644 tests/dags/test_backfill_pooled_tasks.py create mode 100644 tests/dags/test_cli_triggered_dags.py create mode 100644 tests/dags/test_default_impersonation.py create mode 100644 tests/dags/test_double_trigger.py create mode 100644 tests/dags/test_example_bash_operator.py create mode 100644 tests/dags/test_impersonation.py create mode 100644 tests/dags/test_issue_1225.py create mode 100644 tests/dags/test_latest_runs.py create mode 100644 tests/dags/test_mark_success.py create mode 100644 tests/dags/test_no_impersonation.py create mode 100644 tests/dags/test_retry_handling_job.py create mode 100644 tests/dags/test_scheduler_dags.py create mode 100644 tests/dags/test_zip.zip create mode 100644 tests/dags_with_system_exit/a_system_exit.py create mode 100644 tests/dags_with_system_exit/b_test_scheduler_dags.py create mode 100644 tests/dags_with_system_exit/c_system_exit.py create mode 100644 tests/executors/__init__.py create mode 100644 tests/executors/dask_executor.py create mode 100644 tests/executors/test_celery_executor.py create mode 100644 tests/executors/test_executor.py create mode 100644 tests/executors/test_local_executor.py create mode 100644 tests/hooks/__init__.py create mode 100644 tests/hooks/test_dbapi_hook.py create mode 100644 tests/hooks/test_druid_hook.py create mode 100644 tests/hooks/test_postgres_hook.py create mode 100644 tests/impersonation.py create mode 100644 tests/jobs.py create mode 100644 tests/macros/__init__.py create mode 100644 tests/macros/test_hive.py create mode 100644 tests/models.py create mode 100644 tests/operators/__init__.py create mode 100644 tests/operators/docker_operator.py create mode 100644 tests/operators/hive_operator.py create mode 100644 tests/operators/latest_only_operator.py create mode 100644 tests/operators/operators.py create mode 100644 tests/operators/python_operator.py create mode 100644 tests/operators/s3_to_hive_operator.py create mode 100644 tests/operators/sensors.py create mode 100644 tests/operators/subdag_operator.py create mode 100644 tests/operators/test_hive_to_druid.py create mode 100644 tests/operators/test_virtualenv_operator.py create mode 100644 tests/plugins/test_plugin.py create mode 100644 tests/plugins_manager.py create mode 100644 tests/security/__init__.py create mode 100644 tests/security/kerberos.py create mode 100644 tests/test_logging_config.py create mode 100644 tests/test_utils/README.md create mode 100644 tests/test_utils/__init__.py create mode 100644 tests/test_utils/fake_datetime.py create mode 100644 tests/test_utils/reset_warning_registry.py create mode 100644 tests/testconfig/conf/core-with-trash.xml create mode 100644 tests/testconfig/conf/emr-core-site.xml create mode 100644 tests/testconfig/conf/ha-core-site.xml create mode 100644 tests/testconfig/conf/ha-noport-hdfs-site.xml create mode 100644 tests/testconfig/conf/ha-noport-trash-hdfs-site.xml create mode 100644 tests/testconfig/conf/ha-port-hdfs-site.xml create mode 100644 tests/testconfig/conf/non-ha-port-core-site.xml create mode 100644 tests/ti_deps/__init__.py create mode 100644 tests/ti_deps/contexts/__init__.py create mode 100644 tests/ti_deps/deps/__init__.py create mode 100644 tests/ti_deps/deps/fake_models.py create mode 100644 tests/ti_deps/deps/test_dag_ti_slots_available_dep.py create mode 100644 tests/ti_deps/deps/test_dag_unpaused_dep.py create mode 100644 tests/ti_deps/deps/test_dagrun_exists_dep.py create mode 100644 tests/ti_deps/deps/test_not_in_retry_period_dep.py create mode 100644 tests/ti_deps/deps/test_not_running_dep.py create mode 100644 tests/ti_deps/deps/test_not_skipped_dep.py create mode 100644 tests/ti_deps/deps/test_prev_dagrun_dep.py create mode 100644 tests/ti_deps/deps/test_runnable_exec_date_dep.py create mode 100644 tests/ti_deps/deps/test_task_concurrency.py create mode 100644 tests/ti_deps/deps/test_trigger_rule_dep.py create mode 100644 tests/ti_deps/deps/test_valid_state_dep.py create mode 100644 tests/utils.py create mode 100644 tests/utils/__init__.py create mode 100644 tests/utils/log/test_logging.py create mode 100644 tests/utils/test_compression.py create mode 100644 tests/utils/test_dates.py create mode 100644 tests/utils/test_email.py create mode 100644 tests/utils/test_helpers.py create mode 100644 tests/utils/test_json.py create mode 100644 tests/utils/test_log_handlers.py create mode 100644 tests/utils/test_logging_mixin.py create mode 100644 tests/utils/test_module_loading.py create mode 100644 tests/utils/test_operator_helpers.py create mode 100644 tests/www/__init__.py create mode 100644 tests/www/api/__init__.py create mode 100644 tests/www/api/experimental/__init__.py create mode 100644 tests/www/api/experimental/test_endpoints.py create mode 100644 tests/www/api/experimental/test_kerberos_endpoints.py create mode 100644 tests/www/test_logs/dag_for_testing_log_view/task_for_testing_log_view/2017-09-01T00.00.00/1.log create mode 100644 tests/www/test_utils.py create mode 100644 tests/www/test_validators.py create mode 100644 tests/www/test_views.py create mode 100644 tox.ini diff --git a/.codecov.yml b/.codecov.yml new file mode 100644 index 0000000000000..a496cb4ce07f1 --- /dev/null +++ b/.codecov.yml @@ -0,0 +1,14 @@ +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +# keep default diff --git a/.coveragerc b/.coveragerc new file mode 100644 index 0000000000000..1d487e845ca45 --- /dev/null +++ b/.coveragerc @@ -0,0 +1,20 @@ +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +[report] +omit = + *contrib* + tests/* + scripts/* + dev/* + airflow/migrations/* diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000000000..b4d5f0728d243 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,45 @@ +# -*- coding: utf-8 -*- +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +root = true + +[*] +end_of_line = lf +indent_style = space +insert_final_newline = true +trim_trailing_whitespace = true +charset = utf-8 + +[*.py] +indent_size = 4 + +[*.sh] +indent_size = 4 + +[*.sql] +indent_size = 4 + +[*.js] +indent_size = 2 + +[*.css] +indent_size = 2 + +[*.{md,rst}] +indent_size = 2 + +[*.{yml,yaml}] +indent_size = 2 + +[*.{htm,html}] +indent_size = 2 diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 0000000000000..63e3b026047a3 --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,27 @@ +Dear Airflow maintainers, + +Please accept this PR. I understand that it will not be reviewed until I have checked off all the steps below! + + +### JIRA +- [ ] My PR addresses the following [Airflow JIRA](https://issues.apache.org/jira/browse/AIRFLOW/) issues and references them in the PR title. For example, "[AIRFLOW-XXX] My Airflow PR" + - https://issues.apache.org/jira/browse/AIRFLOW-XXX + + +### Description +- [ ] Here are some details about my PR, including screenshots of any UI changes: + + +### Tests +- [ ] My PR adds the following unit tests __OR__ does not need testing for this extremely good reason: + + +### Commits +- [ ] My commits all reference JIRA issues in their subject lines, and I have squashed multiple commits if they address the same issue. In addition, my commits follow the guidelines from "[How to write a good git commit message](http://chris.beams.io/posts/git-commit/)": + 1. Subject is separated from body by a blank line + 2. Subject is limited to 50 characters + 3. Subject does not end with a period + 4. Subject uses the imperative mood ("add", not "adding") + 5. Body wraps at 72 characters + 6. Body explains "what" and "why", not "how" + diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000000000..254ad156132b6 --- /dev/null +++ b/.gitignore @@ -0,0 +1,126 @@ +# Airflow configuration +airflow.cfg +unittests.cfg +airflow_login.py +dbinit.py +initdb.py +secrets.py + +# Airflow sqlite databases +airflow.db +unittests.db + +# Airflow temporary artifacts +airflow/git_version +airflow/www/static/coverage/ +logs/ + +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class + +# C extensions +*.so + +# Distribution / packaging +.Python +env/ +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +*.egg-info/ +.installed.cfg +*.egg + +# PyInstaller +# Usually these files are written by a python script from a template +# before PyInstaller builds the exe, so as to inject date/other infos into it. +*.manifest +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*,cover +.hypothesis/ + +# Translations +*.mo +*.pot + +# Django stuff: +# *.log +local_settings.py + +# Flask stuff: +instance/ +.webassets-cache + +# Scrapy stuff: +.scrapy + +# Sphinx documentation +docs/_build/ + +# PyBuilder +target/ + +# Jupyter Notebook +.ipynb_checkpoints + +# pyenv +.python-version + +# celery beat schedule file +celerybeat-schedule + +# SageMath parsed files +*.sage.py + +# dotenv +.env + +# virtualenv +.venv* +venv* +ENV/ + +# Spyder project settings +.spyderproject + +# Rope project settings +.ropeproject + +# PyCharm +.idea/ + +# vim +*.swp + +# OSX +.DS_Store + +# SQL Server backups +*.bkp + +# Spark +rat-results.txt diff --git a/.rat-excludes b/.rat-excludes new file mode 100644 index 0000000000000..725a305c01e1f --- /dev/null +++ b/.rat-excludes @@ -0,0 +1,37 @@ +.gitignore +.gitattributes +.coverage +.coveragerc +.codecov.yml +.rat-excludes +requirements.txt +.*log +.travis.yml +.*pyc +docs +.*md +dist +build +airflow.egg-info +apache_airflow.egg-info +.idea +metastore_db +.*sql +.*csv +CHANGELOG.txt +.*zip +# Apache Rat does not detect BSD-2 clause properly +# it is compatible according to http://www.apache.org/legal/resolved.html#category-a +kerberos_auth.py +airflow_api_auth_backend_kerberos_auth_py.html +licenses/* +airflow/www/static/docs +parallel.js +underscore.js +jquery.dataTables.min.js +jqClock.min.js +dagre-d3.min.js +bootstrap-toggle.min.js +bootstrap-toggle.min.css +d3.v3.min.js +ace.js diff --git a/.readthedocs.yml b/.readthedocs.yml new file mode 100644 index 0000000000000..ca5b56d9efe56 --- /dev/null +++ b/.readthedocs.yml @@ -0,0 +1,20 @@ +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +python: + pip_install: true + extra_requirements: + - doc + - docker + - gcp_api + - emr diff --git a/.travis.yml b/.travis.yml new file mode 100644 index 0000000000000..d3cd216ba1252 --- /dev/null +++ b/.travis.yml @@ -0,0 +1,112 @@ +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +sudo: true +dist: trusty +language: python +jdk: + - oraclejdk8 +services: + - mysql + - postgresql + - rabbitmq +addons: + apt: + packages: + - slapd + - ldap-utils + - openssh-server + - mysql-server-5.6 + - mysql-client-core-5.6 + - mysql-client-5.6 + - krb5-user + - krb5-kdc + - krb5-admin-server + - oracle-java8-installer + - python-selinux + postgresql: "9.2" +python: + - "2.7" + - "3.4" +env: + global: + - TRAVIS_CACHE=$HOME/.travis_cache/ + - KRB5_CONFIG=/etc/krb5.conf + - KRB5_KTNAME=/etc/airflow.keytab + # Travis on google cloud engine has a global /etc/boto.cfg that + # does not work with python 3 + - BOTO_CONFIG=/tmp/bogusvalue + matrix: + - TOX_ENV=py27-cdh-airflow_backend_mysql + - TOX_ENV=py27-cdh-airflow_backend_sqlite + - TOX_ENV=py27-cdh-airflow_backend_postgres +# - TOX_ENV=py27-hdp-airflow_backend_mysql +# - TOX_ENV=py27-hdp-airflow_backend_sqlite +# - TOX_ENV=py27-hdp-airflow_backend_postgres + - TOX_ENV=py34-cdh-airflow_backend_mysql + - TOX_ENV=py34-cdh-airflow_backend_sqlite + - TOX_ENV=py34-cdh-airflow_backend_postgres +# - TOX_ENV=py34-hdp-airflow_backend_mysql +# - TOX_ENV=py34-hdp-airflow_backend_sqlite +# - TOX_ENV=py34-hdp-airflow_backend_postgres +matrix: + exclude: + - python: "3.4" + env: TOX_ENV=py27-cdh-airflow_backend_mysql + - python: "3.4" + env: TOX_ENV=py27-cdh-airflow_backend_sqlite + - python: "3.4" + env: TOX_ENV=py27-cdh-airflow_backend_postgres + - python: "3.4" + env: TOX_ENV=py27-hdp-airflow_backend_mysql + - python: "3.4" + env: TOX_ENV=py27-hdp-airflow_backend_sqlite + - python: "3.4" + env: TOX_ENV=py27-hdp-airflow_backend_postgres + - python: "2.7" + env: TOX_ENV=py34-cdh-airflow_backend_mysql + - python: "2.7" + env: TOX_ENV=py34-cdh-airflow_backend_sqlite + - python: "2.7" + env: TOX_ENV=py34-cdh-airflow_backend_postgres + - python: "2.7" + env: TOX_ENV=py34-hdp-airflow_backend_mysql + - python: "2.7" + env: TOX_ENV=py34-hdp-airflow_backend_sqlite + - python: "2.7" + env: TOX_ENV=py34-hdp-airflow_backend_postgres +cache: + directories: + - $HOME/.wheelhouse/ + - $HOME/.travis_cache/ +before_install: + - yes | ssh-keygen -t rsa -C your_email@youremail.com -P '' -f ~/.ssh/id_rsa + - cat ~/.ssh/id_rsa.pub >> ~/.ssh/authorized_keys + - ln -s ~/.ssh/authorized_keys ~/.ssh/authorized_keys2 + - chmod 600 ~/.ssh/* + - jdk_switcher use oraclejdk8 +install: + - pip install --upgrade pip + - pip install tox + - pip install codecov +before_script: + - mysql -e 'drop database if exists airflow; create database airflow' -u root + - psql -c 'create database airflow;' -U postgres + - export PATH=${PATH}:/tmp/hive/bin +script: + - pip --version + - ls -l $HOME/.wheelhouse + - tox --version + - tox -e $TOX_ENV +after_success: + - codecov diff --git a/CHANGELOG.txt b/CHANGELOG.txt new file mode 100644 index 0000000000000..070fe1524b418 --- /dev/null +++ b/CHANGELOG.txt @@ -0,0 +1,678 @@ +AIRFLOW 1.8.2, 2017-09-04 +------------------------- + +9a53e66 [AIRFLOW-809][AIRFLOW-1] Use __eq__ ColumnOperator When Testing Booleans +333e0b3 [AIRFLOW-1296] Propagate SKIPPED to all downstream tasks +93825d5 Re-enable caching for hadoop components +33a9dcb Pin Hive and Hadoop to a specific version and create writable warehouse dir +7cff6cd [AIRFLOW-1308] Disable nanny usage for Dask +c6a09c4 Updating CHANGELOG for 1.8.2rc1 +570b2ed [AIRFLOW-1294] Backfills can loose tasks to execute +3f48d48 [AIRFLOW-1291] Update NOTICE and LICENSE files to match ASF requirements +e10af9a [AIRFLOW-XXX] Set version to 1.8.2rc1 +69bd269 [AIRFLOW-1160] Update Spark parameters for Mesos +9692510 [AIRFLOW 1149][AIRFLOW-1149] Allow for custom filters in Jinja2 templates +6de5330 [AIRFLOW-1119] Fix unload query so headers are on first row[] +b4e9eb8 [AIRFLOW-1089] Add Spark application arguments +a4083f3 [AIRFLOW-1078] Fix latest_runs endpoint for old flask versions +7a02841 [AIRFLOW-1074] Don't count queued tasks for concurrency limits +a2c18a5 [AIRFLOW-1064] Change default sort to job_id for TaskInstanceModelView +d1c64ab [AIRFLOW-1038] Specify celery serialization options explicitly +b4ee88a [AIRFLOW-1036] Randomize exponential backoff +9fca409 [AIRFLOW-993] Update date inference logic +272c2f5 [AIRFLOW-1167] Support microseconds in FTPHook modification time +c7c0b72 [AIRFLOW-1179] Fix Pandas 0.2x breaking Google BigQuery change +acd0166 [AIRFLOW-1263] Dynamic height for charts +7f33f6e [AIRFLOW-1266] Increase width of gantt y axis +fc33c04 [AIRFLOW-1290] set docs author to 'Apache Airflow' +2e9eee3 [AIRFLOW-1282] Fix known event column sorting +2389a8a [AIRFLOW-1166] Speed up _change_state_for_tis_without_dagrun +bf966e6 [AIRFLOW-1192] Some enhancements to qubole_operator +57d5bcd [AIRFLOW-1281] Sort variables by key field by default +802fc15 [AIRFLOW-1244] Forbid creation of a pool with empty name +1232b6a [AIRFLOW-1243] DAGs table has no default entries to show +b0ba3c9 [AIRFLOW-1227] Remove empty column on the Logs view +c406652 [AIRFLOW-1226] Remove empty column on the Jobs view +51a83cc [AIRFLOW-1199] Fix create modal +cac7d4c [AIRFLOW-1200] Forbid creation of a variable with an empty key +5f3ee52 [AIRFLOW-1186] Sort dag.get_task_instances by execution_date +f446c08 [AIRFLOW-1145] Fix closest_date_partition function with before set to True If we're looking for the closest date before, we should take the latest date in the list of date before. +93b8e96 [AIRFLOW-1180] Fix flask-wtf version for test_csrf_rejection +bb56805 [AIRFLOW-1170] DbApiHook insert_rows inserts parameters separately +093b2f0 [AIRFLOW-1150] Fix scripts execution in sparksql hook[] +777f181 [AIRFLOW-1168] Add closing() to all connections and cursors +bc8e912 [AIRFLOW-XXX] Updating CHANGELOG, README, and UPDATING after 1.8.1 release + +AIRFLOW 1.8.1, 2017-05-09 +------------------------- + +[AIRFLOW-1142] SubDAG Tasks Not Executed Even Though All Dependencies Met +[AIRFLOW-1138] Add licenses to files in scripts directory +[AIRFLOW-1127] Move license notices to LICENSE instead of NOTICE +[AIRFLOW-1124] Do not set all task instances to scheduled on backfill +[AIRFLOW-1120] Update version view to include Apache prefix +[AIRFLOW-1062] DagRun#find returns wrong result if external_trigger=False is specified +[AIRFLOW-1054] Fix broken import on test_dag +[AIRFLOW-1050] Retries ignored - regression +[AIRFLOW-1033] TypeError: can't compare datetime.datetime to None +[AIRFLOW-1017] get_task_instance should return None instead of throw an exception for non-existent TIs +[AIRFLOW-1011] Fix bug in BackfillJob._execute() for SubDAGs +[AIRFLOW-1004] `airflow webserver -D` runs in foreground +[AIRFLOW-1001] Landing Time shows "unsupported operand type(s) for -: 'datetime.datetime' and 'NoneType'" on example_subdag_operator +[AIRFLOW-1000] Rebrand to Apache Airflow instead of Airflow +[AIRFLOW-989] Clear Task Regression +[AIRFLOW-974] airflow.util.file mkdir has a race condition +[AIRFLOW-906] Update Code icon from lightning bolt to file +[AIRFLOW-858] Configurable database name for DB operators +[AIRFLOW-853] ssh_execute_operator.py stdout decode default to ASCII +[AIRFLOW-832] Fix debug server +[AIRFLOW-817] Trigger dag fails when using CLI + API +[AIRFLOW-816] Make sure to pull nvd3 from local resources +[AIRFLOW-815] Add previous/next execution dates to available default variables. +[AIRFLOW-813] Fix unterminated unit tests in tests.job (tests/job.py) +[AIRFLOW-812] Scheduler job terminates when there is no dag file +[AIRFLOW-806] UI should properly ignore DAG doc when it is None +[AIRFLOW-794] Consistent access to DAGS_FOLDER and SQL_ALCHEMY_CONN +[AIRFLOW-785] ImportError if cgroupspy is not installed +[AIRFLOW-784] Cannot install with funcsigs > 1.0.0 +[AIRFLOW-780] The UI no longer shows broken DAGs +[AIRFLOW-777] dag_is_running is initlialized to True instead of False +[AIRFLOW-719] Skipped operations make DAG finish prematurely +[AIRFLOW-694] Empty env vars do not overwrite non-empty config values +[AIRFLOW-492] Insert into dag_stats table results into failed task while task itself succeeded +[AIRFLOW-139] Executing VACUUM with PostgresOperator +[AIRFLOW-111] DAG concurrency is not honored +[AIRFLOW-88] Improve clarity Travis CI reports + +AIRFLOW 1.8.0, 2017-03-12 +------------------------- + +[AIRFLOW-900] Double trigger should not kill original task instance +[AIRFLOW-900] Fixes bugs in LocalTaskJob for double run protection +[AIRFLOW-932] Do not mark tasks removed when backfilling +[AIRFLOW-961] run onkill when SIGTERMed +[AIRFLOW-910] Use parallel task execution for backfills +[AIRFLOW-967] Wrap strings in native for py2 ldap compatibility +[AIRFLOW-941] Use defined parameters for psycopg2 +[AIRFLOW-719] Prevent DAGs from ending prematurely +[AIRFLOW-938] Use test for True in task_stats queries +[AIRFLOW-937] Improve performance of task_stats +[AIRFLOW-933] use ast.literal_eval rather eval because ast.literal_eval does not execute input. +[AIRFLOW-925] Revert airflow.hooks change that cherry-pick picked +[AIRFLOW-919] Running tasks with no start date shouldn't break a DAGs UI +[AIRFLOW-802] Add spark-submit operator/hook +[AIRFLOW-897] Prevent dagruns from failing with unfinished tasks +[AIRFLOW-861] make pickle_info endpoint be login_required +[AIRFLOW-853] use utf8 encoding for stdout line decode +[AIRFLOW-856] Make sure execution date is set for local client +[AIRFLOW-830][AIRFLOW-829][AIRFLOW-88] Reduce Travis log verbosity +[AIRFLOW-831] Restore import to fix broken tests +[AIRFLOW-794] Access DAGS_FOLDER and SQL_ALCHEMY_CONN exclusively from settings +[AIRFLOW-694] Fix config behaviour for empty envvar +[AIRFLOW-365] Set dag.fileloc explicitly and use for Code view +[AIRFLOW-931] Do not set QUEUED in TaskInstances +[AIRFLOW-899] Tasks in SCHEDULED state should be white in the UI instead of black +[AIRFLOW-895] Address Apache release incompliancies +[AIRFLOW-893][AIRFLOW-510] Fix crashing webservers when a dagrun has no start date +[AIRFLOW-793] Enable compressed loading in S3ToHiveTransfer +[AIRFLOW-863] Example DAGs should have recent start dates +[AIRFLOW-869] Refactor mark success functionality +[AIRFLOW-856] Make sure execution date is set for local client +[AIRFLOW-814] Fix Presto*CheckOperator.__init__ +[AIRFLOW-844] Fix cgroups directory creation +[AIRFLOW-816] Use static nvd3 and d3 +[AIRFLOW-821] Fix py3 compatibility +[AIRFLOW-817] Check for None value of execution_date in endpoint +[AIRFLOW-822] Close db before exception +[AIRFLOW-815] Add prev/next execution dates to template variables +[AIRFLOW-813] Fix unterminated unit tests in SchedulerJobTest +[AIRFLOW-813] Fix unterminated scheduler unit tests +[AIRFLOW-806] UI should properly ignore DAG doc when it is None +[AIRFLOW-812] Fix the scheduler termination bug. +[AIRFLOW-780] Fix dag import errors no longer working +[AIRFLOW-783] Fix py3 incompatibility in BaseTaskRunner +[AIRFLOW-810] Correct down_revision dag_id/state index creation +[AIRFLOW-807] Improve scheduler performance for large DAGs +[AIRFLOW-798] Check return_code before forcing termination +[AIRFLOW-139] Let psycopg2 handle autocommit for PostgresHook +[AIRFLOW-776] Add missing cgroups devel dependency +[AIRFLOW-777] Fix expression to check if a DagRun is in running state +[AIRFLOW-785] Don't import CgroupTaskRunner at global scope +[AIRFLOW-784] Pin funcsigs to 1.0.0 +[AIRFLOW-624] Fix setup.py to not import airflow.version as version +[AIRFLOW-779] Task should fail with specific message when deleted +[AIRFLOW-778] Fix completey broken MetastorePartitionSensor +[AIRFLOW-739] Set pickle_info log to debug +[AIRFLOW-771] Make S3 logs append instead of clobber +[AIRFLOW-773] Fix flaky datetime addition in api test +[AIRFLOW-219][AIRFLOW-398] Cgroups + impersonation +[AIRFLOW-683] Add jira hook, operator and sensor +[AIRFLOW-762] Add Google DataProc delete operator +[AIRFLOW-760] Update systemd config +[AIRFLOW-759] Use previous dag_run to verify depend_on_past +[AIRFLOW-757] Set child_process_log_directory default more sensible +[AIRFLOW-692] Open XCom page to super-admins only +[AIRFLOW-737] Fix HDFS Sensor directory. +[AIRFLOW-747] Fix retry_delay not honoured +[AIRFLOW-558] Add Support for dag.catchup=(True|False) Option +[AIRFLOW-489] Allow specifying execution date in trigger_dag API +[AIRFLOW-738] Commit deleted xcom items before insert +[AIRFLOW-729] Add Google Cloud Dataproc cluster creation operator +[AIRFLOW-728] Add Google BigQuery table sensor +[AIRFLOW-741] Log to debug instead of info for app.py +[AIRFLOW-731] Fix period bug for NamedHivePartitionSensor +[AIRFLOW-740] Pin jinja2 to < 2.9.0 +[AIRFLOW-663] Improve time units for task performance charts +[AIRFLOW-665] Fix email attachments +[AIRFLOW-734] Fix SMTP auth regression when not using user/pass +[AIRFLOW-702] Fix LDAP Regex Bug +[AIRFLOW-717] Add Cloud Storage updated sensor +[AIRFLOW-695] Retries do not execute because dagrun is in FAILED state +[AIRFLOW-673] Add operational metrics test for SchedulerJob +[AIRFLOW-727] try_number is not increased +[AIRFLOW-715] A more efficient HDFS Sensor: +[AIRFLOW-716] Allow AVRO BigQuery load-job without schema +[AIRFLOW-718] Allow the query URI for DataProc Pig +Log needs to be part of try/catch block +[AIRFLOW-721] Descendant process can disappear before termination +[AIRFLOW-403] Bash operator's kill method leaves underlying processes running +[AIRFLOW-657] Add AutoCommit Parameter for MSSQL +[AIRFLOW-641] Improve pull request instructions +[AIRFLOW-685] Add test for MySqlHook.bulk_load() +[AIRFLOW-686] Match auth backend config section +[AIRFLOW-691] Add SSH KeepAlive option to SSH_hook +[AIRFLOW-709] Use same engine for migrations and reflection +[AIRFLOW-700] Update to reference to web authentication documentation +[AIRFLOW-649] Support non-sched DAGs in LatestOnlyOp +[AIRFLOW-712] Fix AIRFLOW-667 to use proper HTTP error properties +[AIRFLOW-710] Add OneFineStay as official user +[AIRFLOW-703][AIRFLOW-1] Stop Xcom being cleared too early +[AIRFLOW-679] Stop concurrent task instances from running +[AIRFLOW-704][AIRFLOW-1] Fix invalid syntax in BQ hook +[AIRFLOW-667] Handle BigQuery 503 error +[AIRFLOW-680] Disable connection pool for commands +[AIRFLOW-678] Prevent scheduler from double triggering TIs +[AIRFLOW-677] Kill task if it fails to heartbeat +[AIRFLOW-674] Ability to add descriptions for DAGs +[AIRFLOW-682] Bump MAX_PERIODS to make mark_success work for large DAGs +Use jdk selector to set required jdk +[AIRFLOW-647] Restore dag.get_active_runs +[AIRFLOW-662] Change seasons to months in project description +[AIRFLOW-656] Add dag/task/date index to xcom table +[AIRFLOW-658] Improve schema_update_options in GCP +[AIRFLOW-41] Fix pool oversubscription +[AIRFLOW-489] Add API Framework +[AIRFLOW-653] Add some missing endpoint tests +[AIRFLOW-652] Remove obsolete endpoint +[AIRFLOW-345] Add contrib ECSOperator +[AIRFLOW-650] Adding Celect to user list +[AIRFLOW-510] Filter Paused Dags, show Last Run & Trigger Dag +[AIRFLOW-643] Improve date handling for sf_hook +[AIRFLOW-638] Add schema_update_options to GCP ops +[AIRFLOW-640] Install and enable nose-ignore-docstring +[AIRFLOW-639]AIRFLOW-639] Alphasort package names +[AIRFLOW-375] Fix pylint errors +[AIRFLOW-347] Show empty DAG runs in tree view +[AIRFLOW-628] Adding SalesforceHook to contrib/hooks +[AIRFLOW-514] hive hook loads data from pandas DataFrame into hive and infers types +[AIRFLOW-565] Fixes DockerOperator on Python3.x +[AIRFLOW-635] Encryption option for S3 hook +[AIRFLOW-137] Fix max_active_runs on clearing tasks +[AIRFLOW-343] Fix schema plumbing in HiveServer2Hook +[AIRFLOW-130] Fix ssh operator macosx +[AIRFLOW-633] Show TI attributes in TI view +[AIRFLOW-626][AIRFLOW-1] HTML Content does not show up when sending email with attachment +[AIRFLOW-533] Set autocommit via set_autocommit +[AIRFLOW-629] stop pinning lxml +[AIRFLOW-464] Add setdefault method to Variable +[AIRFLOW-626][AIRFLOW-1] HTML Content does not show up when sending email with attachment +[AIRFLOW-591] Add datadog hook & sensor +[AIRFLOW-561] Add RedshiftToS3Transfer operator +[AIRFLOW-570] Pass root to date form on gantt +[AIRFLOW-504] Store fractional seconds in MySQL tables +[AIRFLOW-623] LDAP attributes not always a list +[AIRFLOW-611] source_format in BigQueryBaseCursor +[AIRFLOW-619] Fix exception in Gannt chart +[AIRFLOW-618] Cast DateTimes to avoid sqllite errors +[AIRFLOW-422] Add JSON endpoint for task info +[AIRFLOW-616][AIRFLOW-617] Minor fixes to PR tool UX +[AIRFLOW-179] Fix DbApiHook with non-ASCII chars +[AIRFLOW-566] Add timeout while fetching logs +[AIRFLOW-615] Set graph glyphicon first +[AIRFLOW-609] Add application_name to PostgresHook +[AIRFLOW-604] Revert .first() to .one() +[AIRFLOW-370] Create AirflowConfigException in exceptions.py +[AIRFLOW-582] Fixes TI.get_dagrun filter (removes start_date) +[AIRFLOW-568] Fix double task_stats count if a DagRun is active +[AIRFLOW-585] Fix race condition in backfill execution loop +[AIRFLOW-580] Prevent landscape warning on .format +[AIRFLOW-597] Check if content is None, not false-equivalent +[AIRFLOW-586] test_dag_v1 fails from 0 to 3 a.m. +[AIRFLOW-453] Add XCom Admin Page +[AIRFLOW-588] Add Google Cloud Storage Object sensor[] +[AIRFLOW-592] example_xcom import Error +[AIRFLOW-587] Fix incorrect scope for Google Auth[] +[AIRFLOW-589] Add templatable job_name[] +[AIRFLOW-227] Show running config in config view +[AIRFLOW-319]AIRFLOW-319] xcom push response in HTTP Operator +[AIRFLOW-385] Add symlink to latest scheduler log directory +[AIRFLOW-583] Fix decode error in gcs_to_bq +[AIRFLOW-96] s3_conn_id using environment variable +[AIRFLOW-575] Clarify tutorial and FAQ about `schedule_interval` always inheriting from DAG object +[AIRFLOW-577] Output BigQuery job for improved debugging +[AIRFLOW-560] Get URI & SQLA engine from Connection +[AIRFLOW-518] Require DataProfilingMixin for Variables CRUD +[AIRFLOW-553] Fix load path for filters.js +[AIRFLOW-554] Add Jinja support to Spark-sql +[AIRFLOW-550] Make ssl config check empty string safe +[AIRFLOW-500] Use id for github allowed teams +[AIRFLOW-556] Add UI PR guidelines +[AIRFLOW-358][AIRFLOW-430] Add `connections` cli +[AIRFLOW-548] Load DAGs immediately & continually +[AIRFLOW-539] Updated BQ hook and BQ operator to support Standard SQL. +[AIRFLOW-378] Add string casting to params of spark-sql operator +[AIRFLOW-544] Add Pause/Resume toggle button +[AIRFLOW-333][AIRFLOW-258] Fix non-module plugin components +[AIRFLOW-542] Add tooltip to DAGs links icons +[AIRFLOW-530] Update docs to reflect connection environment var has to be in uppercase +[AIRFLOW-525] Update template_fields in Qubole Op +[AIRFLOW-480] Support binary file download from GCS +[AIRFLOW-198] Implement latest_only_operator +[AIRFLOW-91] Add SSL config option for the webserver +[AIRFLOW-191] Fix connection leak with PostgreSQL backend +[AIRFLOW-512] Fix 'bellow' typo in docs & comments +[AIRFLOW-509][AIRFLOW-1] Create operator to delete tables in BigQuery +[AIRFLOW-498] Remove hard-coded gcp project id +[AIRFLOW-505] Support unicode characters in authors' names +[AIRFLOW-494] Add per-operator success/failure metrics +[AIRFLOW-488] Fix test_simple fail +[AIRFLOW-468] Update Panda requirement to 0.17.1 +[AIRFLOW-159] Add cloud integration section + GCP documentation +[AIRFLOW-477][AIRFLOW-478] Restructure security section for clarity +[AIRFLOW-467] Allow defining of project_id in BigQueryHook +[AIRFLOW-483] Change print to logging statement +[AIRFLOW-475] make the segment granularity in Druid hook configurable + + +AIRFLOW 1.7.2 +------------- + +[AIRFLOW-463] Link Airflow icon to landing page +[AIRFLOW-149] Task Dependency Engine + Why Isn't My Task Running View +[AIRFLOW-361] Add default failure handler for the Qubole Operator +[AIRFLOW-353] Fix dag run status update failure +[AIRFLOW-447] Store source URIs in Python 3 compatible list +[AIRFLOW-443] Make module names unique when importing +[AIRFLOW-444] Add Google authentication backend +[AIRFLOW-446][AIRFLOW-445] Adds missing dataproc submit options +[AIRFLOW-431] Add CLI for CRUD operations on pools +[AIRFLOW-329] Update Dag Overview Page with Better Status Columns +[AIRFLOW-360] Fix style warnings in models.py +[AIRFLOW-425] Add white fill for null state tasks in tree view. +[AIRFLOW-69] Use dag runs in backfill jobs +[AIRFLOW-415] Make dag_id not found error clearer +[AIRFLOW-416] Use ordinals in README's company list +[AIRFLOW-369] Allow setting default DAG orientation +[AIRFLOW-410] Add 2 Q/A to the FAQ in the docs +[AIRFLOW-407] Add different colors for some sensors +[AIRFLOW-414] Improve error message for missing FERNET_KEY +[AIRFLOW-406] Sphinx/rst fixes +[AIRFLOW-412] Fix lxml dependency +[AIRFLOW-413] Fix unset path bug when backfilling via pickle +[AIRFLOW-78] Airflow clear leaves dag_runs +[AIRFLOW-402] Remove NamedHivePartitionSensor static check, add docs +[AIRFLOW-394] Add an option to the Task Duration graph to show cumulative times +[AIRFLOW-404] Retry download if unpacking fails for hive +[AIRFLOW-276] Gunicorn rolling restart +[AIRFLOW-399] Remove dags/testdruid.py +[AIRFLOW-400] models.py/DAG.set_dag_runs_state() does not correctly set state +[AIRFLOW-395] Fix colon/equal signs typo for resources in default config +[AIRFLOW-397] Documentation: Fix typo "instatiating" to "instantiating" +[AIRFLOW-395] Remove trailing commas from resources in config +[AIRFLOW-388] Add a new chart for Task_Tries for each DAG +[AIRFLOW-322] Fix typo in FAQ section +[AIRFLOW-375] Pylint fixes +limit scope to user email only AIRFLOW-386 +[AIRFLOW-383] Cleanup example qubole operator dag +[AIRFLOW-160] Parse DAG files through child processes +[AIRFLOW-381] Manual UI Dag Run creation: require dag_id field +[AIRFLOW-373] Enhance CLI variables functionality +[AIRFLOW-379] Enhance Variables page functionality: import/export variables +[AIRFLOW-331] modify the LDAP authentication config lines in 'Security' sample codes +[AIRFLOW-356][AIRFLOW-355][AIRFLOW-354] Replace nobr, enable DAG only exists locally message, change edit DAG icon +[AIRFLOW-362] Import __future__ division +[AIRFLOW-359] Pin flask-login to 0.2.11 +[AIRFLOW-261] Add bcc and cc fields to EmailOperator +[AIRFLOW-348] Fix code style warnings +[AIRFLOW-349] Add metric for number of zombies killed +[AIRFLOW-340] Remove unused dependency on Babel +[AIRFLOW-339]: Ability to pass a flower conf file +[AIRFLOW-341][operators] Add resource requirement attributes to operators +[AIRFLOW-335] Fix simple style errors/warnings +[AIRFLOW-337] Add __repr__ to VariableAccessor and VariableJsonAccessor +[AIRFLOW-334] Fix using undefined variable +[AIRFLOW-315] Fix blank lines code style warnings +[AIRFLOW-306] Add Spark-sql Hook and Operator +[AIRFLOW-327] Add rename method to the FTPHook +[AIRFLOW-321] Fix a wrong code example about tests/dags +[AIRFLOW-316] Always check DB state for Backfill Job execution +[AIRFLOW-264] Adding workload management for Hive +[AIRFLOW-297] support exponential backoff option for retry delay +[AIRFLOW-31][AIRFLOW-200] Add note to updating.md +[AIRFLOW-307] There is no __neq__ python magic method. +[AIRFLOW-309] Add requirements of develop dependencies to docs +[AIRFLOW-307] Rename __neq__ to __ne__ python magic method. +[AIRFLOW-313] Fix code style for sqoop_hook.py +[AIRFLOW-311] Fix wrong path in CONTRIBUTING.md +[AIRFLOW-24] DataFlow Java Operator +[AIRFLOW-308] Add link to refresh DAG within DAG view header +[AIRFLOW-314] Fix BigQuery cursor run_table_upsert method +[AIRFLOW-298] fix incubator diclaimer in docs +[AIRFLOW-284] HiveServer2Hook fix for cursor scope for get_results +[AIRFLOW-260] More graceful exit when issues can't be closed +[AIRFLOW-260] Handle case when no version is found +[AIRFLOW-228] Handle empty version list in PR tool +[AIRFLOW-302] Improve default squash commit message +[AIRFLOW-187] Improve prompt styling +[AIRFLOW-187] Fix typo in argument name +[AIRFLOW-187] Move "Close XXX" message to end of squash commit +[AIRFLOW-247] Add EMR hook, operators and sensors. Add AWS base hook +[AIRFLOW-301] Fix broken unit test +[AIRFLOW-100] Add execution_date_fn to ExternalTaskSensor +[AIRFLOW-282] Remove PR Tool logic that depends on version formatting +[AIRFLOW-291] Add index for state in TI table +[AIRFLOW-269] Add some unit tests for PostgreSQL +[AIRFLOW-296] template_ext is being treated as a string rather than a tuple in qubole operator +[AIRFLOW-286] Improve FTPHook to implement context manager interface +[AIRFLOW-243] Create NamedHivePartitionSensor +[AIRFLOW-246] Improve dag_stats endpoint query +[AIRFLOW-189] Highlighting of Parent/Child nodes in Graphs +[ARFLOW-255] Check dagrun timeout when comparing active runs +[AIRFLOW-281] Add port to mssql_hook +[AIRFLOW-285] Use Airflow 2.0 style imports for all remaining hooks/operators +[AIRFLOW-40] Add LDAP group filtering feature. +[AIRFLOW-277] Multiple deletions does not work in Task Instances view if using SQLite backend +[AIRFLOW-200] Make hook/operator imports lazy, and print proper exceptions +[AIRFLOW-283] Make store_to_xcom_key a templated field in GoogleCloudStorageDownloadOperator +[AIRFLOW-278] Support utf-8 ecoding for SQL +[AIRFLOW-280] clean up tmp druid table no matter if an ingestion job succeeds or not +[AIRFLOW-274] Add XCom functionality to GoogleCloudStorageDownloadOperator +[AIRFLOW-273] Create an svg version of the airflow logo. +[AIRFLOW-275] Update contributing guidelines +[AIRFLOW-244] Modify hive operator to inject analysis data +[AIRFLOW-162] Allow variable to be accessible into templates +[AIRFLOW-248] Add Apache license header to all files +[AIRFLOW-263] Remove temp backtick file +[AIRFLOW-252] Raise Sqlite exceptions when deleting tasks instance in WebUI +[AIRFLOW-180] Fix timeout behavior for sensors +[AIRFLOW-262] Simplify commands in MANIFEST.in +[AIRFLOW-31] Add zope dependency +[AIRFLOW-6] Remove dependency on Highcharts +[AIRFLOW-234] make task that aren't `running` self-terminate +[AIRFLOW-256] Fix test_scheduler_reschedule heartrate +Add Python 3 compatibility fix +[AIRFLOW-31] Use standard imports for hooks/operators +[AIRFLOW-173] Initial implementation of FileSensor +[AIRFLOW-224] Collect orphaned tasks and reschedule them +[AIRFLOW-239] Fix tests indentation +[AIRFLOW-225] Better units for task duration graph +[AIRFLOW-241] Add testing done section to PR template +[AIRFLOW-222] Show duration of task instances in ui +[AIRFLOW-231] Do not eval user input in PrestoHook +[AIRFLOW-216] Add Sqoop Hook and Operator +[AIRFLOW-171] Add upgrade notes on email and S3 to 1.7.1.2 +[AIRFLOW-238] Make compatible with flask-admin 1.4.1 +[AIRFLOW-230] [HiveServer2Hook] adding multi statements support +[AIRFLOW-142] setup_env.sh doesn't download hive tarball if hdp is specified as distro +[AIRFLOW-223] Make parametrable the IP on which Flower binds to +[AIRFLOW-218] Added option to enable webserver gunicorn access/err logs +[AIRFLOW-213] Add "Closes #X" phrase to commit messages +[AIRFLOW-68] Align start_date with the schedule_interval +[AIRFLOW-9] Improving docs to meet Apache's standards +[AIRFLOW-131] Make XCom.clear more selective +[AIRFLOW-214] Fix occasion of detached taskinstance +[AIRFLOW-206] Add commit to close PR +[AIRFLOW-206] Always load local log files if they exist +[AIRFLOW-211] Fix JIRA "resolve" vs "close" behavior +[AIRFLOW-64] Add note about relative DAGS_FOLDER +[AIRFLOW-114] Sort plugins dropdown +[AIRFLOW-209] Add scheduler tests and improve lineage handling +[AIRFLOW-207] Improve JIRA auth workflow +[AIRFLOW-187] Improve PR tool UX +[AIRFLOW-155] Documentation of Qubole Operator +Optimize and refactor process_dag +[AIRFLOW-185] Handle empty versions list +[AIRFLOW-201] Fix for HiveMetastoreHook + kerberos +[AIRFLOW-202]: Fixes stray print line +[AIRFLOW-196] Fix bug that exception is not handled in HttpSensor +[AIRFLOW-195] : Add toggle support to subdag clearing in the CLI +[AIRFLOW-23] Support for Google Cloud DataProc +[AIRFLOW-25] Configuration for Celery always required +[AIRFLOW-190] Add codecov and remove download count +[AIRFLOW-168] Correct evaluation of @once schedule +[AIRFLOW-183] Fetch log from remote when worker returns 4xx/5xx response +[AIRFLOW-181] Fix failing unpacking of hadoop by redownloading +[AIRFLOW-176] remove unused formatting key +[AIRFLOW-167]: Add dag_state option in cli +[AIRFLOW-178] Fix bug so that zip file is detected in DAG folder +[AIRFLOW-176] Improve PR Tool JIRA workflow +AIRFLOW-45: Support Hidden Airflow Variables +[AIRFLOW-175] Run git-reset before checkout in PR tool +[AIRFLOW-157] Make PR tool Py3-compat; add JIRA command +[AIRFLOW-170] Add missing @apply_defaults + + +AIRFLOW 1.7.1, 2016-05-19 +------------------------- + +- Fix : Don't treat premature tasks as could_not_run tasks +- AIRFLOW-92 Avoid unneeded upstream_failed session closes apache/incubator-airflow#1485 +- Add logic to lock DB and avoid race condition +- Handle queued tasks from multiple jobs/executors +- AIRFLOW-52 Warn about overwriting tasks in a DAG +- Fix corner case with joining processes/queues (#1473) +- [AIRFLOW-52] Fix bottlenecks when working with many tasks +- Add columns to toggle extra detail in the connection list view. +- Log the number of errors when importing DAGs +- Log dagbag metrics dupplicate messages in queue into Statsd (#1406) +- Clean up issue template (#1419) +- correct missed arg.foreground to arg.daemon in cli +- Reinstate imports for github enterprise auth +- Use os.execvp instead of subprocess.Popen for the webserver +- Revert from using "--foreground" to "--daemon" +- Implement a Cloudant hook +- Add missing args to `airflow clear` +- Fixed a bug in the scheduler: num_runs used where runs intended +- Add multiprocessing support to the scheduler +- Partial fix to make sure next_run_date cannot be None +- Support list/get/set variables in the CLI +- Properly handle BigQuery booleans in BigQuery hook. +- Added the ability to view XCom variables in webserver +- Change DAG.tasks from a list to a dict +- Add support for zipped dags +- Stop creating hook on instantiating of S3 operator +- User subquery in views to find running DAGs +- Prevent DAGs from being reloaded on every scheduler iteration +- Add a missing word to docs +- Document the parameters of `DbApiHook` +- added oracle operator with existing oracle hook +- Add PyOpenSSL to Google cloud gcp_api. +- Remove executor error unit test +- Add DAG inference, deferral, and context manager +- Don't return error when writing files to Google cloud storage. +- Fix GCS logging for gcp_api. +- Ensure attr is in scope for error message +- Fixing misnamed PULL_REQUEST_TEMPLATE +- Extract non_pooled_task_slot_count into a configuration param +- Update plugins.rst for clarity on the example (#1309) +- Fix s3 logging issue +- Add twitter feed example dag +- Github ISSUE_TEMPLATE & PR_TEMPLATE cleanup +- Reduce logger verbosity +- Adding a PR Template +- Add Lucid to list of users +- Fix usage of asciiart +- Use session instead of outdated main_session for are_dependencies_met +- Fix celery flower port allocation +- Fix for missing edit actions due to flask-admin upgrade +- Fix typo in comment in prioritize_queued method +- Add HipchatOperator +- Include all example dags in backfill unit test +- Make sure skipped jobs are actually skipped +- Fixing a broken example dag, example_skip_dag.py +- Add consistent and thorough signal handling and logging +- Allow Operators to specify SKIPPED status internally +- Update docstring for executor trap unit test +- Doc: explain the usage of Jinja templating for templated params +- Don't schedule runs before the DAG's start_date +- Fix infinite retries with pools, with test +- Fix handling of deadlocked jobs +- Show only Airflow's deprecation warnings +- Set DAG_FOLDER for unit tests +- Missing comma in setup.py +- Deprecate *args and **kwargs in BaseOperator +- Raise deep scheduler exceptions to force a process restart. +- Change inconsistent example DAG owners +- Fix module path of send_email_smtp in configuration +- added Gentner Lab to list of users +- Increase timeout time for unit test +- Fix reading strings from conf +- CHORE - Remove Trailing Spaces +- Fix SSHExecuteOperator crash when using a custom ssh port +- Add note about airflow components to template +- Rewrite BackfillJob logic for clarity +- Add unit tests +- Fix miscellaneous bugs and clean up code +- Fix logic for determining DagRun states +- Make SchedulerJob not run EVERY queued task +- Improve BackfillJob handling of queued/deadlocked tasks +- Introduce ignore_depends_on_past parameters +- Use Popen with CeleryExecutor +- Rename user table to users to avoid conflict with postgres +- Beware of negative pool slots. +- Add support for calling_format from boto to S3_Hook +- Add pypi meta data and sync version number +- Set dags_are_paused_at_creation's default value to True +- Resurface S3Log class eaten by rebase/push -f +- Add missing session.commit() at end of initdb +- Validate that subdag tasks have pool slots available, and test +- Use urlparse for remote GCS logs, and add unit tests +- Make webserver worker timeout configurable +- Fixed scheduling for @once interval +- Use psycopg2's API for serializing postgres cell values +- Make the provide_session decorator more robust +- update link to Lyft's website +- use num_shards instead of partitions to be consistent with batch ingestion +- Add documentation links to README +- Update docs with separate configuration section +- Fix airflow.utils deprecation warning code being Python 3 incompatible +- Extract dbapi cell serialization into its own method +- Set Postgres autocommit as supported only if server version is < 7.4 +- Use refactored utils module in unit test imports +- Add changelog for 1.7.0 +- Use LocalExecutor on Travis if possible +- remove unused logging,errno, MiniHiveCluster imports +- remove extra import of logging lib +- Fix required gcloud version +- Refactoring utils into smaller submodules +- Properly measure number of task retry attempts +- Add function to get configuration as dict, plus unit tests +- Merge branch 'master' into hivemeta_sasl +- Add wiki link to README.md +- [hotfix] make email.Utils > email.utils for py3 +- Add the missing "Date" header to the warning e-mails +- Add the missing "Date" header to the warning e-mails +- Check name of SubDag class instead of class itself +- [hotfix] removing repo_token from .coveralls.yml +- Set the service_name in coverals.yml +- Fixes #1223 +- Update Airflow docs for remote logging +- Add unit tests for trapping Executor errors +- Make sure Executors properly trap errors +- Fix HttpOpSensorTest to use fake resquest session +- Linting +- Add an example on pool usage in the documentation +- Add two methods to bigquery hook's base cursor: run_table_upsert, which adds a table or updates an existing table; and run_grant_dataset_view_access, which grants view access to a given dataset for a given table. +- Tasks references upstream and downstream tasks using strings instead of references +- Fix typos in models.py +- Fix broken links in documentation +- [hotfix] fixing the Scheduler CLI to make dag_id optional +- Update link to Common Pitfalls wiki page in README +- Allow disabling periodic committing when inserting rows with DbApiHook +- added Glassdoor to "who uses airflow" +- Fix typo preventing from launching webserver +- Documentation badge +- Fixing ISSUE_TEMPLATE name to include .md suffix +- Adding an ISSUE_TEMPLATE to ensure that issues are adequately defined +- Linting & debugging +- Refactoring the CLI to be data-driven +- Updating the Bug Reporting protocol in the Contributing.md file +- Fixing the docs +- clean up references to old session +- remove session reference +- resolve conflict +- clear xcom data when task instance starts +- replace main_session with @provide_session +- Add extras to installation.rst +- Changes to Contributing to reflect more closely the current state of development. +- Modifying README to link to the wiki committer list +- docs: fixes a spelling mistake in default config +- Set killMode to 'control-group' for webservice.service +- Set KillMode to 'control-group' for worker.service +- Linting +- Fix WebHdfsSensor +- Adding more licenses to pass checks +- fixing landscape's config +- [hotfix] typo that made it in master +- [hotfix] fixing landscape requirement detection +- Make testing on hive conditional +- Merge remote-tracking branch 'upstream/master' into minicluster +- Update README.md +- Throwing in a few license to pass the build +- Adding a reqs.txt for landscape.io +- Pointing to a reqs file +- Some linting +- Adding a .landscape.yml file +- badge for pypi version +- Add license and ignore for sql and csv +- Use correct connection id +- Use correct table name +- Provide data for ci tests +- new badge for showing staleness of reqs +- removing requirements.txt as it is uni-dimensional +- Make it work on py3 +- Remove decode for logging +- Also keep py2 compatible +- More py3 fixes +- Convert to bytes for py3 compat +- Make sure to be py3 compatible +- Use unicodecsv to make it py3 compatible +- Replace tab with spaces Remove unused import +- Merge remote-tracking branch 'upstream/master' +- Support decimal types in MySQL to GCS +- Make sure to write binary as string can be unicode +- Ignore metastore +- More impyla fixes +- Test HivemetaStore if python 2 +- Allow users to set hdfs_namenode_principal in HDFSHook config +- Add tests for Hiveserver2 and fix some issues from impyla +- Merge branch 'impyla' into minicluster +- This patch allows for testing of hive operators and hooks. Sasl is used (NoSasl in connection string is not possible). Tests have been adjusted. +- Treat SKIPPED and SUCCESS the same way when evaluating depends_on_past=True +- fix bigquery hook +- version cap for gcp_api +- Fix typo when returning VerticaHook +- Adding fernet key to use it as part of stdout commands +- Adding support for ssl parameters. (picking up from jthomas123) +- more detail in error message. +- make sure paths don't conflict bc of trailing / +- change gcs_hook to self.hook +- refactor remote log read/write and add GCS support +- Only use multipart upload in S3Hook if file is large enough +- Merge branch 'airbnb/master' +- Add GSSAPI SASL to HiveMetaStoreHook. +- Add warning for deprecated setting +- Use kerberos_service_name = 'hive' as standard instead of 'impala'. +- Use GSSAPI instead of KERBEROS and provide backwards compatibility +- ISSUE-1123 Use impyla instead of pyhs2 +- set celery_executor to use queue name as exchange diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000000000..04b0d7f7135c2 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,197 @@ +# Contributing + +Contributions are welcome and are greatly appreciated! Every +little bit helps, and credit will always be given. + + +# Table of Contents + * [TOC](#table-of-contents) + * [Types of Contributions](#types-of-contribution) + - [Report Bugs](#report-bugs) + - [Fix Bugs](#fix-bugs) + - [Implement Features](#implement-features) + - [Improve Documentation](#improve-documentation) + - [Submit Feedback](#submit-feedback) + * [Documentation](#documentation) + * [Development and Testing](#development-and-testing) + - [Setting up a development environment](#setting-up-a-development-environment) + - [Pull requests guidelines](#pull-requests-guidelines) + - [Testing Locally](#testing-locally) + * [Changing the Metadata Database](#changing-the-metadata-database) + + +## Types of Contributions + +### Report Bugs + +Report bugs through [Apache Jira](https://issues.apache.org/jira/browse/AIRFLOW) + +Please report relevant information and preferably code that exhibits +the problem. + + +### Fix Bugs + +Look through the Jira issues for bugs. Anything is open to whoever wants +to implement it. + +### Implement Features + +Look through the GitHub issues for features. Anything tagged with +"feature" is open to whoever wants to implement it. + +We've created the operators, hooks, macros and executors we needed, but we +made sure that this part of Airflow is extensible. New operators, +hooks and operators are very welcomed! + +### Improve Documentation + +Airflow could always use better documentation, +whether as part of the official Airflow docs, +in docstrings, `docs/*.rst` or even on the web as blog posts or +articles. + +### Submit Feedback + +The best way to send feedback is to file an issue on Github. + +If you are proposing a feature: + +- Explain in detail how it would work. +- Keep the scope as narrow as possible, to make it easier to + implement. +- Remember that this is a volunteer-driven project, and that + contributions are welcome :) + +## Documentation + +The latest API documentation is usually available [here](http://pythonhosted.org/airflow). +To generate a local version, you need to have installed airflow with +the `doc` extra. In that case you can generate the doc by running: + + cd docs && ./build.sh + +## Development and Testing + +### Setting up a development environment + +Please install python(2.7.x or 3.4.x), mysql, and libxml by using system-level package +managers like yum, apt-get for Linux, or homebrew for Mac OS at first. +It is usually best to work in a virtualenv and tox. Install development requirements: + + cd $AIRFLOW_HOME + virtualenv env + source env/bin/activate + pip install -e .[devel] + tox + +Feel free to customize based on the extras available in [setup.py](./setup.py) + +### Pull Request Guidelines + +Before you submit a pull request from your forked repo, check that it +meets these guidelines: + +1. The pull request should include tests, either as doctests, unit tests, or +both. The airflow repo uses [Travis CI](https://travis-ci.org/apache/incubator-airflow) +to run the tests and [codecov](https://codecov.io/gh/apache/incubator-airflow) +to track coverage. You can set up both for free on your fork. It will +help you making sure you do not break the build with your PR and that you help +increase coverage. +2. Please [rebase your fork](http://stackoverflow.com/a/7244456/1110993), +squash commits, and resolve all conflicts. +3. Every pull request should have an associated +[JIRA](https://issues.apache.org/jira/browse/AIRFLOW/?selectedTab=com.atlassian.jira.jira-projects-plugin:summary-panel). +The JIRA link should also be contained in the PR description. +4. Preface your commit's subject & PR's title with **[AIRFLOW-XXX]** +where *XXX* is the JIRA number. We compose release notes (i.e. for Airflow releases) from all commit titles in a release. +By placing the JIRA number in the commit title and hence in the release notes, +Airflow users can look into JIRA and Github PRs for more details about a particular change. +5. Add an [Apache License](http://www.apache.org/legal/src-headers.html) + header to all new files +6. If the pull request adds functionality, the docs should be updated as part +of the same PR. Doc string are often sufficient. Make sure to follow the +Sphinx compatible standards. +7. The pull request should work for Python 2.7 and 3.4. If you need help +writing code that works in both Python 2 and 3, see the documentation at the +[Python-Future project](http://python-future.org) (the future package is an +Airflow requirement and should be used where possible). +8. As Airflow grows as a project, we try to enforce a more consistent +style and try to follow the Python community guidelines. We track this +using [landscape.io](https://landscape.io/github/apache/incubator-airflow/), +which you can setup on your fork as well to check before you submit your +PR. We currently enforce most [PEP8](https://www.python.org/dev/peps/pep-0008/) +and a few other linting rules. It is usually a good idea to lint locally +as well using [flake8](https://flake8.readthedocs.org/en/latest/) +using `flake8 airflow tests` +9. Please read this excellent [article](http://chris.beams.io/posts/git-commit/) on +commit messages and adhere to them. It makes the lives of those who +come after you a lot easier. + +### Testing locally + +#### TL;DR +Tests can then be run with (see also the [Running unit tests](#running-unit-tests) section below): + + ./run_unit_tests.sh + +#### Running unit tests + +We *highly* recommend setting up [Travis CI](https://travis-ci.org/) on +your repo to automate this. It is free for open source projects. If for +some reason you cannot, you can use the steps below to run tests. + +Here are loose guidelines on how to get your environment to run the unit tests. +We do understand that no one out there can run the full test suite since +Airflow is meant to connect to virtually any external system and that you most +likely have only a subset of these in your environment. You should run the +CoreTests and tests related to things you touched in your PR. + +To set up a unit test environment, first take a look at `run_unit_tests.sh` and +understand that your ``AIRFLOW_CONFIG`` points to an alternate config file +while running the tests. You shouldn't have to alter this config file but +you may if need be. + +From that point, you can actually export these same environment variables in +your shell, start an Airflow webserver ``airflow webserver -d`` and go and +configure your connection. Default connections that are used in the tests +should already have been created, you just need to point them to the systems +where you want your tests to run. + +Once your unit test environment is setup, you should be able to simply run +``./run_unit_tests.sh`` at will. + +For example, in order to just execute the "core" unit tests, run the following: + +``` +./run_unit_tests.sh tests.core:CoreTest -s --logging-level=DEBUG +``` + +or a single test method: + +``` +./run_unit_tests.sh tests.core:CoreTest.test_check_operators -s --logging-level=DEBUG +``` + +For more information on how to run a subset of the tests, take a look at the +nosetests docs. + +See also the list of test classes and methods in `tests/core.py`. + +### Changing the Metadata Database + +When developing features the need may arise to persist information to the the +metadata database. Airflow has [Alembic](https://bitbucket.org/zzzeek/alembic) +built-in to handle all schema changes. Alembic must be installed on your +development machine before continuing. + +``` +# starting at the root of the project +$ pwd +~/airflow +# change to the airflow directory +$ cd airflow +$ alembic revision -m "add new field to db" + Generating +~/airflow/airflow/migrations/versions/12341123_add_new_field_to_db.py +``` diff --git a/DISCLAIMER b/DISCLAIMER new file mode 100644 index 0000000000000..8fe69887c3638 --- /dev/null +++ b/DISCLAIMER @@ -0,0 +1 @@ +Apache Airflow is an effort undergoing incubation at The Apache Software Foundation (ASF), sponsored by the Apache Incubator. Incubation is required of all newly accepted projects until a further review indicates that the infrastructure, communications, and decision making process have stabilized in a manner consistent with other successful ASF projects. While incubation status is not necessarily a reflection of the completeness or stability of the code, it does indicate that the project has yet to be fully endorsed by the ASF. diff --git a/INSTALL b/INSTALL new file mode 100644 index 0000000000000..7e5bf50a60f4e --- /dev/null +++ b/INSTALL @@ -0,0 +1,9 @@ +# INSTALL / BUILD instruction for Apache Airflow (incubating) +# fetch the tarball and untar the source + +# [optional] run Apache RAT (release audit tool) to validate license headers +# RAT docs here: https://creadur.apache.org/rat/ +java -jar apache-rat.jar -E ./.rat-excludes -d . + +# install the release +python setup.py install diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000000000..19ba93fe743a5 --- /dev/null +++ b/LICENSE @@ -0,0 +1,238 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + +TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + +1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + +2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + +3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + +4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + +5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + +6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + +7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + +8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + +9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + +END OF TERMS AND CONDITIONS + +APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + +Copyright [yyyy] [name of copyright owner] + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. + +============================================================================ + APACHE AIRFLOW SUBCOMPONENTS: + + The Apache Airflow project contains subcomponents with separate copyright + notices and license terms. Your use of the source code for the these + subcomponents is subject to the terms and conditions of the following + licenses. + + +======================================================================== +MIT licenses +======================================================================== + +The following components are provided under the MIT License. See project link for details. +The text of each license is also included at licenses/LICENSE-[project]-[version].txt. + + (MIT License) jquery (https://jquery.org/license/) + (MIT License) dagre-d3 (https://github.com/cpettitt/dagre-d3) + (MIT License) bootstrap (https://github.com/twbs/bootstrap/blob/v3-dev/LICENSE) + (MIT License) d3-tip (https://github.com/Caged/d3-tip) + (MIT License) dataTables (https://datatables.net) + (MIT License) Clock Plugin (https://github.com/Lwangaman/jQuery-Clock-Plugin) + (MIT License) WebGL-2D (https://github.com/gameclosure/webgl-2d) + (MIT License) Underscore.js (http://underscorejs.org) + + +======================================================================== +BSD 2-Clause licenses +======================================================================== +The following components are provided under the BSD 2-Clause license. +See file headers and project links for details. +The text of each license is also included at licenses/LICENSE-[project]-[version].txt. + + (BSD 2 License) flask-kerberos (https://github.com/mkomitee/flask-kerberos) + (BSD 2 License) Ace (https://github.com/ajaxorg/ace) + (BSD 2 License) d3js (https://d3js.org) diff --git a/MANIFEST.in b/MANIFEST.in new file mode 100644 index 0000000000000..69ccafec80113 --- /dev/null +++ b/MANIFEST.in @@ -0,0 +1,24 @@ +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +include NOTICE +include LICENSE +include DISCLAIMER +include CHANGELOG.txt +include README.md +graft airflow/www/templates +graft airflow/www/static +include airflow/alembic.ini +graft scripts/systemd +graft scripts/upstart +graft airflow/config_templates diff --git a/NOTICE b/NOTICE new file mode 100644 index 0000000000000..b1e78ad9c3380 --- /dev/null +++ b/NOTICE @@ -0,0 +1,6 @@ +Apache Airflow +Copyright 2016 and onwards The Apache Software Foundation + + +This product includes software developed at The Apache Software +Foundation (http://www.apache.org/). diff --git a/README.md b/README.md new file mode 100644 index 0000000000000..4d44bcadc1a0b --- /dev/null +++ b/README.md @@ -0,0 +1,205 @@ +# Airflow + +[![PyPI version](https://badge.fury.io/py/apache-airflow.svg)](https://badge.fury.io/py/apache-airflow) +[![Build Status](https://travis-ci.org/apache/incubator-airflow.svg?branch=master)](https://travis-ci.org/apache/incubator-airflow) +[![Coverage Status](https://img.shields.io/codecov/c/github/apache/incubator-airflow/master.svg)](https://codecov.io/github/apache/incubator-airflow?branch=master) +[![Documentation](https://img.shields.io/badge/docs-pythonhosted-blue.svg)](http://pythonhosted.org/airflow/) +[![Join the chat at https://gitter.im/apache/incubator-airflow](https://badges.gitter.im/apache/incubator-airflow.svg)](https://gitter.im/apache/incubator-airflow?utm_source=badge&utm_medium=badge&utm_campaign=pr-badge&utm_content=badge) + +_NOTE: The transition from 1.8.0 (or before) to 1.8.1 (or after) requires uninstalling Airflow before installing the new version. The package name was changed from `airflow` to `apache-airflow` as of version 1.8.1._ + +Airflow is a platform to programmatically author, schedule, and monitor +workflows. + +When workflows are defined as code, they become more maintainable, +versionable, testable, and collaborative. + +Use Airflow to author workflows as directed acyclic graphs (DAGs) of tasks. +The Airflow scheduler executes your tasks on an array of workers while +following the specified dependencies. Rich command line utilities make +performing complex surgeries on DAGs a snap. The rich user interface +makes it easy to visualize pipelines running in production, +monitor progress, and troubleshoot issues when needed. + +## Getting started +Please visit the Airflow Platform documentation for help with [installing Airflow](http://pythonhosted.org/airflow/installation.html), getting a [quick start](http://pythonhosted.org/airflow/start.html), or a more complete [tutorial](http://pythonhosted.org/airflow/tutorial.html). + +For further information, please visit the [Airflow Wiki](https://cwiki.apache.org/confluence/display/AIRFLOW/Airflow+Home). + +## Beyond the Horizon + +Airflow **is not** a data streaming solution. Tasks do not move data from +one to the other (though tasks can exchange metadata!). Airflow is not +in the [Spark Streaming](http://spark.apache.org/streaming/) +or [Storm](https://storm.apache.org/) space, it is more comparable to +[Oozie](http://oozie.apache.org/) or +[Azkaban](https://azkaban.github.io/). + +Workflows are expected to be mostly static or slowly changing. You can think +of the structure of the tasks in your workflow as slightly more dynamic +than a database structure would be. Airflow workflows are expected to look +similar from a run to the next, this allows for clarity around +unit of work and continuity. + +## Principles + +- **Dynamic**: Airflow pipelines are configuration as code (Python), allowing for dynamic pipeline generation. This allows for writing code that instantiates pipelines dynamically. +- **Extensible**: Easily define your own operators, executors and extend the library so that it fits the level of abstraction that suits your environment. +- **Elegant**: Airflow pipelines are lean and explicit. Parameterizing your scripts is built into the core of Airflow using the powerful **Jinja** templating engine. +- **Scalable**: Airflow has a modular architecture and uses a message queue to orchestrate an arbitrary number of workers. Airflow is ready to scale to infinity. + +## User Interface + +- **DAGs**: Overview of all DAGs in your environment. +![](/docs/img/dags.png) + +- **Tree View**: Tree representation of a DAG that spans across time. +![](/docs/img/tree.png) + +- **Graph View**: Visualization of a DAG's dependencies and their current status for a specific run. +![](/docs/img/graph.png) + +- **Task Duration**: Total time spent on different tasks over time. +![](/docs/img/duration.png) + +- **Gantt View**: Duration and overlap of a DAG. +![](/docs/img/gantt.png) + +- **Code View**: Quick way to view source code of a DAG. +![](/docs/img/code.png) + +## Who uses Airflow? + +As the Airflow community grows, we'd like to keep track of who is using +the platform. Please send a PR with your company name and @githubhandle +if you may. + +Committers: + +* Refer to [Committers](https://cwiki.apache.org/confluence/display/AIRFLOW/Committers) + +Currently **officially** using Airflow: + +1. [Airbnb](http://airbnb.io/) [[@mistercrunch](https://github.com/mistercrunch), [@artwr](https://github.com/artwr)] +1. [Agari](https://github.com/agaridata) [[@r39132](https://github.com/r39132)] +1. [allegro.pl](http://allegro.tech/) [[@kretes](https://github.com/kretes)] +1. [AltX](https://www.getaltx.com/about) [[@pedromduarte](https://github.com/pedromduarte)] +1. [Apigee](https://apigee.com) [[@btallman](https://github.com/btallman)] +1. [ARGO Labs](http://www.argolabs.org) [[California Data Collaborative](https://github.com/California-Data-Collaborative)] +1. [Astronomer](http://www.astronomer.io) [[@schnie](https://github.com/schnie)] +1. [Auth0](https://auth0.com) [[@sicarul](https://github.com/sicarul)] +1. [Azri Solutions](http://www.azrisolutions.com/) [[@userimack](https://github.com/userimack)] +1. [BandwidthX](http://www.bandwidthx.com) [[@dineshdsharma](https://github.com/dineshdsharma)] +1. [Bellhops](https://github.com/bellhops) +1. [BlaBlaCar](https://www.blablacar.com) [[@puckel](https://github.com/puckel) & [@wmorin](https://github.com/wmorin)] +1. [Bloc](https://www.bloc.io) [[@dpaola2](https://github.com/dpaola2)] +1. [BlueApron](https://www.blueapron.com) [[@jasonjho](https://github.com/jasonjho) & [@matthewdavidhauser](https://github.com/matthewdavidhauser)] +1. [Blue Yonder](http://www.blue-yonder.com) [[@blue-yonder](https://github.com/blue-yonder)] +1. [California Data Collaborative](https://github.com/California-Data-Collaborative) powered by [ARGO Labs](http://www.argolabs.org) +1. [Carbonite](https://www.carbonite.com) [[@ajbosco](https://github.com/ajbosco)] +1. [Celect](http://www.celect.com) [[@superdosh](https://github.com/superdosh) & [@chadcelect](https://github.com/chadcelect)] +1. [Change.org](https://www.change.org) [[@change](https://github.com/change), [@vijaykramesh](https://github.com/vijaykramesh)] +1. [Checkr](https://checkr.com) [[@tongboh](https://github.com/tongboh)] +1. [Children's Hospital of Philadelphia Division of Genomic Diagnostics](http://www.chop.edu/centers-programs/division-genomic-diagnostics) [[@genomics-geek]](https://github.com/genomics-geek/) +1. [City of San Diego](http://sandiego.gov) [[@MrMaksimize](https://github.com/mrmaksimize), [@andrell81](https://github.com/andrell81) & [@arnaudvedy](https://github.com/arnaudvedy)] +1. [Clairvoyant](https://clairvoyantsoft.com) [@shekharv](https://github.com/shekharv) +1. [Clover Health](https://www.cloverhealth.com) [[@gwax](https://github.com/gwax) & [@vansivallab](https://github.com/vansivallab)] +1. [Chartboost](https://www.chartboost.com) [[@cgelman](https://github.com/cgelman) & [@dclubb](https://github.com/dclubb)] +1. [ContaAzul](https://www.contaazul.com) [[@bern4rdelli](https://github.com/bern4rdelli), [@renanleme](https://github.com/renanleme) & [@sabino](https://github.com/sabino)] +1. [Cotap](https://github.com/cotap/) [[@maraca](https://github.com/maraca) & [@richardchew](https://github.com/richardchew)] +1. [CreditCards.com](https://www.creditcards.com/)[[@vmAggies](https://github.com/vmAggies) & [@jay-wallaby](https://github.com/jay-wallaby)] +1. [Credit Karma](https://www.creditkarma.com/) [[@preete-dixit-ck](https://github.com/preete-dixit-ck) & [@harish-gaggar-ck](https://github.com/harish-gaggar-ck) & [@greg-finley-ck](https://github.com/greg-finley-ck)] +1. [DataFox](https://www.datafox.com/) [[@sudowork](https://github.com/sudowork)] +1. [Digital First Media](http://www.digitalfirstmedia.com/) [[@duffn](https://github.com/duffn) & [@mschmo](https://github.com/mschmo) & [@seanmuth](https://github.com/seanmuth)] +1. [Drivy](https://www.drivy.com) [[@AntoineAugusti](https://github.com/AntoineAugusti)] +1. [Easy Taxi](http://www.easytaxi.com/) [[@caique-lima](https://github.com/caique-lima) & [@WesleyBatista](https://github.com/WesleyBatista)] +1. [eRevalue](https://www.datamaran.com) [[@hamedhsn](https://github.com/hamedhsn)] +1. [evo.company](https://evo.company/) [[@orhideous](https://github.com/orhideous)] +1. [FreshBooks](https://github.com/freshbooks) [[@DinoCow](https://github.com/DinoCow)] +1. [Fundera](https://fundera.com) [[@andyxhadji](https://github.com/andyxhadji)] +1. [GameWisp](https://gamewisp.com) [[@tjbiii](https://github.com/TJBIII) & [@theryanwalls](https://github.com/theryanwalls)] +1. [Gentner Lab](http://github.com/gentnerlab) [[@neuromusic](https://github.com/neuromusic)] +1. [Glassdoor](https://github.com/Glassdoor) [[@syvineckruyk](https://github.com/syvineckruyk)] +1. [GovTech GDS](https://gds-gov.tech) [[@chrissng](https://github.com/chrissng) & [@datagovsg](https://github.com/datagovsg)] +1. [Grand Rounds](https://www.grandrounds.com/) [[@richddr](https://github.com/richddr), [@timz1290](https://github.com/timz1290) & [@wenever](https://github.com/@wenever)] +1. [Groupalia](http://es.groupalia.com) [[@jesusfcr](https://github.com/jesusfcr)] +1. [Gusto](https://gusto.com) [[@frankhsu](https://github.com/frankhsu)] +1. [Handshake](https://joinhandshake.com/) [[@mhickman](https://github.com/mhickman)] +1. [Handy](http://www.handy.com/careers/73115?gh_jid=73115&gh_src=o5qcxn) [[@marcintustin](https://github.com/marcintustin) / [@mtustin-handy](https://github.com/mtustin-handy)] +1. [Healthjump](http://www.healthjump.com/) [[@miscbits](https://github.com/miscbits)] +1. [HBO](http://www.hbo.com/)[[@yiwang](https://github.com/yiwang)] +1. [HelloFresh](https://www.hellofresh.com) [[@tammymendt](https://github.com/tammymendt) & [@davidsbatista](https://github.com/davidsbatista) & [@iuriinedostup](https://github.com/iuriinedostup)] +1. [Holimetrix](http://holimetrix.com/) [[@thibault-ketterer](https://github.com/thibault-ketterer)] +1. [Hootsuite](https://github.com/hootsuite) +1. [IFTTT](https://www.ifttt.com/) [[@apurvajoshi](https://github.com/apurvajoshi)] +1. [iHeartRadio](http://www.iheart.com/)[[@yiwang](https://github.com/yiwang)] +1. [imgix](https://www.imgix.com/) [[@dclubb](https://github.com/dclubb)] +1. [ING](http://www.ing.com/) +1. [Jampp](https://github.com/jampp) +1. [Kiwi.com](https://kiwi.com/) [[@underyx](https://github.com/underyx)] +1. [Kogan.com](https://github.com/kogan) [[@geeknam](https://github.com/geeknam)] +1. [Lemann Foundation](http://fundacaolemann.org.br) [[@fernandosjp](https://github.com/fernandosjp)] +1. [LendUp](https://www.lendup.com/) [[@lendup](https://github.com/lendup)] +1. [LetsBonus](http://www.letsbonus.com) [[@jesusfcr](https://github.com/jesusfcr) & [@OpringaoDoTurno](https://github.com/OpringaoDoTurno)] +1. [liligo](http://liligo.com/) [[@tromika](https://github.com/tromika)] +1. [LingoChamp](http://www.liulishuo.com/) [[@haitaoyao](https://github.com/haitaoyao)] +1. [Lucid](http://luc.id) [[@jbrownlucid](https://github.com/jbrownlucid) & [@kkourtchikov](https://github.com/kkourtchikov)] +1. [Lumos Labs](https://www.lumosity.com/) [[@rfroetscher](https://github.com/rfroetscher/) & [@zzztimbo](https://github.com/zzztimbo/)] +1. [Lyft](https://www.lyft.com/)[[@SaurabhBajaj](https://github.com/SaurabhBajaj)] +1. [Madrone](http://madroneco.com/) [[@mbreining](https://github.com/mbreining) & [@scotthb](https://github.com/scotthb)] +1. [Markovian](https://markovian.com/) [[@al-xv](https://github.com/al-xv), [@skogsbaeck](https://github.com/skogsbaeck), [@waltherg](https://github.com/waltherg)] +1. [Mercadoni](https://www.mercadoni.com.co) [[@demorenoc](https://github.com/demorenoc)] +1. [Mercari](http://www.mercari.com/) [[@yu-iskw](https://github.com/yu-iskw)] +1. [MiNODES](https://www.minodes.com) [[@dice89](https://github.com/dice89), [@diazcelsa](https://github.com/diazcelsa)] +1. [MFG Labs](https://github.com/MfgLabs) +1. [mytaxi](https://mytaxi.com) [[@mytaxi](https://github.com/mytaxi)] +1. [Nerdwallet](https://www.nerdwallet.com) +1. [New Relic](https://www.newrelic.com) [[@marcweil](https://github.com/marcweil)] +1. [Nextdoor](https://nextdoor.com) [[@SivaPandeti](https://github.com/SivaPandeti), [@zshapiro](https://github.com/zshapiro) & [@jthomas123](https://github.com/jthomas123)] +1. [OfferUp](https://offerupnow.com) +1. [OneFineStay](https://www.onefinestay.com) [[@slangwald](https://github.com/slangwald)] +1. [Open Knowledge International](https://okfn.org) [@vitorbaptista](https://github.com/vitorbaptista) +1. [Pandora Media](https://www.pandora.com/) [[@Acehaidrey](https://github.com/Acehaidrey)] +1. [PAYMILL](https://www.paymill.com/) [[@paymill](https://github.com/paymill) & [@matthiashuschle](https://github.com/matthiashuschle)] +1. [PayPal](https://www.paypal.com/) [[@r39132](https://github.com/r39132) & [@jhsenjaliya](https://github.com/jhsenjaliya)] +1. [Postmates](http://www.postmates.com) [[@syeoryn](https://github.com/syeoryn)] +1. [Pronto Tools](http://www.prontotools.io/) [[@zkan](https://github.com/zkan) & [@mesodiar](https://github.com/mesodiar)] +1. [Qubole](https://qubole.com) [[@msumit](https://github.com/msumit)] +1. [Quora](https://www.quora.com/) +1. [Robinhood](https://robinhood.com) [[@vineet-rh](https://github.com/vineet-rh)] +1. [Scaleway](https://scaleway.com) [[@kdeldycke](https://github.com/kdeldycke)] +1. [Sense360](https://github.com/Sense360) [[@kamilmroczek](https://github.com/KamilMroczek)] +1. [Shopkick](https://shopkick.com/) [[@shopkick](https://github.com/shopkick)] +1. [Sidecar](https://hello.getsidecar.com/) [[@getsidecar](https://github.com/getsidecar)] +1. [SimilarWeb](https://www.similarweb.com/) [[@similarweb](https://github.com/similarweb)] +1. [SmartNews](https://www.smartnews.com/) [[@takus](https://github.com/takus)] +1. [Spotify](https://github.com/spotify) [[@znichols](https://github.com/znichols)] +1. [Stackspace](https://beta.stackspace.io/) +1. [Stripe](https://stripe.com) [[@jbalogh](https://github.com/jbalogh)] +1. [Tails.com](https://tails.com/) [[@alanmcruickshank](https://github.com/alanmcruickshank)] +1. [Thumbtack](https://www.thumbtack.com/) [[@natekupp](https://github.com/natekupp)] +1. [Tictail](https://tictail.com/) +1. [T2 Systems](http://t2systems.com) [[@unclaimedpants](https://github.com/unclaimedpants)] +1. [United Airlines](https://www.united.com/) [[@ilopezfr](https://github.com/ilopezfr)] +1. [Vente-Exclusive.com](http://www.vente-exclusive.com/) [[@alexvanboxel](https://github.com/alexvanboxel)] +1. [Vnomics](https://github.com/vnomics) [[@lpalum](https://github.com/lpalum)] +1. [WePay](http://www.wepay.com) [[@criccomini](https://github.com/criccomini) & [@mtagle](https://github.com/mtagle)] +1. [WeTransfer](https://github.com/WeTransfer) [[@jochem](https://github.com/jochem)] +1. [Whistle Labs](http://www.whistle.com) [[@ananya77041](https://github.com/ananya77041)] +1. [WiseBanyan](https://wisebanyan.com/) +1. [Wooga](https://www.wooga.com/) +1. [Xoom](https://www.xoom.com/india/send-money) [[@gepser](https://github.com/gepser) & [@omarvides](https://github.com/omarvides)] +1. [Yahoo!](https://www.yahoo.com/) +1. [Zapier](https://www.zapier.com) [[@drknexus](https://github.com/drknexus) & [@statwonk](https://github.com/statwonk)] +1. [Zendesk](https://www.github.com/zendesk) +1. [Zenly](https://zen.ly) [[@cerisier](https://github.com/cerisier) & [@jbdalido](https://github.com/jbdalido)] +1. [Zymergen](https://www.zymergen.com/) +1. [99](https://99taxis.com) [[@fbenevides](https://github.com/fbenevides), [@gustavoamigo](https://github.com/gustavoamigo) & [@mmmaia](https://github.com/mmmaia)] + +## Links + + +* [Documentation](http://airflow.incubator.apache.org/) +* [Chat](https://gitter.im/apache/incubator-airflow) +* [Apache Airflow Incubation Status](http://incubator.apache.org/projects/airflow.html) +* [More](https://cwiki.apache.org/confluence/display/AIRFLOW/Airflow+Links) diff --git a/TODO.md b/TODO.md new file mode 100644 index 0000000000000..cf19035e1b995 --- /dev/null +++ b/TODO.md @@ -0,0 +1,30 @@ +#### Roadmap items +* UI page answering "Why isn't this task instance running?" +* Attempt removing DagBag caching for the web server +* Distributed scheduler (supervisors) + * Get the supervisors to run sensors (as opposed to each sensor taking a slot) + * Improve DagBag differential refresh + * Pickle all the THINGS! supervisors maintains fresh, versionned pickles in the database as they monitor for change +* Pre-prod running off of master +* Containment / YarnExecutor / Docker? +* Get s3 logs +* Test and migrate to use beeline instead of the Hive CLI +* Run Hive / Hadoop / HDFS tests in Travis-CI + +#### UI +* Backfill form +* Better task filtering int duration and landing time charts (operator toggle, task regex, uncheck all button) +* Add templating to adhoc queries + +#### Backend +* Add a run_only_latest flag to BaseOperator, runs only most recent task instance where deps are met +* Raise errors when setting dependencies on task in foreign DAGs +* Add an is_test flag to the run context + +#### Wishlist +* Pause flag at the task level +* Increase unit test coverage +* Stats logging interface with support for stats and sqlalchemy to collect detailed information from the scheduler and dag processing times + +#### Other +* deprecate TimeSensor diff --git a/UPDATING.md b/UPDATING.md new file mode 100644 index 0000000000000..ebcb5cda89bb9 --- /dev/null +++ b/UPDATING.md @@ -0,0 +1,319 @@ +# Updating Airflow + +This file documents any backwards-incompatible changes in Airflow and +assists people when migrating to a new version. + +## Airflow 1.9 + +### SSH Hook updates, along with new SSH Operator & SFTP Operator + SSH Hook now uses Paramiko library to create ssh client connection, instead of sub-process based ssh command execution previously (<1.9.0), so this is backward incompatible. + - update SSHHook constructor + - use SSHOperator class in place of SSHExecuteOperator which is removed now. Refer test_ssh_operator.py for usage info. + - SFTPOperator is added to perform secure file transfer from serverA to serverB. Refer test_sftp_operator.py.py for usage info. + - No updates are required if you are using ftpHook, it will continue work as is. + +### Logging update + +The logging structure of Airflow has been rewritten to make configuration easier and the logging system more transparent. + +#### A quick recap about logging + +A logger is the entry point into the logging system. Each logger is a named bucket to which messages can be written for processing. A logger is configured to have a log level. This log level describes the severity of the messages that the logger will handle. Python defines the following log levels: DEBUG, INFO, WARNING, ERROR or CRITICAL. + +Each message that is written to the logger is a Log Record. Each log record also has a log level indicating the severity of that specific message. A log record can also contain useful metadata that describes the event that is being logged. This can include details such as a stack trace or an error code. + +When a message is given to the logger, the log level of the message is compared to the log level of the logger. If the log level of the message meets or exceeds the log level of the logger itself, the message will undergo further processing. If it doesn’t, the message will be ignored. + +Once a logger has determined that a message needs to be processed, it is passed to a Handler. This configuration is now more flexible and can be easily be maintained in a single file. + +#### Changes in Airflow Logging + +Airflow's logging mechanism has been refactored to uses Python’s builtin `logging` module to perform logging of the application. By extending classes with the existing `LoggingMixin`, all the logging will go through a central logger. Also the `BaseHook` and `BaseOperator` already extends this class, so it is easily available to do logging. + +The main benefit is easier configuration of the logging by setting a single centralized python file. Disclaimer; there is still some inline configuration, but this will be removed eventually. The new logging class is defined by setting the dotted classpath in your `~/airflow/airflow.cfg` file: + +``` +# Logging class +# Specify the class that will specify the logging configuration +# This class has to be on the python classpath +logging_config_class = my.path.default_local_settings.LOGGING_CONFIG +``` + +The logging configuration file that contains the configuration needs te on the the `PYTHONPATH`, for example in `~/airflow/dags` or `~/airflow/plugins`. These directories are loaded by default, of course you are free to add a directory to the `PYTHONPATH`, this might be handy when you have the config in another directory or you mount a volume in case of Docker. As an example you can start from `airflow.config_templates.airflow_local_settings.LOGGING_CONFIG`: + +``` +LOGGING_CONFIG = { + 'version': 1, + 'disable_existing_loggers': False, + 'formatters': { + 'airflow.task': { + 'format': LOG_FORMAT, + }, + }, + 'handlers': { + 'console': { + 'class': 'logging.StreamHandler', + 'formatter': 'airflow.task', + 'stream': 'ext://sys.stdout' + }, + 'file.task': { + 'class': 'airflow.utils.log.file_task_handler.FileTaskHandler', + 'formatter': 'airflow.task', + 'base_log_folder': os.path.expanduser(BASE_LOG_FOLDER), + 'filename_template': FILENAME_TEMPLATE, + }, + # When using s3 or gcs, provide a customized LOGGING_CONFIG + # in airflow_local_settings within your PYTHONPATH, see UPDATING.md + # for details + 's3.task': { + 'class': 'airflow.utils.log.s3_task_handler.S3TaskHandler', + 'formatter': 'airflow.task', + 'base_log_folder': os.path.expanduser(BASE_LOG_FOLDER), + 's3_log_folder': S3_LOG_FOLDER, + 'filename_template': FILENAME_TEMPLATE, + }, + 'gcs.task': { + 'class': 'airflow.utils.log.gcs_task_handler.GCSTaskHandler', + 'formatter': 'airflow.task', + 'base_log_folder': os.path.expanduser(BASE_LOG_FOLDER), + 'gcs_log_folder': GCS_LOG_FOLDER, + 'filename_template': FILENAME_TEMPLATE, + }, + }, + 'loggers': { + 'airflow.task': { + 'handlers': ['file.task'], + 'level': LOG_LEVEL, + 'propagate': False, + }, + 'airflow.task_runner': { + 'handlers': ['file.task'], + 'level': LOG_LEVEL, + 'propagate': True, + }, + 'airflow': { + 'handlers': ['console'], + 'level': LOG_LEVEL, + 'propagate': False, + }, + } +} +``` + +If you want to customize the logging (for example, use logging rotate), you can do this by defining one or more of the logging handles that [Python has to offer](https://docs.python.org/3/library/logging.handlers.html). For more details about the Python logging, please refer to the [official logging documentation](https://docs.python.org/3/library/logging.html). + +Furthermore, this change also simplifies logging within the DAG itself: + +``` +root@ae1bc863e815:/airflow# python +Python 3.6.2 (default, Sep 13 2017, 14:26:54) +[GCC 4.9.2] on linux +Type "help", "copyright", "credits" or "license" for more information. +>>> from airflow.settings import * +>>> +>>> from datetime import datetime +>>> from airflow import DAG +>>> from airflow.operators.dummy_operator import DummyOperator +>>> +>>> dag = DAG('simple_dag', start_date=datetime(2017, 9, 1)) +>>> +>>> task = DummyOperator(task_id='task_1', dag=dag) +>>> +>>> task.log.error('I want to say something..') +[2017-09-25 20:17:04,927] {:1} ERROR - I want to say something.. +``` + +#### Template path of the file_task_handler + +The `file_task_handler` logger is more flexible. You can change the default format, `{dag_id}/{task_id}/{execution_date}/{try_number}.log` by supplying Jinja templating in the `FILENAME_TEMPLATE` configuration variable. See the `file_task_handler` for more information. + +#### I'm using S3Log or GCSLogs, what do I do!? + +If you are logging to Google cloud storage, please see the [Google cloud platform documentation](https://airflow.incubator.apache.org/integration.html#gcp-google-cloud-platform) for logging instructions. + +If you are using S3, the instructions should be largely the same as the Google cloud platform instructions above. You will need a custom logging config. The `REMOTE_BASE_LOG_FOLDER` configuration key in your airflow config has been removed, therefore you will need to take the following steps: + - Copy the logging configuration from [`airflow/config_templates/airflow_logging_settings.py`](https://github.com/apache/incubator-airflow/blob/master/airflow/config_templates/airflow_local_settings.py) and copy it. + - Place it in a directory inside the Python import path `PYTHONPATH`. If you are using Python 2.7, ensuring that any `__init__.py` files exist so that it is importable. + - Update the config by setting the path of `REMOTE_BASE_LOG_FOLDER` explicitly in the config. The `REMOTE_BASE_LOG_FOLDER` key is not used anymore. + - Set the `logging_config_class` to the filename and dict. For example, if you place `custom_logging_config.py` on the base of your pythonpath, you will need to set `logging_config_class = custom_logging_config.LOGGING_CONFIG` in your config as Airflow 1.8. + +### New Features + +#### Dask Executor + +A new DaskExecutor allows Airflow tasks to be run in Dask Distributed clusters. + +### Deprecated Features +These features are marked for deprecation. They may still work (and raise a `DeprecationWarning`), but are no longer +supported and will be removed entirely in Airflow 2.0 +- If you're using the `google_cloud_conn_id` or `dataproc_cluster` argument names explicitly in `contrib.operators.Dataproc{*}Operator`(s), be sure to rename them to `gcp_conn_id` or `cluster_name`, respectively. We've renamed these arguments for consistency. (AIRFLOW-1323) + +- `post_execute()` hooks now take two arguments, `context` and `result` + (AIRFLOW-886) + + Previously, post_execute() only took one argument, `context`. + +- `contrib.hooks.gcp_dataflow_hook.DataFlowHook` starts to use `--runner=DataflowRunner` instead of `DataflowPipelineRunner`, which is removed from the package `google-cloud-dataflow-0.6.0`. + +- The pickle type for XCom messages has been replaced by json to prevent RCE attacks. + Note that JSON serialization is stricter than pickling, so if you want to e.g. pass + raw bytes through XCom you must encode them using an encoding like base64. + By default pickling is still enabled until Airflow 2.0. To disable it + Set enable_xcom_pickling = False in your Airflow config. + +## Airflow 1.8.1 + +The Airflow package name was changed from `airflow` to `apache-airflow` during this release. You must uninstall your +previously installed version of Airflow before installing 1.8.1. + +## Airflow 1.8 + +### Database +The database schema needs to be upgraded. Make sure to shutdown Airflow and make a backup of your database. To +upgrade the schema issue `airflow upgradedb`. + +### Upgrade systemd unit files +Systemd unit files have been updated. If you use systemd please make sure to update these. + +> Please note that the webserver does not detach properly, this will be fixed in a future version. + +### Tasks not starting although dependencies are met due to stricter pool checking +Airflow 1.7.1 has issues with being able to over subscribe to a pool, ie. more slots could be used than were +available. This is fixed in Airflow 1.8.0, but due to past issue jobs may fail to start although their +dependencies are met after an upgrade. To workaround either temporarily increase the amount of slots above +the the amount of queued tasks or use a new pool. + +### Less forgiving scheduler on dynamic start_date +Using a dynamic start_date (e.g. `start_date = datetime.now()`) is not considered a best practice. The 1.8.0 scheduler +is less forgiving in this area. If you encounter DAGs not being scheduled you can try using a fixed start_date and +renaming your dag. The last step is required to make sure you start with a clean slate, otherwise the old schedule can +interfere. + +### New and updated scheduler options +Please read through these options, defaults have changed since 1.7.1. + +#### child_process_log_directory +In order the increase the robustness of the scheduler, DAGS our now processed in their own process. Therefore each +DAG has its own log file for the scheduler. These are placed in `child_process_log_directory` which defaults to +`/scheduler/latest`. You will need to make sure these log files are removed. + +> DAG logs or processor logs ignore and command line settings for log file locations. + +#### run_duration +Previously the command line option `num_runs` was used to let the scheduler terminate after a certain amount of +loops. This is now time bound and defaults to `-1`, which means run continuously. See also num_runs. + +#### num_runs +Previously `num_runs` was used to let the scheduler terminate after a certain amount of loops. Now num_runs specifies +the number of times to try to schedule each DAG file within `run_duration` time. Defaults to `-1`, which means try +indefinitely. This is only available on the command line. + +#### min_file_process_interval +After how much time should an updated DAG be picked up from the filesystem. + +#### dag_dir_list_interval +How often the scheduler should relist the contents of the DAG directory. If you experience that while developing your +dags are not being picked up, have a look at this number and decrease it when necessary. + +#### catchup_by_default +By default the scheduler will fill any missing interval DAG Runs between the last execution date and the current date. +This setting changes that behavior to only execute the latest interval. This can also be specified per DAG as +`catchup = False / True`. Command line backfills will still work. + +### Faulty Dags do not show an error in the Web UI + +Due to changes in the way Airflow processes DAGs the Web UI does not show an error when processing a faulty DAG. To +find processing errors go the `child_process_log_directory` which defaults to `/scheduler/latest`. + +### New DAGs are paused by default + +Previously, new DAGs would be scheduled immediately. To retain the old behavior, add this to airflow.cfg: + +``` +[core] +dags_are_paused_at_creation = False +``` + +### Airflow Context variable are passed to Hive config if conf is specified + +If you specify a hive conf to the run_cli command of the HiveHook, Airflow add some +convenience variables to the config. In case your run a sceure Hadoop setup it might be +required to whitelist these variables by adding the following to your configuration: + +``` + + hive.security.authorization.sqlstd.confwhitelist.append + airflow\.ctx\..* + +``` +### Google Cloud Operator and Hook alignment + +All Google Cloud Operators and Hooks are aligned and use the same client library. Now you have a single connection +type for all kinds of Google Cloud Operators. + +If you experience problems connecting with your operator make sure you set the connection type "Google Cloud Platform". + +Also the old P12 key file type is not supported anymore and only the new JSON key files are supported as a service +account. + +### Deprecated Features +These features are marked for deprecation. They may still work (and raise a `DeprecationWarning`), but are no longer +supported and will be removed entirely in Airflow 2.0 + +- Hooks and operators must be imported from their respective submodules + + `airflow.operators.PigOperator` is no longer supported; `from airflow.operators.pig_operator import PigOperator` is. + (AIRFLOW-31, AIRFLOW-200) + +- Operators no longer accept arbitrary arguments + + Previously, `Operator.__init__()` accepted any arguments (either positional `*args` or keyword `**kwargs`) without + complaint. Now, invalid arguments will be rejected. (https://github.com/apache/incubator-airflow/pull/1285) + +- The config value secure_mode will default to True which will disable some insecure endpoints/features + +### Known Issues +There is a report that the default of "-1" for num_runs creates an issue where errors are reported while parsing tasks. +It was not confirmed, but a workaround was found by changing the default back to `None`. + +To do this edit `cli.py`, find the following: + +``` + 'num_runs': Arg( + ("-n", "--num_runs"), + default=-1, type=int, + help="Set the number of runs to execute before exiting"), +``` + +and change `default=-1` to `default=None`. Please report on the mailing list if you have this issue. + +## Airflow 1.7.1.2 + +### Changes to Configuration + +#### Email configuration change + +To continue using the default smtp email backend, change the email_backend line in your config file from: + +``` +[email] +email_backend = airflow.utils.send_email_smtp +``` +to: +``` +[email] +email_backend = airflow.utils.email.send_email_smtp +``` + +#### S3 configuration change + +To continue using S3 logging, update your config file so: + +``` +s3_log_folder = s3://my-airflow-log-bucket/logs +``` +becomes: +``` +remote_base_log_folder = s3://my-airflow-log-bucket/logs +remote_log_conn_id = +``` diff --git a/airflow/__init__.py b/airflow/__init__.py new file mode 100644 index 0000000000000..3c5f24c218f03 --- /dev/null +++ b/airflow/__init__.py @@ -0,0 +1,86 @@ +# -*- coding: utf-8 -*- +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +""" +Authentication is implemented using flask_login and different environments can +implement their own login mechanisms by providing an `airflow_login` module +in their PYTHONPATH. airflow_login should be based off the +`airflow.www.login` +""" +from builtins import object +from airflow import version +from airflow.utils.log.logging_mixin import LoggingMixin + +__version__ = version.version + +import sys + +from airflow import configuration as conf +from airflow import settings +from airflow.models import DAG +from flask_admin import BaseView +from importlib import import_module +from airflow.exceptions import AirflowException + +if settings.DAGS_FOLDER not in sys.path: + sys.path.append(settings.DAGS_FOLDER) + +login = None + + +def load_login(): + log = LoggingMixin().log + + auth_backend = 'airflow.default_login' + try: + if conf.getboolean('webserver', 'AUTHENTICATE'): + auth_backend = conf.get('webserver', 'auth_backend') + except conf.AirflowConfigException: + if conf.getboolean('webserver', 'AUTHENTICATE'): + log.warning( + "auth_backend not found in webserver config reverting to " + "*deprecated* behavior of importing airflow_login") + auth_backend = "airflow_login" + + try: + global login + login = import_module(auth_backend) + except ImportError as err: + log.critical( + "Cannot import authentication module %s. " + "Please correct your authentication backend or disable authentication: %s", + auth_backend, err + ) + if conf.getboolean('webserver', 'AUTHENTICATE'): + raise AirflowException("Failed to import authentication backend") + + +class AirflowViewPlugin(BaseView): + pass + + +class AirflowMacroPlugin(object): + def __init__(self, namespace): + self.namespace = namespace + +from airflow import operators +from airflow import hooks +from airflow import executors +from airflow import macros + +operators._integrate_plugins() +hooks._integrate_plugins() +executors._integrate_plugins() +macros._integrate_plugins() diff --git a/airflow/alembic.ini b/airflow/alembic.ini new file mode 100644 index 0000000000000..6274d5ee86528 --- /dev/null +++ b/airflow/alembic.ini @@ -0,0 +1,80 @@ +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +# A generic, single database configuration. + +[alembic] +# path to migration scripts +script_location = migrations + +# template used to generate migration files +# file_template = %%(rev)s_%%(slug)s + +# max length of characters to apply to the +# "slug" field +#truncate_slug_length = 40 + +# set to 'true' to run the environment during +# the 'revision' command, regardless of autogenerate +# revision_environment = false + +# set to 'true' to allow .pyc and .pyo files without +# a source .py file to be detected as revisions in the +# versions/ directory +# sourceless = false + +# version location specification; this defaults +# to help/versions. When using multiple version +# directories, initial revisions must be specified with --version-path +# version_locations = %(here)s/bar %(here)s/bat help/versions + +# the output encoding used when revision files +# are written from script.py.mako +# output_encoding = utf-8 + +sqlalchemy.url = scheme://localhost/airflow + +# Logging configuration +[loggers] +keys = root,sqlalchemy,alembic + +[handlers] +keys = console + +[formatters] +keys = generic + +[logger_root] +level = WARN +handlers = console +qualname = + +[logger_sqlalchemy] +level = WARN +handlers = +qualname = sqlalchemy.engine + +[logger_alembic] +level = INFO +handlers = +qualname = alembic + +[handler_console] +class = StreamHandler +args = (sys.stderr,) +level = NOTSET +formatter = generic + +[formatter_generic] +format = %(levelname)-5.5s [%(name)s] %(message)s +datefmt = %H:%M:%S diff --git a/airflow/api/__init__.py b/airflow/api/__init__.py new file mode 100644 index 0000000000000..31a303b9e0708 --- /dev/null +++ b/airflow/api/__init__.py @@ -0,0 +1,42 @@ +# -*- coding: utf-8 -*- +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +from __future__ import print_function + +from airflow.exceptions import AirflowException +from airflow import configuration as conf +from importlib import import_module + +from airflow.utils.log.logging_mixin import LoggingMixin + +api_auth = None + +log = LoggingMixin().log + + +def load_auth(): + auth_backend = 'airflow.api.auth.backend.default' + try: + auth_backend = conf.get("api", "auth_backend") + except conf.AirflowConfigException: + pass + + try: + global api_auth + api_auth = import_module(auth_backend) + except ImportError as err: + log.critical( + "Cannot import %s for API authentication due to: %s", + auth_backend, err + ) + raise AirflowException(err) diff --git a/airflow/api/auth/__init__.py b/airflow/api/auth/__init__.py new file mode 100644 index 0000000000000..9d7677a99b293 --- /dev/null +++ b/airflow/api/auth/__init__.py @@ -0,0 +1,13 @@ +# -*- coding: utf-8 -*- +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. diff --git a/airflow/api/auth/backend/__init__.py b/airflow/api/auth/backend/__init__.py new file mode 100644 index 0000000000000..9d7677a99b293 --- /dev/null +++ b/airflow/api/auth/backend/__init__.py @@ -0,0 +1,13 @@ +# -*- coding: utf-8 -*- +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. diff --git a/airflow/api/auth/backend/default.py b/airflow/api/auth/backend/default.py new file mode 100644 index 0000000000000..49453ea4bdd43 --- /dev/null +++ b/airflow/api/auth/backend/default.py @@ -0,0 +1,29 @@ +# -*- coding: utf-8 -*- +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from functools import wraps + +client_auth = None + + +def init_app(app): + pass + + +def requires_authentication(function): + @wraps(function) + def decorated(*args, **kwargs): + return function(*args, **kwargs) + + return decorated diff --git a/airflow/api/auth/backend/kerberos_auth.py b/airflow/api/auth/backend/kerberos_auth.py new file mode 100644 index 0000000000000..a904d59d3dfd9 --- /dev/null +++ b/airflow/api/auth/backend/kerberos_auth.py @@ -0,0 +1,132 @@ +# Copyright (c) 2013, Michael Komitee +# All rights reserved. +# +# Redistribution and use in source and binary forms, with or without modification, +# are permitted provided that the following conditions are met: +# +# 1. Redistributions of source code must retain the above copyright notice, +# this list of conditions and the following disclaimer. +# +# 2. Redistributions in binary form must reproduce the above copyright notice, +# this list of conditions and the following disclaimer in the documentation +# and/or other materials provided with the distribution. +# +# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND +# ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +# WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +# DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR +# ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES +# (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; +# LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND +# ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +# (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS +# SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +from future.standard_library import install_aliases + +from airflow.utils.log.logging_mixin import LoggingMixin + +install_aliases() + +import kerberos +import os + +from airflow import configuration as conf + +from flask import Response +from flask import _request_ctx_stack as stack +from flask import make_response +from flask import request +from flask import g +from functools import wraps + +from requests_kerberos import HTTPKerberosAuth +from socket import getfqdn + +client_auth = HTTPKerberosAuth(service='airflow') + +_SERVICE_NAME = None + +log = LoggingMixin().log + + +def init_app(app): + global _SERVICE_NAME + + hostname = app.config.get('SERVER_NAME') + if not hostname: + hostname = getfqdn() + log.info("Kerberos: hostname %s", hostname) + + service = 'airflow' + + _SERVICE_NAME = "{}@{}".format(service, hostname) + + if 'KRB5_KTNAME' not in os.environ: + os.environ['KRB5_KTNAME'] = conf.get('kerberos', 'keytab') + + try: + log.info("Kerberos init: %s %s", service, hostname) + principal = kerberos.getServerPrincipalDetails(service, hostname) + except kerberos.KrbError as err: + log.warning("Kerberos: %s", err) + else: + log.info("Kerberos API: server is %s", principal) + + +def _unauthorized(): + """ + Indicate that authorization is required + :return: + """ + return Response("Unauthorized", 401, {"WWW-Authenticate": "Negotiate"}) + + +def _forbidden(): + return Response("Forbidden", 403) + + +def _gssapi_authenticate(token): + state = None + ctx = stack.top + try: + rc, state = kerberos.authGSSServerInit(_SERVICE_NAME) + if rc != kerberos.AUTH_GSS_COMPLETE: + return None + rc = kerberos.authGSSServerStep(state, token) + if rc == kerberos.AUTH_GSS_COMPLETE: + ctx.kerberos_token = kerberos.authGSSServerResponse(state) + ctx.kerberos_user = kerberos.authGSSServerUserName(state) + return rc + elif rc == kerberos.AUTH_GSS_CONTINUE: + return kerberos.AUTH_GSS_CONTINUE + else: + return None + except kerberos.GSSError: + return None + finally: + if state: + kerberos.authGSSServerClean(state) + + +def requires_authentication(function): + @wraps(function) + def decorated(*args, **kwargs): + header = request.headers.get("Authorization") + if header: + ctx = stack.top + token = ''.join(header.split()[1:]) + rc = _gssapi_authenticate(token) + if rc == kerberos.AUTH_GSS_COMPLETE: + g.user = ctx.kerberos_user + response = function(*args, **kwargs) + response = make_response(response) + if ctx.kerberos_token is not None: + response.headers['WWW-Authenticate'] = ' '.join(['negotiate', + ctx.kerberos_token]) + + return response + elif rc != kerberos.AUTH_GSS_CONTINUE: + return _forbidden() + return _unauthorized() + return decorated diff --git a/airflow/api/client/__init__.py b/airflow/api/client/__init__.py new file mode 100644 index 0000000000000..c82f5790fe906 --- /dev/null +++ b/airflow/api/client/__init__.py @@ -0,0 +1,14 @@ +# -*- coding: utf-8 -*- +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + diff --git a/airflow/api/client/api_client.py b/airflow/api/client/api_client.py new file mode 100644 index 0000000000000..f24d80945f3bd --- /dev/null +++ b/airflow/api/client/api_client.py @@ -0,0 +1,60 @@ +# -*- coding: utf-8 -*- +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + + +class Client(object): + """Base API client for all API clients.""" + + def __init__(self, api_base_url, auth): + self._api_base_url = api_base_url + self._auth = auth + + def trigger_dag(self, dag_id, run_id=None, conf=None, execution_date=None): + """Create a dag run for the specified dag. + + :param dag_id: + :param run_id: + :param conf: + :param execution_date: + :return: + """ + raise NotImplementedError() + + def get_pool(self, name): + """Get pool. + + :param name: pool name + """ + raise NotImplementedError() + + def get_pools(self): + """Get all pools.""" + raise NotImplementedError() + + def create_pool(self, name, slots, description): + """Create a pool. + + :param name: pool name + :param slots: pool slots amount + :param description: pool description + """ + raise NotImplementedError() + + def delete_pool(self, name): + """Delete pool. + + :param name: pool name + """ + raise NotImplementedError() diff --git a/airflow/api/client/json_client.py b/airflow/api/client/json_client.py new file mode 100644 index 0000000000000..37e24d3c4ecee --- /dev/null +++ b/airflow/api/client/json_client.py @@ -0,0 +1,80 @@ +# -*- coding: utf-8 -*- +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from future.moves.urllib.parse import urljoin +import requests + +from airflow.api.client import api_client + + +class Client(api_client.Client): + """Json API client implementation.""" + + def _request(self, url, method='GET', json=None): + params = { + 'url': url, + 'auth': self._auth, + } + if json is not None: + params['json'] = json + + resp = getattr(requests, method.lower())(**params) + if not resp.ok: + try: + data = resp.json() + except Exception: + data = {} + raise IOError(data.get('error', 'Server error')) + + return resp.json() + + def trigger_dag(self, dag_id, run_id=None, conf=None, execution_date=None): + endpoint = '/api/experimental/dags/{}/dag_runs'.format(dag_id) + url = urljoin(self._api_base_url, endpoint) + data = self._request(url, method='POST', + json={ + "run_id": run_id, + "conf": conf, + "execution_date": execution_date, + }) + return data['message'] + + def get_pool(self, name): + endpoint = '/api/experimental/pools/{}'.format(name) + url = urljoin(self._api_base_url, endpoint) + pool = self._request(url) + return pool['pool'], pool['slots'], pool['description'] + + def get_pools(self): + endpoint = '/api/experimental/pools' + url = urljoin(self._api_base_url, endpoint) + pools = self._request(url) + return [(p['pool'], p['slots'], p['description']) for p in pools] + + def create_pool(self, name, slots, description): + endpoint = '/api/experimental/pools' + url = urljoin(self._api_base_url, endpoint) + pool = self._request(url, method='POST', + json={ + 'name': name, + 'slots': slots, + 'description': description, + }) + return pool['pool'], pool['slots'], pool['description'] + + def delete_pool(self, name): + endpoint = '/api/experimental/pools/{}'.format(name) + url = urljoin(self._api_base_url, endpoint) + pool = self._request(url, method='DELETE') + return pool['pool'], pool['slots'], pool['description'] diff --git a/airflow/api/client/local_client.py b/airflow/api/client/local_client.py new file mode 100644 index 0000000000000..5bc7f76aaa095 --- /dev/null +++ b/airflow/api/client/local_client.py @@ -0,0 +1,43 @@ +# -*- coding: utf-8 -*- +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from airflow.api.client import api_client +from airflow.api.common.experimental import pool +from airflow.api.common.experimental import trigger_dag + + +class Client(api_client.Client): + """Local API client implementation.""" + + def trigger_dag(self, dag_id, run_id=None, conf=None, execution_date=None): + dr = trigger_dag.trigger_dag(dag_id=dag_id, + run_id=run_id, + conf=conf, + execution_date=execution_date) + return "Created {}".format(dr) + + def get_pool(self, name): + p = pool.get_pool(name=name) + return p.pool, p.slots, p.description + + def get_pools(self): + return [(p.pool, p.slots, p.description) for p in pool.get_pools()] + + def create_pool(self, name, slots, description): + p = pool.create_pool(name=name, slots=slots, description=description) + return p.pool, p.slots, p.description + + def delete_pool(self, name): + p = pool.delete_pool(name=name) + return p.pool, p.slots, p.description diff --git a/airflow/api/common/__init__.py b/airflow/api/common/__init__.py new file mode 100644 index 0000000000000..9d7677a99b293 --- /dev/null +++ b/airflow/api/common/__init__.py @@ -0,0 +1,13 @@ +# -*- coding: utf-8 -*- +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. diff --git a/airflow/api/common/experimental/__init__.py b/airflow/api/common/experimental/__init__.py new file mode 100644 index 0000000000000..9d7677a99b293 --- /dev/null +++ b/airflow/api/common/experimental/__init__.py @@ -0,0 +1,13 @@ +# -*- coding: utf-8 -*- +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. diff --git a/airflow/api/common/experimental/get_task.py b/airflow/api/common/experimental/get_task.py new file mode 100644 index 0000000000000..9023ad1f8d6c1 --- /dev/null +++ b/airflow/api/common/experimental/get_task.py @@ -0,0 +1,35 @@ +# -*- coding: utf-8 -*- +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from airflow.exceptions import AirflowException +from airflow.models import DagBag + + +def get_task(dag_id, task_id): + """Return the task object identified by the given dag_id and task_id.""" + dagbag = DagBag() + + # Check DAG exists. + if dag_id not in dagbag.dags: + error_message = "Dag id {} not found".format(dag_id) + raise AirflowException(error_message) + + # Get DAG object and check Task Exists + dag = dagbag.get_dag(dag_id) + if not dag.has_task(task_id): + error_message = 'Task {} not found in dag {}'.format(task_id, dag_id) + raise AirflowException(error_message) + + # Return the task. + return dag.get_task(task_id) diff --git a/airflow/api/common/experimental/get_task_instance.py b/airflow/api/common/experimental/get_task_instance.py new file mode 100644 index 0000000000000..7ab5e6e20f79b --- /dev/null +++ b/airflow/api/common/experimental/get_task_instance.py @@ -0,0 +1,49 @@ +# -*- coding: utf-8 -*- +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from airflow.exceptions import AirflowException +from airflow.models import DagBag + + +def get_task_instance(dag_id, task_id, execution_date): + """Return the task object identified by the given dag_id and task_id.""" + + dagbag = DagBag() + + # Check DAG exists. + if dag_id not in dagbag.dags: + error_message = "Dag id {} not found".format(dag_id) + raise AirflowException(error_message) + + # Get DAG object and check Task Exists + dag = dagbag.get_dag(dag_id) + if not dag.has_task(task_id): + error_message = 'Task {} not found in dag {}'.format(task_id, dag_id) + raise AirflowException(error_message) + + # Get DagRun object and check that it exists + dagrun = dag.get_dagrun(execution_date=execution_date) + if not dagrun: + error_message = ('Dag Run for date {} not found in dag {}' + .format(execution_date, dag_id)) + raise AirflowException(error_message) + + # Get task instance object and check that it exists + task_instance = dagrun.get_task_instance(task_id) + if not task_instance: + error_message = ('Task {} instance for date {} not found' + .format(task_id, execution_date)) + raise AirflowException(error_message) + + return task_instance diff --git a/airflow/api/common/experimental/mark_tasks.py b/airflow/api/common/experimental/mark_tasks.py new file mode 100644 index 0000000000000..e0ea313b337ab --- /dev/null +++ b/airflow/api/common/experimental/mark_tasks.py @@ -0,0 +1,218 @@ +# -*- coding: utf-8 -*- +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import datetime + +from airflow.jobs import BackfillJob +from airflow.models import DagRun, TaskInstance +from airflow.operators.subdag_operator import SubDagOperator +from airflow.settings import Session +from airflow.utils.state import State + +from sqlalchemy import or_ + +def _create_dagruns(dag, execution_dates, state, run_id_template): + """ + Infers from the dates which dag runs need to be created and does so. + :param dag: the dag to create dag runs for + :param execution_dates: list of execution dates to evaluate + :param state: the state to set the dag run to + :param run_id_template:the template for run id to be with the execution date + :return: newly created and existing dag runs for the execution dates supplied + """ + # find out if we need to create any dag runs + drs = DagRun.find(dag_id=dag.dag_id, execution_date=execution_dates) + dates_to_create = list(set(execution_dates) - set([dr.execution_date for dr in drs])) + + for date in dates_to_create: + dr = dag.create_dagrun( + run_id=run_id_template.format(date.isoformat()), + execution_date=date, + start_date=datetime.datetime.utcnow(), + external_trigger=False, + state=state, + ) + drs.append(dr) + + return drs + + +def set_state(task, execution_date, upstream=False, downstream=False, + future=False, past=False, state=State.SUCCESS, commit=False): + """ + Set the state of a task instance and if needed its relatives. Can set state + for future tasks (calculated from execution_date) and retroactively + for past tasks. Will verify integrity of past dag runs in order to create + tasks that did not exist. It will not create dag runs that are missing + on the schedule (but it will as for subdag dag runs if needed). + :param task: the task from which to work. task.task.dag needs to be set + :param execution_date: the execution date from which to start looking + :param upstream: Mark all parents (upstream tasks) + :param downstream: Mark all siblings (downstream tasks) of task_id, including SubDags + :param future: Mark all future tasks on the interval of the dag up until + last execution date. + :param past: Retroactively mark all tasks starting from start_date of the DAG + :param state: State to which the tasks need to be set + :param commit: Commit tasks to be altered to the database + :return: list of tasks that have been created and updated + """ + assert isinstance(execution_date, datetime.datetime) + + # microseconds are supported by the database, but is not handled + # correctly by airflow on e.g. the filesystem and in other places + execution_date = execution_date.replace(microsecond=0) + + assert task.dag is not None + dag = task.dag + + latest_execution_date = dag.latest_execution_date + assert latest_execution_date is not None + + # determine date range of dag runs and tasks to consider + end_date = latest_execution_date if future else execution_date + + if 'start_date' in dag.default_args: + start_date = dag.default_args['start_date'] + elif dag.start_date: + start_date = dag.start_date + else: + start_date = execution_date + + start_date = execution_date if not past else start_date + + if dag.schedule_interval == '@once': + dates = [start_date] + else: + dates = dag.date_range(start_date=start_date, end_date=end_date) + + # find relatives (siblings = downstream, parents = upstream) if needed + task_ids = [task.task_id] + if downstream: + relatives = task.get_flat_relatives(upstream=False) + task_ids += [t.task_id for t in relatives] + if upstream: + relatives = task.get_flat_relatives(upstream=True) + task_ids += [t.task_id for t in relatives] + + # verify the integrity of the dag runs in case a task was added or removed + # set the confirmed execution dates as they might be different + # from what was provided + confirmed_dates = [] + drs = DagRun.find(dag_id=dag.dag_id, execution_date=dates) + for dr in drs: + dr.dag = dag + dr.verify_integrity() + confirmed_dates.append(dr.execution_date) + + # go through subdagoperators and create dag runs. We will only work + # within the scope of the subdag. We wont propagate to the parent dag, + # but we will propagate from parent to subdag. + session = Session() + dags = [dag] + sub_dag_ids = [] + while len(dags) > 0: + current_dag = dags.pop() + for task_id in task_ids: + if not current_dag.has_task(task_id): + continue + + current_task = current_dag.get_task(task_id) + if isinstance(current_task, SubDagOperator): + # this works as a kind of integrity check + # it creates missing dag runs for subdagoperators, + # maybe this should be moved to dagrun.verify_integrity + drs = _create_dagruns(current_task.subdag, + execution_dates=confirmed_dates, + state=State.RUNNING, + run_id_template=BackfillJob.ID_FORMAT_PREFIX) + + for dr in drs: + dr.dag = current_task.subdag + dr.verify_integrity() + if commit: + dr.state = state + session.merge(dr) + + dags.append(current_task.subdag) + sub_dag_ids.append(current_task.subdag.dag_id) + + # now look for the task instances that are affected + TI = TaskInstance + + # get all tasks of the main dag that will be affected by a state change + qry_dag = session.query(TI).filter( + TI.dag_id==dag.dag_id, + TI.execution_date.in_(confirmed_dates), + TI.task_id.in_(task_ids)).filter( + or_(TI.state.is_(None), + TI.state != state) + ) + + # get *all* tasks of the sub dags + if len(sub_dag_ids) > 0: + qry_sub_dag = session.query(TI).filter( + TI.dag_id.in_(sub_dag_ids), + TI.execution_date.in_(confirmed_dates)).filter( + or_(TI.state.is_(None), + TI.state != state) + ) + + if commit: + tis_altered = qry_dag.with_for_update().all() + if len(sub_dag_ids) > 0: + tis_altered += qry_sub_dag.with_for_update().all() + for ti in tis_altered: + ti.state = state + session.commit() + else: + tis_altered = qry_dag.all() + if len(sub_dag_ids) > 0: + tis_altered += qry_sub_dag.all() + + session.expunge_all() + session.close() + + return tis_altered + +def set_dag_run_state(dag, execution_date, state=State.SUCCESS, commit=False): + """ + Set the state of a dag run and all task instances associated with the dag + run for a specific execution date. + :param dag: the DAG of which to alter state + :param execution_date: the execution date from which to start looking + :param state: the state to which the DAG need to be set + :param commit: commit DAG and tasks to be altered to the database + :return: list of tasks that have been created and updated + :raises: AssertionError if dag or execution_date is invalid + """ + res = [] + + if not dag or not execution_date: + return res + + # Mark all task instances in the dag run + for task in dag.tasks: + task.dag = dag + new_state = set_state(task=task, execution_date=execution_date, + state=state, commit=commit) + res.extend(new_state) + + # Mark the dag run + if commit: + drs = DagRun.find(dag.dag_id, execution_date=execution_date) + for dr in drs: + dr.dag = dag + dr.update_state() + + return res diff --git a/airflow/api/common/experimental/pool.py b/airflow/api/common/experimental/pool.py new file mode 100644 index 0000000000000..6e963a2fd2e3a --- /dev/null +++ b/airflow/api/common/experimental/pool.py @@ -0,0 +1,85 @@ +# -*- coding: utf-8 -*- +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from airflow.exceptions import AirflowException +from airflow.models import Pool +from airflow.utils.db import provide_session + + +class PoolBadRequest(AirflowException): + status = 400 + + +class PoolNotFound(AirflowException): + status = 404 + + +@provide_session +def get_pool(name, session=None): + """Get pool by a given name.""" + if not (name and name.strip()): + raise PoolBadRequest("Pool name shouldn't be empty") + + pool = session.query(Pool).filter_by(pool=name).first() + if pool is None: + raise PoolNotFound("Pool '%s' doesn't exist" % name) + + return pool + + +@provide_session +def get_pools(session=None): + """Get all pools.""" + return session.query(Pool).all() + + +@provide_session +def create_pool(name, slots, description, session=None): + """Create a pool with a given parameters.""" + if not (name and name.strip()): + raise PoolBadRequest("Pool name shouldn't be empty") + + try: + slots = int(slots) + except ValueError: + raise PoolBadRequest("Bad value for `slots`: %s" % slots) + + session.expire_on_commit = False + pool = session.query(Pool).filter_by(pool=name).first() + if pool is None: + pool = Pool(pool=name, slots=slots, description=description) + session.add(pool) + else: + pool.slots = slots + pool.description = description + + session.commit() + + return pool + + +@provide_session +def delete_pool(name, session=None): + """Delete pool by a given name.""" + if not (name and name.strip()): + raise PoolBadRequest("Pool name shouldn't be empty") + + pool = session.query(Pool).filter_by(pool=name).first() + if pool is None: + raise PoolNotFound("Pool '%s' doesn't exist" % name) + + session.delete(pool) + session.commit() + + return pool diff --git a/airflow/api/common/experimental/trigger_dag.py b/airflow/api/common/experimental/trigger_dag.py new file mode 100644 index 0000000000000..bfb6ad47c177a --- /dev/null +++ b/airflow/api/common/experimental/trigger_dag.py @@ -0,0 +1,59 @@ +# -*- coding: utf-8 -*- +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import datetime +import json + +from airflow.exceptions import AirflowException +from airflow.models import DagRun, DagBag +from airflow.utils.state import State + + +def trigger_dag(dag_id, run_id=None, conf=None, execution_date=None): + dagbag = DagBag() + + if dag_id not in dagbag.dags: + raise AirflowException("Dag id {} not found".format(dag_id)) + + dag = dagbag.get_dag(dag_id) + + if not execution_date: + execution_date = datetime.datetime.utcnow() + + assert isinstance(execution_date, datetime.datetime) + execution_date = execution_date.replace(microsecond=0) + + if not run_id: + run_id = "manual__{0}".format(execution_date.isoformat()) + + dr = DagRun.find(dag_id=dag_id, run_id=run_id) + if dr: + raise AirflowException("Run id {} already exists for dag id {}".format( + run_id, + dag_id + )) + + run_conf = None + if conf: + run_conf = json.loads(conf) + + trigger = dag.create_dagrun( + run_id=run_id, + execution_date=execution_date, + state=State.RUNNING, + conf=run_conf, + external_trigger=True + ) + + return trigger diff --git a/airflow/bin/__init__.py b/airflow/bin/__init__.py new file mode 100644 index 0000000000000..c82f5790fe906 --- /dev/null +++ b/airflow/bin/__init__.py @@ -0,0 +1,14 @@ +# -*- coding: utf-8 -*- +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + diff --git a/airflow/bin/airflow b/airflow/bin/airflow new file mode 100755 index 0000000000000..2c0024d24063a --- /dev/null +++ b/airflow/bin/airflow @@ -0,0 +1,27 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +import os +from airflow import configuration +from airflow.bin.cli import CLIFactory + +if __name__ == '__main__': + + if configuration.get("core", "security") == 'kerberos': + os.environ['KRB5CCNAME'] = configuration.get('kerberos', 'ccache') + os.environ['KRB5_KTNAME'] = configuration.get('kerberos', 'keytab') + + parser = CLIFactory.get_parser() + args = parser.parse_args() + args.func(args) diff --git a/airflow/bin/airflow_scheduler_autorestart.sh b/airflow/bin/airflow_scheduler_autorestart.sh new file mode 100755 index 0000000000000..910404d39b47f --- /dev/null +++ b/airflow/bin/airflow_scheduler_autorestart.sh @@ -0,0 +1,19 @@ +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +while echo "Running"; do + airflow scheduler -n 5 + echo "Scheduler crashed with exit code $?. Respawning.." >&2 + date >> /tmp/airflow_scheduler_errors.txt + sleep 1 +done diff --git a/airflow/bin/cli.py b/airflow/bin/cli.py new file mode 100755 index 0000000000000..3943aeb5b69cb --- /dev/null +++ b/airflow/bin/cli.py @@ -0,0 +1,1638 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from __future__ import print_function +import logging + +import reprlib + +import os +import socket +import subprocess +import textwrap +from importlib import import_module + +import argparse +from builtins import input +from collections import namedtuple +from dateutil.parser import parse as parsedate +import json +from tabulate import tabulate + +import daemon +from daemon.pidfile import TimeoutPIDLockFile +import signal +import sys +import threading +import traceback +import time +import psutil +import re +from urllib.parse import urlunparse + +import airflow +from airflow import api +from airflow import jobs, settings +from airflow import configuration as conf +from airflow.exceptions import AirflowException +from airflow.executors import GetDefaultExecutor +from airflow.models import (DagModel, DagBag, TaskInstance, + DagPickle, DagRun, Variable, DagStat, + Connection, DAG) + +from airflow.ti_deps.dep_context import (DepContext, SCHEDULER_DEPS) +from airflow.utils import db as db_utils +from airflow.utils.log.logging_mixin import LoggingMixin +from airflow.www.app import cached_app + +from sqlalchemy import func +from sqlalchemy.orm import exc + +api.load_auth() +api_module = import_module(conf.get('cli', 'api_client')) +api_client = api_module.Client(api_base_url=conf.get('cli', 'endpoint_url'), + auth=api.api_auth.client_auth) + +log = LoggingMixin().log + + +def sigint_handler(sig, frame): + sys.exit(0) + + +def sigquit_handler(sig, frame): + """Helps debug deadlocks by printing stacktraces when this gets a SIGQUIT + e.g. kill -s QUIT or CTRL+\ + """ + print("Dumping stack traces for all threads in PID {}".format(os.getpid())) + id_to_name = dict([(th.ident, th.name) for th in threading.enumerate()]) + code = [] + for thread_id, stack in sys._current_frames().items(): + code.append("\n# Thread: {}({})" + .format(id_to_name.get(thread_id, ""), thread_id)) + for filename, line_number, name, line in traceback.extract_stack(stack): + code.append('File: "{}", line {}, in {}' + .format(filename, line_number, name)) + if line: + code.append(" {}".format(line.strip())) + print("\n".join(code)) + + +def setup_logging(filename): + root = logging.getLogger() + handler = logging.FileHandler(filename) + formatter = logging.Formatter(settings.SIMPLE_LOG_FORMAT) + handler.setFormatter(formatter) + root.addHandler(handler) + root.setLevel(settings.LOGGING_LEVEL) + + return handler.stream + + +def setup_locations(process, pid=None, stdout=None, stderr=None, log=None): + if not stderr: + stderr = os.path.join(os.path.expanduser(settings.AIRFLOW_HOME), "airflow-{}.err".format(process)) + if not stdout: + stdout = os.path.join(os.path.expanduser(settings.AIRFLOW_HOME), "airflow-{}.out".format(process)) + if not log: + log = os.path.join(os.path.expanduser(settings.AIRFLOW_HOME), "airflow-{}.log".format(process)) + if not pid: + pid = os.path.join(os.path.expanduser(settings.AIRFLOW_HOME), "airflow-{}.pid".format(process)) + + return pid, stdout, stderr, log + + +def process_subdir(subdir): + if subdir: + subdir = subdir.replace('DAGS_FOLDER', settings.DAGS_FOLDER) + subdir = os.path.abspath(os.path.expanduser(subdir)) + return subdir + + +def get_dag(args): + dagbag = DagBag(process_subdir(args.subdir)) + if args.dag_id not in dagbag.dags: + raise AirflowException( + 'dag_id could not be found: {}. Either the dag did not exist or it failed to ' + 'parse.'.format(args.dag_id)) + return dagbag.dags[args.dag_id] + + +def get_dags(args): + if not args.dag_regex: + return [get_dag(args)] + dagbag = DagBag(process_subdir(args.subdir)) + matched_dags = [dag for dag in dagbag.dags.values() if re.search( + args.dag_id, dag.dag_id)] + if not matched_dags: + raise AirflowException( + 'dag_id could not be found with regex: {}. Either the dag did not exist ' + 'or it failed to parse.'.format(args.dag_id)) + return matched_dags + + +def backfill(args, dag=None): + logging.basicConfig( + level=settings.LOGGING_LEVEL, + format=settings.SIMPLE_LOG_FORMAT) + + dag = dag or get_dag(args) + + if not args.start_date and not args.end_date: + raise AirflowException("Provide a start_date and/or end_date") + + # If only one date is passed, using same as start and end + args.end_date = args.end_date or args.start_date + args.start_date = args.start_date or args.end_date + + if args.task_regex: + dag = dag.sub_dag( + task_regex=args.task_regex, + include_upstream=not args.ignore_dependencies) + + if args.dry_run: + print("Dry run of DAG {0} on {1}".format(args.dag_id, + args.start_date)) + for task in dag.tasks: + print("Task {0}".format(task.task_id)) + ti = TaskInstance(task, args.start_date) + ti.dry_run() + else: + dag.run( + start_date=args.start_date, + end_date=args.end_date, + mark_success=args.mark_success, + include_adhoc=args.include_adhoc, + local=args.local, + donot_pickle=(args.donot_pickle or + conf.getboolean('core', 'donot_pickle')), + ignore_first_depends_on_past=args.ignore_first_depends_on_past, + ignore_task_deps=args.ignore_dependencies, + pool=args.pool, + delay_on_limit_secs=args.delay_on_limit) + + +def trigger_dag(args): + """ + Creates a dag run for the specified dag + :param args: + :return: + """ + log = LoggingMixin().log + try: + message = api_client.trigger_dag(dag_id=args.dag_id, + run_id=args.run_id, + conf=args.conf, + execution_date=args.exec_date) + except IOError as err: + log.error(err) + raise AirflowException(err) + log.info(message) + + +def pool(args): + log = LoggingMixin().log + + def _tabulate(pools): + return "\n%s" % tabulate(pools, ['Pool', 'Slots', 'Description'], + tablefmt="fancy_grid") + + try: + if args.get is not None: + pools = [api_client.get_pool(name=args.get)] + elif args.set: + pools = [api_client.create_pool(name=args.set[0], + slots=args.set[1], + description=args.set[2])] + elif args.delete: + pools = [api_client.delete_pool(name=args.delete)] + else: + pools = api_client.get_pools() + except (AirflowException, IOError) as err: + log.error(err) + else: + log.info(_tabulate(pools=pools)) + + +def variables(args): + if args.get: + try: + var = Variable.get(args.get, + deserialize_json=args.json, + default_var=args.default) + print(var) + except ValueError as e: + print(e) + if args.delete: + session = settings.Session() + session.query(Variable).filter_by(key=args.delete).delete() + session.commit() + session.close() + if args.set: + Variable.set(args.set[0], args.set[1]) + # Work around 'import' as a reserved keyword + imp = getattr(args, 'import') + if imp: + if os.path.exists(imp): + import_helper(imp) + else: + print("Missing variables file.") + if args.export: + export_helper(args.export) + if not (args.set or args.get or imp or args.export or args.delete): + # list all variables + session = settings.Session() + vars = session.query(Variable) + msg = "\n".join(var.key for var in vars) + print(msg) + + +def import_helper(filepath): + with open(filepath, 'r') as varfile: + var = varfile.read() + + try: + d = json.loads(var) + except Exception: + print("Invalid variables file.") + else: + try: + n = 0 + for k, v in d.items(): + if isinstance(v, dict): + Variable.set(k, v, serialize_json=True) + else: + Variable.set(k, v) + n += 1 + except Exception: + pass + finally: + print("{} of {} variables successfully updated.".format(n, len(d))) + + +def export_helper(filepath): + session = settings.Session() + qry = session.query(Variable).all() + session.close() + + var_dict = {} + d = json.JSONDecoder() + for var in qry: + val = None + try: + val = d.decode(var.val) + except Exception: + val = var.val + var_dict[var.key] = val + + with open(filepath, 'w') as varfile: + varfile.write(json.dumps(var_dict, sort_keys=True, indent=4)) + print("{} variables successfully exported to {}".format(len(var_dict), filepath)) + + +def pause(args, dag=None): + set_is_paused(True, args, dag) + + +def unpause(args, dag=None): + set_is_paused(False, args, dag) + + +def set_is_paused(is_paused, args, dag=None): + dag = dag or get_dag(args) + + session = settings.Session() + dm = session.query(DagModel).filter( + DagModel.dag_id == dag.dag_id).first() + dm.is_paused = is_paused + session.commit() + + msg = "Dag: {}, paused: {}".format(dag, str(dag.is_paused)) + print(msg) + + +def run(args, dag=None): + # Disable connection pooling to reduce the # of connections on the DB + # while it's waiting for the task to finish. + settings.configure_orm(disable_connection_pool=True) + db_utils.pessimistic_connection_handling() + if dag: + args.dag_id = dag.dag_id + + log = LoggingMixin().log + + # Load custom airflow config + if args.cfg_path: + with open(args.cfg_path, 'r') as conf_file: + conf_dict = json.load(conf_file) + + if os.path.exists(args.cfg_path): + os.remove(args.cfg_path) + + for section, config in conf_dict.items(): + for option, value in config.items(): + conf.set(section, option, value) + settings.configure_vars() + settings.configure_orm() + + if not args.pickle and not dag: + dag = get_dag(args) + elif not dag: + session = settings.Session() + log.info('Loading pickle id {args.pickle}'.format(args=args)) + dag_pickle = session.query( + DagPickle).filter(DagPickle.id == args.pickle).first() + if not dag_pickle: + raise AirflowException("Who hid the pickle!? [missing pickle]") + dag = dag_pickle.pickle + + task = dag.get_task(task_id=args.task_id) + ti = TaskInstance(task, args.execution_date) + ti.refresh_from_db() + + log = logging.getLogger('airflow.task') + if args.raw: + log = logging.getLogger('airflow.task.raw') + + for handler in log.handlers: + try: + handler.set_context(ti) + except AttributeError: + # Not all handlers need to have context passed in so we ignore + # the error when handlers do not have set_context defined. + pass + + hostname = socket.getfqdn() + log.info("Running on host %s", hostname) + + if args.local: + run_job = jobs.LocalTaskJob( + task_instance=ti, + mark_success=args.mark_success, + pickle_id=args.pickle, + ignore_all_deps=args.ignore_all_dependencies, + ignore_depends_on_past=args.ignore_depends_on_past, + ignore_task_deps=args.ignore_dependencies, + ignore_ti_state=args.force, + pool=args.pool) + run_job.run() + elif args.raw: + ti._run_raw_task( + mark_success=args.mark_success, + job_id=args.job_id, + pool=args.pool, + ) + else: + pickle_id = None + if args.ship_dag: + try: + # Running remotely, so pickling the DAG + session = settings.Session() + pickle = DagPickle(dag) + session.add(pickle) + session.commit() + pickle_id = pickle.id + # TODO: This should be written to a log + print(( + 'Pickled dag {dag} ' + 'as pickle_id:{pickle_id}').format(**locals())) + except Exception as e: + print('Could not pickle the DAG') + print(e) + raise e + + executor = GetDefaultExecutor() + executor.start() + print("Sending to executor.") + executor.queue_task_instance( + ti, + mark_success=args.mark_success, + pickle_id=pickle_id, + ignore_all_deps=args.ignore_all_dependencies, + ignore_depends_on_past=args.ignore_depends_on_past, + ignore_task_deps=args.ignore_dependencies, + ignore_ti_state=args.force, + pool=args.pool) + executor.heartbeat() + executor.end() + + # Child processes should not flush or upload to remote + if args.raw: + return + + # Force the log to flush. The flush is important because we + # might subsequently read from the log to insert into S3 or + # Google cloud storage. Explicitly close the handler is + # needed in order to upload to remote storage services. + for handler in log.handlers: + handler.flush() + handler.close() + + +def task_failed_deps(args): + """ + Returns the unmet dependencies for a task instance from the perspective of the + scheduler (i.e. why a task instance doesn't get scheduled and then queued by the + scheduler, and then run by an executor). + + >>> airflow task_failed_deps tutorial sleep 2015-01-01 + Task instance dependencies not met: + Dagrun Running: Task instance's dagrun did not exist: Unknown reason + Trigger Rule: Task's trigger rule 'all_success' requires all upstream tasks to have succeeded, but found 1 non-success(es). + """ + dag = get_dag(args) + task = dag.get_task(task_id=args.task_id) + ti = TaskInstance(task, args.execution_date) + + dep_context = DepContext(deps=SCHEDULER_DEPS) + failed_deps = list(ti.get_failed_dep_statuses(dep_context=dep_context)) + # TODO, Do we want to print or log this + if failed_deps: + print("Task instance dependencies not met:") + for dep in failed_deps: + print("{}: {}".format(dep.dep_name, dep.reason)) + else: + print("Task instance dependencies are all met.") + + +def task_state(args): + """ + Returns the state of a TaskInstance at the command line. + + >>> airflow task_state tutorial sleep 2015-01-01 + success + """ + dag = get_dag(args) + task = dag.get_task(task_id=args.task_id) + ti = TaskInstance(task, args.execution_date) + print(ti.current_state()) + + +def dag_state(args): + """ + Returns the state of a DagRun at the command line. + + >>> airflow dag_state tutorial 2015-01-01T00:00:00.000000 + running + """ + dag = get_dag(args) + dr = DagRun.find(dag.dag_id, execution_date=args.execution_date) + print(dr[0].state if len(dr) > 0 else None) + + +def list_dags(args): + dagbag = DagBag(process_subdir(args.subdir)) + s = textwrap.dedent("""\n + ------------------------------------------------------------------- + DAGS + ------------------------------------------------------------------- + {dag_list} + """) + dag_list = "\n".join(sorted(dagbag.dags)) + print(s.format(dag_list=dag_list)) + if args.report: + print(dagbag.dagbag_report()) + + +def list_tasks(args, dag=None): + dag = dag or get_dag(args) + if args.tree: + dag.tree_view() + else: + tasks = sorted([t.task_id for t in dag.tasks]) + print("\n".join(sorted(tasks))) + + +def test(args, dag=None): + dag = dag or get_dag(args) + + task = dag.get_task(task_id=args.task_id) + # Add CLI provided task_params to task.params + if args.task_params: + passed_in_params = json.loads(args.task_params) + task.params.update(passed_in_params) + ti = TaskInstance(task, args.execution_date) + + if args.dry_run: + ti.dry_run() + else: + ti.run(ignore_task_deps=True, ignore_ti_state=True, test_mode=True) + + +def render(args): + dag = get_dag(args) + task = dag.get_task(task_id=args.task_id) + ti = TaskInstance(task, args.execution_date) + ti.render_templates() + for attr in task.__class__.template_fields: + print(textwrap.dedent("""\ + # ---------------------------------------------------------- + # property: {} + # ---------------------------------------------------------- + {} + """.format(attr, getattr(task, attr)))) + + +def clear(args): + logging.basicConfig( + level=settings.LOGGING_LEVEL, + format=settings.SIMPLE_LOG_FORMAT) + dags = get_dags(args) + + if args.task_regex: + for idx, dag in enumerate(dags): + dags[idx] = dag.sub_dag( + task_regex=args.task_regex, + include_downstream=args.downstream, + include_upstream=args.upstream) + + DAG.clear_dags( + dags, + start_date=args.start_date, + end_date=args.end_date, + only_failed=args.only_failed, + only_running=args.only_running, + confirm_prompt=not args.no_confirm, + include_subdags=not args.exclude_subdags) + + +def restart_workers(gunicorn_master_proc, num_workers_expected): + """ + Runs forever, monitoring the child processes of @gunicorn_master_proc and + restarting workers occasionally. + + Each iteration of the loop traverses one edge of this state transition + diagram, where each state (node) represents + [ num_ready_workers_running / num_workers_running ]. We expect most time to + be spent in [n / n]. `bs` is the setting webserver.worker_refresh_batch_size. + + The horizontal transition at ? happens after the new worker parses all the + dags (so it could take a while!) + + V ────────────────────────────────────────────────────────────────────────┐ + [n / n] ──TTIN──> [ [n, n+bs) / n + bs ] ────?───> [n + bs / n + bs] ──TTOU─┘ + ^ ^───────────────┘ + │ + │ ┌────────────────v + └──────┴────── [ [0, n) / n ] <─── start + + We change the number of workers by sending TTIN and TTOU to the gunicorn + master process, which increases and decreases the number of child workers + respectively. Gunicorn guarantees that on TTOU workers are terminated + gracefully and that the oldest worker is terminated. + """ + + def wait_until_true(fn): + """ + Sleeps until fn is true + """ + while not fn(): + time.sleep(0.1) + + def get_num_workers_running(gunicorn_master_proc): + workers = psutil.Process(gunicorn_master_proc.pid).children() + return len(workers) + + def get_num_ready_workers_running(gunicorn_master_proc): + workers = psutil.Process(gunicorn_master_proc.pid).children() + ready_workers = [ + proc for proc in workers + if settings.GUNICORN_WORKER_READY_PREFIX in proc.cmdline()[0] + ] + return len(ready_workers) + + def start_refresh(gunicorn_master_proc): + batch_size = conf.getint('webserver', 'worker_refresh_batch_size') + log.debug('%s doing a refresh of %s workers', state, batch_size) + sys.stdout.flush() + sys.stderr.flush() + + excess = 0 + for _ in range(batch_size): + gunicorn_master_proc.send_signal(signal.SIGTTIN) + excess += 1 + wait_until_true(lambda: num_workers_expected + excess == + get_num_workers_running(gunicorn_master_proc)) + + wait_until_true(lambda: num_workers_expected == + get_num_workers_running(gunicorn_master_proc)) + + while True: + num_workers_running = get_num_workers_running(gunicorn_master_proc) + num_ready_workers_running = get_num_ready_workers_running(gunicorn_master_proc) + + state = '[{0} / {1}]'.format(num_ready_workers_running, num_workers_running) + + # Whenever some workers are not ready, wait until all workers are ready + if num_ready_workers_running < num_workers_running: + log.debug('%s some workers are starting up, waiting...', state) + sys.stdout.flush() + time.sleep(1) + + # Kill a worker gracefully by asking gunicorn to reduce number of workers + elif num_workers_running > num_workers_expected: + excess = num_workers_running - num_workers_expected + log.debug('%s killing %s workers', state, excess) + + for _ in range(excess): + gunicorn_master_proc.send_signal(signal.SIGTTOU) + excess -= 1 + wait_until_true(lambda: num_workers_expected + excess == + get_num_workers_running(gunicorn_master_proc)) + + # Start a new worker by asking gunicorn to increase number of workers + elif num_workers_running == num_workers_expected: + refresh_interval = conf.getint('webserver', 'worker_refresh_interval') + log.debug( + '%s sleeping for %ss starting doing a refresh...', + state, refresh_interval + ) + time.sleep(refresh_interval) + start_refresh(gunicorn_master_proc) + + else: + # num_ready_workers_running == num_workers_running < num_workers_expected + log.error(( + "%s some workers seem to have died and gunicorn" + "did not restart them as expected" + ), state) + time.sleep(10) + if len( + psutil.Process(gunicorn_master_proc.pid).children() + ) < num_workers_expected: + start_refresh(gunicorn_master_proc) + + +def webserver(args): + print(settings.HEADER) + + app = cached_app(conf) + access_logfile = args.access_logfile or conf.get('webserver', 'access_logfile') + error_logfile = args.error_logfile or conf.get('webserver', 'error_logfile') + num_workers = args.workers or conf.get('webserver', 'workers') + worker_timeout = (args.worker_timeout or + conf.get('webserver', 'web_server_worker_timeout')) + ssl_cert = args.ssl_cert or conf.get('webserver', 'web_server_ssl_cert') + ssl_key = args.ssl_key or conf.get('webserver', 'web_server_ssl_key') + if not ssl_cert and ssl_key: + raise AirflowException( + 'An SSL certificate must also be provided for use with ' + ssl_key) + if ssl_cert and not ssl_key: + raise AirflowException( + 'An SSL key must also be provided for use with ' + ssl_cert) + + if args.debug: + print( + "Starting the web server on port {0} and host {1}.".format( + args.port, args.hostname)) + app.run(debug=True, port=args.port, host=args.hostname, + ssl_context=(ssl_cert, ssl_key) if ssl_cert and ssl_key else None) + else: + pid, stdout, stderr, log_file = setup_locations("webserver", args.pid, args.stdout, args.stderr, args.log_file) + if args.daemon: + handle = setup_logging(log_file) + stdout = open(stdout, 'w+') + stderr = open(stderr, 'w+') + + print( + textwrap.dedent('''\ + Running the Gunicorn Server with: + Workers: {num_workers} {args.workerclass} + Host: {args.hostname}:{args.port} + Timeout: {worker_timeout} + Logfiles: {access_logfile} {error_logfile} + =================================================================\ + '''.format(**locals()))) + + run_args = [ + 'gunicorn', + '-w', str(num_workers), + '-k', str(args.workerclass), + '-t', str(worker_timeout), + '-b', args.hostname + ':' + str(args.port), + '-n', 'airflow-webserver', + '-p', str(pid), + '-c', 'airflow.www.gunicorn_config' + ] + + if args.access_logfile: + run_args += ['--access-logfile', str(args.access_logfile)] + + if args.error_logfile: + run_args += ['--error-logfile', str(args.error_logfile)] + + if args.daemon: + run_args += ['-D'] + + if ssl_cert: + run_args += ['--certfile', ssl_cert, '--keyfile', ssl_key] + + run_args += ["airflow.www.app:cached_app()"] + + gunicorn_master_proc = None + + def kill_proc(dummy_signum, dummy_frame): + gunicorn_master_proc.terminate() + gunicorn_master_proc.wait() + sys.exit(0) + + def monitor_gunicorn(gunicorn_master_proc): + # These run forever until SIG{INT, TERM, KILL, ...} signal is sent + if conf.getint('webserver', 'worker_refresh_interval') > 0: + restart_workers(gunicorn_master_proc, num_workers) + else: + while True: + time.sleep(1) + + if args.daemon: + base, ext = os.path.splitext(pid) + ctx = daemon.DaemonContext( + pidfile=TimeoutPIDLockFile(base + "-monitor" + ext, -1), + files_preserve=[handle], + stdout=stdout, + stderr=stderr, + signal_map={ + signal.SIGINT: kill_proc, + signal.SIGTERM: kill_proc + }, + ) + with ctx: + subprocess.Popen(run_args) + + # Reading pid file directly, since Popen#pid doesn't + # seem to return the right value with DaemonContext. + while True: + try: + with open(pid) as f: + gunicorn_master_proc_pid = int(f.read()) + break + except IOError: + log.debug("Waiting for gunicorn's pid file to be created.") + time.sleep(0.1) + + gunicorn_master_proc = psutil.Process(gunicorn_master_proc_pid) + monitor_gunicorn(gunicorn_master_proc) + + stdout.close() + stderr.close() + else: + gunicorn_master_proc = subprocess.Popen(run_args) + + signal.signal(signal.SIGINT, kill_proc) + signal.signal(signal.SIGTERM, kill_proc) + + monitor_gunicorn(gunicorn_master_proc) + + +def scheduler(args): + print(settings.HEADER) + job = jobs.SchedulerJob( + dag_id=args.dag_id, + subdir=process_subdir(args.subdir), + run_duration=args.run_duration, + num_runs=args.num_runs, + do_pickle=args.do_pickle) + + if args.daemon: + pid, stdout, stderr, log_file = setup_locations("scheduler", args.pid, args.stdout, args.stderr, args.log_file) + handle = setup_logging(log_file) + stdout = open(stdout, 'w+') + stderr = open(stderr, 'w+') + + ctx = daemon.DaemonContext( + pidfile=TimeoutPIDLockFile(pid, -1), + files_preserve=[handle], + stdout=stdout, + stderr=stderr, + ) + with ctx: + job.run() + + stdout.close() + stderr.close() + else: + signal.signal(signal.SIGINT, sigint_handler) + signal.signal(signal.SIGTERM, sigint_handler) + signal.signal(signal.SIGQUIT, sigquit_handler) + job.run() + + +def serve_logs(args): + print("Starting flask") + import flask + flask_app = flask.Flask(__name__) + + @flask_app.route('/log/') + def serve_logs(filename): # noqa + log = os.path.expanduser(conf.get('core', 'BASE_LOG_FOLDER')) + return flask.send_from_directory( + log, + filename, + mimetype="application/json", + as_attachment=False) + + WORKER_LOG_SERVER_PORT = \ + int(conf.get('celery', 'WORKER_LOG_SERVER_PORT')) + flask_app.run( + host='0.0.0.0', port=WORKER_LOG_SERVER_PORT) + + +def worker(args): + env = os.environ.copy() + env['AIRFLOW_HOME'] = settings.AIRFLOW_HOME + + # Celery worker + from airflow.executors.celery_executor import app as celery_app + from celery.bin import worker + + worker = worker.worker(app=celery_app) + options = { + 'optimization': 'fair', + 'O': 'fair', + 'queues': args.queues, + 'concurrency': args.concurrency, + 'hostname': args.celery_hostname, + } + + if args.daemon: + pid, stdout, stderr, log_file = setup_locations("worker", args.pid, args.stdout, args.stderr, args.log_file) + handle = setup_logging(log_file) + stdout = open(stdout, 'w+') + stderr = open(stderr, 'w+') + + ctx = daemon.DaemonContext( + pidfile=TimeoutPIDLockFile(pid, -1), + files_preserve=[handle], + stdout=stdout, + stderr=stderr, + ) + with ctx: + sp = subprocess.Popen(['airflow', 'serve_logs'], env=env) + worker.run(**options) + sp.kill() + + stdout.close() + stderr.close() + else: + signal.signal(signal.SIGINT, sigint_handler) + signal.signal(signal.SIGTERM, sigint_handler) + + sp = subprocess.Popen(['airflow', 'serve_logs'], env=env) + + worker.run(**options) + sp.kill() + + +def initdb(args): # noqa + print("DB: " + repr(settings.engine.url)) + db_utils.initdb() + print("Done.") + + +def resetdb(args): + print("DB: " + repr(settings.engine.url)) + if args.yes or input( + "This will drop existing tables if they exist. " + "Proceed? (y/n)").upper() == "Y": + db_utils.resetdb() + else: + print("Bail.") + + +def upgradedb(args): # noqa + print("DB: " + repr(settings.engine.url)) + db_utils.upgradedb() + + # Populate DagStats table + session = settings.Session() + ds_rows = session.query(DagStat).count() + if not ds_rows: + qry = ( + session.query(DagRun.dag_id, DagRun.state, func.count('*')) + .group_by(DagRun.dag_id, DagRun.state) + ) + for dag_id, state, count in qry: + session.add(DagStat(dag_id=dag_id, state=state, count=count)) + session.commit() + + +def version(args): # noqa + print(settings.HEADER + " v" + airflow.__version__) + + +alternative_conn_specs = ['conn_type', 'conn_host', + 'conn_login', 'conn_password', 'conn_schema', 'conn_port'] + + +def connections(args): + if args.list: + # Check that no other flags were passed to the command + invalid_args = list() + for arg in ['conn_id', 'conn_uri', 'conn_extra'] + alternative_conn_specs: + if getattr(args, arg) is not None: + invalid_args.append(arg) + if invalid_args: + msg = ('\n\tThe following args are not compatible with the ' + + '--list flag: {invalid!r}\n') + msg = msg.format(invalid=invalid_args) + print(msg) + return + + session = settings.Session() + conns = session.query(Connection.conn_id, Connection.conn_type, + Connection.host, Connection.port, + Connection.is_encrypted, + Connection.is_extra_encrypted, + Connection.extra).all() + conns = [map(reprlib.repr, conn) for conn in conns] + print(tabulate(conns, ['Conn Id', 'Conn Type', 'Host', 'Port', + 'Is Encrypted', 'Is Extra Encrypted', 'Extra'], + tablefmt="fancy_grid")) + return + + if args.delete: + # Check that only the `conn_id` arg was passed to the command + invalid_args = list() + for arg in ['conn_uri', 'conn_extra'] + alternative_conn_specs: + if getattr(args, arg) is not None: + invalid_args.append(arg) + if invalid_args: + msg = ('\n\tThe following args are not compatible with the ' + + '--delete flag: {invalid!r}\n') + msg = msg.format(invalid=invalid_args) + print(msg) + return + + if args.conn_id is None: + print('\n\tTo delete a connection, you Must provide a value for ' + + 'the --conn_id flag.\n') + return + + session = settings.Session() + try: + to_delete = (session + .query(Connection) + .filter(Connection.conn_id == args.conn_id) + .one()) + except exc.NoResultFound: + msg = '\n\tDid not find a connection with `conn_id`={conn_id}\n' + msg = msg.format(conn_id=args.conn_id) + print(msg) + return + except exc.MultipleResultsFound: + msg = ('\n\tFound more than one connection with ' + + '`conn_id`={conn_id}\n') + msg = msg.format(conn_id=args.conn_id) + print(msg) + return + else: + deleted_conn_id = to_delete.conn_id + session.delete(to_delete) + session.commit() + msg = '\n\tSuccessfully deleted `conn_id`={conn_id}\n' + msg = msg.format(conn_id=deleted_conn_id) + print(msg) + return + + if args.add: + # Check that the conn_id and conn_uri args were passed to the command: + missing_args = list() + invalid_args = list() + if not args.conn_id: + missing_args.append('conn_id') + if args.conn_uri: + for arg in alternative_conn_specs: + if getattr(args, arg) is not None: + invalid_args.append(arg) + elif not args.conn_type: + missing_args.append('conn_uri or conn_type') + if missing_args: + msg = ('\n\tThe following args are required to add a connection:' + + ' {missing!r}\n'.format(missing=missing_args)) + print(msg) + if invalid_args: + msg = ('\n\tThe following args are not compatible with the ' + + '--add flag and --conn_uri flag: {invalid!r}\n') + msg = msg.format(invalid=invalid_args) + print(msg) + if missing_args or invalid_args: + return + + if args.conn_uri: + new_conn = Connection(conn_id=args.conn_id, uri=args.conn_uri) + else: + new_conn = Connection(conn_id=args.conn_id, conn_type=args.conn_type, host=args.conn_host, + login=args.conn_login, password=args.conn_password, schema=args.conn_schema, port=args.conn_port) + if args.conn_extra is not None: + new_conn.set_extra(args.conn_extra) + + session = settings.Session() + if not (session + .query(Connection) + .filter(Connection.conn_id == new_conn.conn_id).first()): + session.add(new_conn) + session.commit() + msg = '\n\tSuccessfully added `conn_id`={conn_id} : {uri}\n' + msg = msg.format(conn_id=new_conn.conn_id, uri=args.conn_uri or urlunparse((args.conn_type, '{login}:{password}@{host}:{port}'.format( + login=args.conn_login or '', password=args.conn_password or '', host=args.conn_host or '', port=args.conn_port or ''), args.conn_schema or '', '', '', ''))) + print(msg) + else: + msg = '\n\tA connection with `conn_id`={conn_id} already exists\n' + msg = msg.format(conn_id=new_conn.conn_id) + print(msg) + + return + + +def flower(args): + broka = conf.get('celery', 'BROKER_URL') + address = '--address={}'.format(args.hostname) + port = '--port={}'.format(args.port) + api = '' + if args.broker_api: + api = '--broker_api=' + args.broker_api + + flower_conf = '' + if args.flower_conf: + flower_conf = '--conf=' + args.flower_conf + + if args.daemon: + pid, stdout, stderr, log_file = setup_locations("flower", args.pid, args.stdout, args.stderr, args.log_file) + stdout = open(stdout, 'w+') + stderr = open(stderr, 'w+') + + ctx = daemon.DaemonContext( + pidfile=TimeoutPIDLockFile(pid, -1), + stdout=stdout, + stderr=stderr, + ) + + with ctx: + os.execvp("flower", ['flower', '-b', broka, address, port, api, flower_conf]) + + stdout.close() + stderr.close() + else: + signal.signal(signal.SIGINT, sigint_handler) + signal.signal(signal.SIGTERM, sigint_handler) + + os.execvp("flower", ['flower', '-b', broka, address, port, api, flower_conf]) + + +def kerberos(args): # noqa + print(settings.HEADER) + import airflow.security.kerberos + + if args.daemon: + pid, stdout, stderr, log_file = setup_locations("kerberos", args.pid, args.stdout, args.stderr, args.log_file) + stdout = open(stdout, 'w+') + stderr = open(stderr, 'w+') + + ctx = daemon.DaemonContext( + pidfile=TimeoutPIDLockFile(pid, -1), + stdout=stdout, + stderr=stderr, + ) + + with ctx: + airflow.security.kerberos.run() + + stdout.close() + stderr.close() + else: + airflow.security.kerberos.run() + + +Arg = namedtuple( + 'Arg', ['flags', 'help', 'action', 'default', 'nargs', 'type', 'choices', 'metavar']) +Arg.__new__.__defaults__ = (None, None, None, None, None, None, None) + + +class CLIFactory(object): + args = { + # Shared + 'dag_id': Arg(("dag_id",), "The id of the dag"), + 'task_id': Arg(("task_id",), "The id of the task"), + 'execution_date': Arg( + ("execution_date",), help="The execution date of the DAG", + type=parsedate), + 'task_regex': Arg( + ("-t", "--task_regex"), + "The regex to filter specific task_ids to backfill (optional)"), + 'subdir': Arg( + ("-sd", "--subdir"), + "File location or directory from which to look for the dag", + default=settings.DAGS_FOLDER), + 'start_date': Arg( + ("-s", "--start_date"), "Override start_date YYYY-MM-DD", + type=parsedate), + 'end_date': Arg( + ("-e", "--end_date"), "Override end_date YYYY-MM-DD", + type=parsedate), + 'dry_run': Arg( + ("-dr", "--dry_run"), "Perform a dry run", "store_true"), + 'pid': Arg( + ("--pid",), "PID file location", + nargs='?'), + 'daemon': Arg( + ("-D", "--daemon"), "Daemonize instead of running " + "in the foreground", + "store_true"), + 'stderr': Arg( + ("--stderr",), "Redirect stderr to this file"), + 'stdout': Arg( + ("--stdout",), "Redirect stdout to this file"), + 'log_file': Arg( + ("-l", "--log-file"), "Location of the log file"), + + # backfill + 'mark_success': Arg( + ("-m", "--mark_success"), + "Mark jobs as succeeded without running them", "store_true"), + 'local': Arg( + ("-l", "--local"), + "Run the task using the LocalExecutor", "store_true"), + 'donot_pickle': Arg( + ("-x", "--donot_pickle"), ( + "Do not attempt to pickle the DAG object to send over " + "to the workers, just tell the workers to run their version " + "of the code."), + "store_true"), + 'include_adhoc': Arg( + ("-a", "--include_adhoc"), + "Include dags with the adhoc parameter.", "store_true"), + 'bf_ignore_dependencies': Arg( + ("-i", "--ignore_dependencies"), + ( + "Skip upstream tasks, run only the tasks " + "matching the regexp. Only works in conjunction " + "with task_regex"), + "store_true"), + 'bf_ignore_first_depends_on_past': Arg( + ("-I", "--ignore_first_depends_on_past"), + ( + "Ignores depends_on_past dependencies for the first " + "set of tasks only (subsequent executions in the backfill " + "DO respect depends_on_past)."), + "store_true"), + 'pool': Arg(("--pool",), "Resource pool to use"), + 'delay_on_limit': Arg( + ("--delay_on_limit",), + help=("Amount of time in seconds to wait when the limit " + "on maximum active dag runs (max_active_runs) has " + "been reached before trying to execute a dag run " + "again."), + type=float, + default=1.0), + # list_tasks + 'tree': Arg(("-t", "--tree"), "Tree view", "store_true"), + # list_dags + 'report': Arg( + ("-r", "--report"), "Show DagBag loading report", "store_true"), + # clear + 'upstream': Arg( + ("-u", "--upstream"), "Include upstream tasks", "store_true"), + 'only_failed': Arg( + ("-f", "--only_failed"), "Only failed jobs", "store_true"), + 'only_running': Arg( + ("-r", "--only_running"), "Only running jobs", "store_true"), + 'downstream': Arg( + ("-d", "--downstream"), "Include downstream tasks", "store_true"), + 'no_confirm': Arg( + ("-c", "--no_confirm"), + "Do not request confirmation", "store_true"), + 'exclude_subdags': Arg( + ("-x", "--exclude_subdags"), + "Exclude subdags", "store_true"), + 'dag_regex': Arg( + ("-dx", "--dag_regex"), + "Search dag_id as regex instead of exact string", "store_true"), + # trigger_dag + 'run_id': Arg(("-r", "--run_id"), "Helps to identify this run"), + 'conf': Arg( + ('-c', '--conf'), + "JSON string that gets pickled into the DagRun's conf attribute"), + 'exec_date': Arg( + ("-e", "--exec_date"), help="The execution date of the DAG", + type=parsedate), + # pool + 'pool_set': Arg( + ("-s", "--set"), + nargs=3, + metavar=('NAME', 'SLOT_COUNT', 'POOL_DESCRIPTION'), + help="Set pool slot count and description, respectively"), + 'pool_get': Arg( + ("-g", "--get"), + metavar='NAME', + help="Get pool info"), + 'pool_delete': Arg( + ("-x", "--delete"), + metavar="NAME", + help="Delete a pool"), + # variables + 'set': Arg( + ("-s", "--set"), + nargs=2, + metavar=('KEY', 'VAL'), + help="Set a variable"), + 'get': Arg( + ("-g", "--get"), + metavar='KEY', + help="Get value of a variable"), + 'default': Arg( + ("-d", "--default"), + metavar="VAL", + default=None, + help="Default value returned if variable does not exist"), + 'json': Arg( + ("-j", "--json"), + help="Deserialize JSON variable", + action="store_true"), + 'var_import': Arg( + ("-i", "--import"), + metavar="FILEPATH", + help="Import variables from JSON file"), + 'var_export': Arg( + ("-e", "--export"), + metavar="FILEPATH", + help="Export variables to JSON file"), + 'var_delete': Arg( + ("-x", "--delete"), + metavar="KEY", + help="Delete a variable"), + # kerberos + 'principal': Arg( + ("principal",), "kerberos principal", + nargs='?', default=conf.get('kerberos', 'principal')), + 'keytab': Arg( + ("-kt", "--keytab"), "keytab", + nargs='?', default=conf.get('kerberos', 'keytab')), + # run + # TODO(aoen): "force" is a poor choice of name here since it implies it overrides + # all dependencies (not just past success), e.g. the ignore_depends_on_past + # dependency. This flag should be deprecated and renamed to 'ignore_ti_state' and + # the "ignore_all_dependencies" command should be called the"force" command + # instead. + 'force': Arg( + ("-f", "--force"), + "Ignore previous task instance state, rerun regardless if task already " + "succeeded/failed", + "store_true"), + 'raw': Arg(("-r", "--raw"), argparse.SUPPRESS, "store_true"), + 'ignore_all_dependencies': Arg( + ("-A", "--ignore_all_dependencies"), + "Ignores all non-critical dependencies, including ignore_ti_state and " + "ignore_task_deps", + "store_true"), + # TODO(aoen): ignore_dependencies is a poor choice of name here because it is too + # vague (e.g. a task being in the appropriate state to be run is also a dependency + # but is not ignored by this flag), the name 'ignore_task_dependencies' is + # slightly better (as it ignores all dependencies that are specific to the task), + # so deprecate the old command name and use this instead. + 'ignore_dependencies': Arg( + ("-i", "--ignore_dependencies"), + "Ignore task-specific dependencies, e.g. upstream, depends_on_past, and " + "retry delay dependencies", + "store_true"), + 'ignore_depends_on_past': Arg( + ("-I", "--ignore_depends_on_past"), + "Ignore depends_on_past dependencies (but respect " + "upstream dependencies)", + "store_true"), + 'ship_dag': Arg( + ("--ship_dag",), + "Pickles (serializes) the DAG and ships it to the worker", + "store_true"), + 'pickle': Arg( + ("-p", "--pickle"), + "Serialized pickle object of the entire dag (used internally)"), + 'job_id': Arg(("-j", "--job_id"), argparse.SUPPRESS), + 'cfg_path': Arg( + ("--cfg_path",), "Path to config file to use instead of airflow.cfg"), + # webserver + 'port': Arg( + ("-p", "--port"), + default=conf.get('webserver', 'WEB_SERVER_PORT'), + type=int, + help="The port on which to run the server"), + 'ssl_cert': Arg( + ("--ssl_cert",), + default=conf.get('webserver', 'WEB_SERVER_SSL_CERT'), + help="Path to the SSL certificate for the webserver"), + 'ssl_key': Arg( + ("--ssl_key",), + default=conf.get('webserver', 'WEB_SERVER_SSL_KEY'), + help="Path to the key to use with the SSL certificate"), + 'workers': Arg( + ("-w", "--workers"), + default=conf.get('webserver', 'WORKERS'), + type=int, + help="Number of workers to run the webserver on"), + 'workerclass': Arg( + ("-k", "--workerclass"), + default=conf.get('webserver', 'WORKER_CLASS'), + choices=['sync', 'eventlet', 'gevent', 'tornado'], + help="The worker class to use for Gunicorn"), + 'worker_timeout': Arg( + ("-t", "--worker_timeout"), + default=conf.get('webserver', 'WEB_SERVER_WORKER_TIMEOUT'), + type=int, + help="The timeout for waiting on webserver workers"), + 'hostname': Arg( + ("-hn", "--hostname"), + default=conf.get('webserver', 'WEB_SERVER_HOST'), + help="Set the hostname on which to run the web server"), + 'debug': Arg( + ("-d", "--debug"), + "Use the server that ships with Flask in debug mode", + "store_true"), + 'access_logfile': Arg( + ("-A", "--access_logfile"), + default=conf.get('webserver', 'ACCESS_LOGFILE'), + help="The logfile to store the webserver access log. Use '-' to print to " + "stderr."), + 'error_logfile': Arg( + ("-E", "--error_logfile"), + default=conf.get('webserver', 'ERROR_LOGFILE'), + help="The logfile to store the webserver error log. Use '-' to print to " + "stderr."), + # resetdb + 'yes': Arg( + ("-y", "--yes"), + "Do not prompt to confirm reset. Use with care!", + "store_true", + default=False), + # scheduler + 'dag_id_opt': Arg(("-d", "--dag_id"), help="The id of the dag to run"), + 'run_duration': Arg( + ("-r", "--run-duration"), + default=None, type=int, + help="Set number of seconds to execute before exiting"), + 'num_runs': Arg( + ("-n", "--num_runs"), + default=-1, type=int, + help="Set the number of runs to execute before exiting"), + # worker + 'do_pickle': Arg( + ("-p", "--do_pickle"), + default=False, + help=( + "Attempt to pickle the DAG object to send over " + "to the workers, instead of letting workers run their version " + "of the code."), + action="store_true"), + 'queues': Arg( + ("-q", "--queues"), + help="Comma delimited list of queues to serve", + default=conf.get('celery', 'DEFAULT_QUEUE')), + 'concurrency': Arg( + ("-c", "--concurrency"), + type=int, + help="The number of worker processes", + default=conf.get('celery', 'celeryd_concurrency')), + 'celery_hostname': Arg( + ("-cn", "--celery_hostname"), + help=("Set the hostname of celery worker " + "if you have multiple workers on a single machine.")), + # flower + 'broker_api': Arg(("-a", "--broker_api"), help="Broker api"), + 'flower_hostname': Arg( + ("-hn", "--hostname"), + default=conf.get('celery', 'FLOWER_HOST'), + help="Set the hostname on which to run the server"), + 'flower_port': Arg( + ("-p", "--port"), + default=conf.get('celery', 'FLOWER_PORT'), + type=int, + help="The port on which to run the server"), + 'flower_conf': Arg( + ("-fc", "--flower_conf"), + help="Configuration file for flower"), + 'task_params': Arg( + ("-tp", "--task_params"), + help="Sends a JSON params dict to the task"), + # connections + 'list_connections': Arg( + ('-l', '--list'), + help='List all connections', + action='store_true'), + 'add_connection': Arg( + ('-a', '--add'), + help='Add a connection', + action='store_true'), + 'delete_connection': Arg( + ('-d', '--delete'), + help='Delete a connection', + action='store_true'), + 'conn_id': Arg( + ('--conn_id',), + help='Connection id, required to add/delete a connection', + type=str), + 'conn_uri': Arg( + ('--conn_uri',), + help='Connection URI, required to add a connection without conn_type', + type=str), + 'conn_type': Arg( + ('--conn_type',), + help='Connection type, required to add a connection without conn_uri', + type=str), + 'conn_host': Arg( + ('--conn_host',), + help='Connection host, optional when adding a connection', + type=str), + 'conn_login': Arg( + ('--conn_login',), + help='Connection login, optional when adding a connection', + type=str), + 'conn_password': Arg( + ('--conn_password',), + help='Connection password, optional when adding a connection', + type=str), + 'conn_schema': Arg( + ('--conn_schema',), + help='Connection schema, optional when adding a connection', + type=str), + 'conn_port': Arg( + ('--conn_port',), + help='Connection port, optional when adding a connection', + type=str), + 'conn_extra': Arg( + ('--conn_extra',), + help='Connection `Extra` field, optional when adding a connection', + type=str), + } + subparsers = ( + { + 'func': backfill, + 'help': "Run subsections of a DAG for a specified date range", + 'args': ( + 'dag_id', 'task_regex', 'start_date', 'end_date', + 'mark_success', 'local', 'donot_pickle', 'include_adhoc', + 'bf_ignore_dependencies', 'bf_ignore_first_depends_on_past', + 'subdir', 'pool', 'delay_on_limit', 'dry_run') + }, { + 'func': list_tasks, + 'help': "List the tasks within a DAG", + 'args': ('dag_id', 'tree', 'subdir'), + }, { + 'func': clear, + 'help': "Clear a set of task instance, as if they never ran", + 'args': ( + 'dag_id', 'task_regex', 'start_date', 'end_date', 'subdir', + 'upstream', 'downstream', 'no_confirm', 'only_failed', + 'only_running', 'exclude_subdags', 'dag_regex'), + }, { + 'func': pause, + 'help': "Pause a DAG", + 'args': ('dag_id', 'subdir'), + }, { + 'func': unpause, + 'help': "Resume a paused DAG", + 'args': ('dag_id', 'subdir'), + }, { + 'func': trigger_dag, + 'help': "Trigger a DAG run", + 'args': ('dag_id', 'subdir', 'run_id', 'conf', 'exec_date'), + }, { + 'func': pool, + 'help': "CRUD operations on pools", + "args": ('pool_set', 'pool_get', 'pool_delete'), + }, { + 'func': variables, + 'help': "CRUD operations on variables", + "args": ('set', 'get', 'json', 'default', 'var_import', 'var_export', 'var_delete'), + }, { + 'func': kerberos, + 'help': "Start a kerberos ticket renewer", + 'args': ('principal', 'keytab', 'pid', + 'daemon', 'stdout', 'stderr', 'log_file'), + }, { + 'func': render, + 'help': "Render a task instance's template(s)", + 'args': ('dag_id', 'task_id', 'execution_date', 'subdir'), + }, { + 'func': run, + 'help': "Run a single task instance", + 'args': ( + 'dag_id', 'task_id', 'execution_date', 'subdir', + 'mark_success', 'force', 'pool', 'cfg_path', + 'local', 'raw', 'ignore_all_dependencies', 'ignore_dependencies', + 'ignore_depends_on_past', 'ship_dag', 'pickle', 'job_id'), + }, { + 'func': initdb, + 'help': "Initialize the metadata database", + 'args': tuple(), + }, { + 'func': list_dags, + 'help': "List all the DAGs", + 'args': ('subdir', 'report'), + }, { + 'func': dag_state, + 'help': "Get the status of a dag run", + 'args': ('dag_id', 'execution_date', 'subdir'), + }, { + 'func': task_failed_deps, + 'help': ( + "Returns the unmet dependencies for a task instance from the perspective " + "of the scheduler. In other words, why a task instance doesn't get " + "scheduled and then queued by the scheduler, and then run by an " + "executor)."), + 'args': ('dag_id', 'task_id', 'execution_date', 'subdir'), + }, { + 'func': task_state, + 'help': "Get the status of a task instance", + 'args': ('dag_id', 'task_id', 'execution_date', 'subdir'), + }, { + 'func': serve_logs, + 'help': "Serve logs generate by worker", + 'args': tuple(), + }, { + 'func': test, + 'help': ( + "Test a task instance. This will run a task without checking for " + "dependencies or recording it's state in the database."), + 'args': ( + 'dag_id', 'task_id', 'execution_date', 'subdir', 'dry_run', + 'task_params'), + }, { + 'func': webserver, + 'help': "Start a Airflow webserver instance", + 'args': ('port', 'workers', 'workerclass', 'worker_timeout', 'hostname', + 'pid', 'daemon', 'stdout', 'stderr', 'access_logfile', + 'error_logfile', 'log_file', 'ssl_cert', 'ssl_key', 'debug'), + }, { + 'func': resetdb, + 'help': "Burn down and rebuild the metadata database", + 'args': ('yes',), + }, { + 'func': upgradedb, + 'help': "Upgrade the metadata database to latest version", + 'args': tuple(), + }, { + 'func': scheduler, + 'help': "Start a scheduler instance", + 'args': ('dag_id_opt', 'subdir', 'run_duration', 'num_runs', + 'do_pickle', 'pid', 'daemon', 'stdout', 'stderr', + 'log_file'), + }, { + 'func': worker, + 'help': "Start a Celery worker node", + 'args': ('do_pickle', 'queues', 'concurrency', 'celery_hostname', + 'pid', 'daemon', 'stdout', 'stderr', 'log_file'), + }, { + 'func': flower, + 'help': "Start a Celery Flower", + 'args': ('flower_hostname', 'flower_port', 'flower_conf', 'broker_api', + 'pid', 'daemon', 'stdout', 'stderr', 'log_file'), + }, { + 'func': version, + 'help': "Show the version", + 'args': tuple(), + }, { + 'func': connections, + 'help': "List/Add/Delete connections", + 'args': ('list_connections', 'add_connection', 'delete_connection', + 'conn_id', 'conn_uri', 'conn_extra') + tuple(alternative_conn_specs), + }, + ) + subparsers_dict = {sp['func'].__name__: sp for sp in subparsers} + dag_subparsers = ( + 'list_tasks', 'backfill', 'test', 'run', 'pause', 'unpause') + + @classmethod + def get_parser(cls, dag_parser=False): + parser = argparse.ArgumentParser() + subparsers = parser.add_subparsers( + help='sub-command help', dest='subcommand') + subparsers.required = True + + subparser_list = cls.dag_subparsers if dag_parser else cls.subparsers_dict.keys() + for sub in subparser_list: + sub = cls.subparsers_dict[sub] + sp = subparsers.add_parser(sub['func'].__name__, help=sub['help']) + for arg in sub['args']: + if 'dag_id' in arg and dag_parser: + continue + arg = cls.args[arg] + kwargs = { + f: getattr(arg, f) + for f in arg._fields if f != 'flags' and getattr(arg, f)} + sp.add_argument(*arg.flags, **kwargs) + sp.set_defaults(func=sub['func']) + return parser + + +def get_parser(): + return CLIFactory.get_parser() diff --git a/airflow/config_templates/__init__.py b/airflow/config_templates/__init__.py new file mode 100644 index 0000000000000..9d7677a99b293 --- /dev/null +++ b/airflow/config_templates/__init__.py @@ -0,0 +1,13 @@ +# -*- coding: utf-8 -*- +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. diff --git a/airflow/config_templates/airflow_local_settings.py b/airflow/config_templates/airflow_local_settings.py new file mode 100644 index 0000000000000..fe3337fa03bb2 --- /dev/null +++ b/airflow/config_templates/airflow_local_settings.py @@ -0,0 +1,86 @@ +# -*- coding: utf-8 -*- +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import os + +from airflow import configuration as conf + +# TODO: Logging format and level should be configured +# in this file instead of from airflow.cfg. Currently +# there are other log format and level configurations in +# settings.py and cli.py. Please see AIRFLOW-1455. + +LOG_LEVEL = conf.get('core', 'LOGGING_LEVEL').upper() +LOG_FORMAT = conf.get('core', 'log_format') + +BASE_LOG_FOLDER = conf.get('core', 'BASE_LOG_FOLDER') + +FILENAME_TEMPLATE = '{{ ti.dag_id }}/{{ ti.task_id }}/{{ ts }}/{{ try_number }}.log' + +DEFAULT_LOGGING_CONFIG = { + 'version': 1, + 'disable_existing_loggers': False, + 'formatters': { + 'airflow.task': { + 'format': LOG_FORMAT, + }, + }, + 'handlers': { + 'console': { + 'class': 'logging.StreamHandler', + 'formatter': 'airflow.task', + 'stream': 'ext://sys.stdout' + }, + 'file.task': { + 'class': 'airflow.utils.log.file_task_handler.FileTaskHandler', + 'formatter': 'airflow.task', + 'base_log_folder': os.path.expanduser(BASE_LOG_FOLDER), + 'filename_template': FILENAME_TEMPLATE, + } + # When using s3 or gcs, provide a customized LOGGING_CONFIG + # in airflow_local_settings within your PYTHONPATH, see UPDATING.md + # for details + # 's3.task': { + # 'class': 'airflow.utils.log.s3_task_handler.S3TaskHandler', + # 'formatter': 'airflow.task', + # 'base_log_folder': os.path.expanduser(BASE_LOG_FOLDER), + # 's3_log_folder': S3_LOG_FOLDER, + # 'filename_template': FILENAME_TEMPLATE, + # }, + # 'gcs.task': { + # 'class': 'airflow.utils.log.gcs_task_handler.GCSTaskHandler', + # 'formatter': 'airflow.task', + # 'base_log_folder': os.path.expanduser(BASE_LOG_FOLDER), + # 'gcs_log_folder': GCS_LOG_FOLDER, + # 'filename_template': FILENAME_TEMPLATE, + # }, + }, + 'loggers': { + 'airflow.task': { + 'handlers': ['file.task'], + 'level': LOG_LEVEL, + 'propagate': False, + }, + 'airflow.task_runner': { + 'handlers': ['file.task'], + 'level': LOG_LEVEL, + 'propagate': True, + }, + 'airflow': { + 'handlers': ['console'], + 'level': LOG_LEVEL, + 'propagate': False, + }, + } +} diff --git a/airflow/config_templates/default_airflow.cfg b/airflow/config_templates/default_airflow.cfg new file mode 100644 index 0000000000000..91669792f70a5 --- /dev/null +++ b/airflow/config_templates/default_airflow.cfg @@ -0,0 +1,438 @@ +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + + +# This is the template for Airflow's default configuration. When Airflow is +# imported, it looks for a configuration file at $AIRFLOW_HOME/airflow.cfg. If +# it doesn't exist, Airflow uses this template to generate it by replacing +# variables in curly braces with their global values from configuration.py. + +# Users should not modify this file; they should customize the generated +# airflow.cfg instead. + + +# ----------------------- TEMPLATE BEGINS HERE ----------------------- + +[core] +# The home folder for airflow, default is ~/airflow +airflow_home = {AIRFLOW_HOME} + +# The folder where your airflow pipelines live, most likely a +# subfolder in a code repository +# This path must be absolute +dags_folder = {AIRFLOW_HOME}/dags + +# The folder where airflow should store its log files +# This path must be absolute +base_log_folder = {AIRFLOW_HOME}/logs + +# Airflow can store logs remotely in AWS S3 or Google Cloud Storage. Users +# must supply an Airflow connection id that provides access to the storage +# location. +remote_log_conn_id = +encrypt_s3_logs = False + +# Logging level +logging_level = INFO + +# Logging class +# Specify the class that will specify the logging configuration +# This class has to be on the python classpath +# logging_config_class = my.path.default_local_settings.LOGGING_CONFIG +logging_config_class = + +# Log format +log_format = [%%(asctime)s] {{%%(filename)s:%%(lineno)d}} %%(levelname)s - %%(message)s +simple_log_format = %%(asctime)s %%(levelname)s - %%(message)s + +# The executor class that airflow should use. Choices include +# SequentialExecutor, LocalExecutor, CeleryExecutor, DaskExecutor +executor = SequentialExecutor + +# The SqlAlchemy connection string to the metadata database. +# SqlAlchemy supports many different database engine, more information +# their website +sql_alchemy_conn = sqlite:///{AIRFLOW_HOME}/airflow.db + +# The SqlAlchemy pool size is the maximum number of database connections +# in the pool. +sql_alchemy_pool_size = 5 + +# The SqlAlchemy pool recycle is the number of seconds a connection +# can be idle in the pool before it is invalidated. This config does +# not apply to sqlite. +sql_alchemy_pool_recycle = 3600 + +# The amount of parallelism as a setting to the executor. This defines +# the max number of task instances that should run simultaneously +# on this airflow installation +parallelism = 32 + +# The number of task instances allowed to run concurrently by the scheduler +dag_concurrency = 16 + +# Are DAGs paused by default at creation +dags_are_paused_at_creation = True + +# When not using pools, tasks are run in the "default pool", +# whose size is guided by this config element +non_pooled_task_slot_count = 128 + +# The maximum number of active DAG runs per DAG +max_active_runs_per_dag = 16 + +# Whether to load the examples that ship with Airflow. It's good to +# get started, but you probably want to set this to False in a production +# environment +load_examples = True + +# Where your Airflow plugins are stored +plugins_folder = {AIRFLOW_HOME}/plugins + +# Secret key to save connection passwords in the db +fernet_key = {FERNET_KEY} + +# Whether to disable pickling dags +donot_pickle = False + +# How long before timing out a python file import while filling the DagBag +dagbag_import_timeout = 30 + +# The class to use for running task instances in a subprocess +task_runner = BashTaskRunner + +# If set, tasks without a `run_as_user` argument will be run with this user +# Can be used to de-elevate a sudo user running Airflow when executing tasks +default_impersonation = + +# What security module to use (for example kerberos): +security = + +# If set to False enables some unsecure features like Charts. In 2.0 will +# default to True. +secure_mode = False + +# Turn unit test mode on (overwrites many configuration options with test +# values at runtime) +unit_test_mode = False + +# Name of handler to read task instance logs. +# Default to use file task handler. +task_log_reader = file.task + +# Whether to enable pickling for xcom (note that this is insecure and allows for +# RCE exploits). This will be deprecated in Airflow 2.0 (be forced to False). +enable_xcom_pickling = True + +# When a task is killed forcefully, this is the amount of time in seconds that +# it has to cleanup after it is sent a SIGTERM, before it is SIGKILLED +killed_task_cleanup_time = 60 + +[cli] +# In what way should the cli access the API. The LocalClient will use the +# database directly, while the json_client will use the api running on the +# webserver +api_client = airflow.api.client.local_client +endpoint_url = http://localhost:8080 + +[api] +# How to authenticate users of the API +auth_backend = airflow.api.auth.backend.default + +[operators] +# The default owner assigned to each new operator, unless +# provided explicitly or passed via `default_args` +default_owner = Airflow +default_cpus = 1 +default_ram = 512 +default_disk = 512 +default_gpus = 0 + + +[webserver] +# The base url of your website as airflow cannot guess what domain or +# cname you are using. This is used in automated emails that +# airflow sends to point links to the right web server +base_url = http://localhost:8080 + +# The ip specified when starting the web server +web_server_host = 0.0.0.0 + +# The port on which to run the web server +web_server_port = 8080 + +# Paths to the SSL certificate and key for the web server. When both are +# provided SSL will be enabled. This does not change the web server port. +web_server_ssl_cert = +web_server_ssl_key = + +# Number of seconds the gunicorn webserver waits before timing out on a worker +web_server_worker_timeout = 120 + +# Number of workers to refresh at a time. When set to 0, worker refresh is +# disabled. When nonzero, airflow periodically refreshes webserver workers by +# bringing up new ones and killing old ones. +worker_refresh_batch_size = 1 + +# Number of seconds to wait before refreshing a batch of workers. +worker_refresh_interval = 30 + +# Secret key used to run your flask app +secret_key = temporary_key + +# Number of workers to run the Gunicorn web server +workers = 4 + +# The worker class gunicorn should use. Choices include +# sync (default), eventlet, gevent +worker_class = sync + +# Log files for the gunicorn webserver. '-' means log to stderr. +access_logfile = - +error_logfile = - + +# Expose the configuration file in the web server +expose_config = False + +# Set to true to turn on authentication: +# http://pythonhosted.org/airflow/security.html#web-authentication +authenticate = False + +# Filter the list of dags by owner name (requires authentication to be enabled) +filter_by_owner = False + +# Filtering mode. Choices include user (default) and ldapgroup. +# Ldap group filtering requires using the ldap backend +# +# Note that the ldap server needs the "memberOf" overlay to be set up +# in order to user the ldapgroup mode. +owner_mode = user + +# Default DAG view. Valid values are: +# tree, graph, duration, gantt, landing_times +dag_default_view = tree + +# Default DAG orientation. Valid values are: +# LR (Left->Right), TB (Top->Bottom), RL (Right->Left), BT (Bottom->Top) +dag_orientation = LR + +# Puts the webserver in demonstration mode; blurs the names of Operators for +# privacy. +demo_mode = False + +# The amount of time (in secs) webserver will wait for initial handshake +# while fetching logs from other worker machine +log_fetch_timeout_sec = 5 + +# By default, the webserver shows paused DAGs. Flip this to hide paused +# DAGs by default +hide_paused_dags_by_default = False + +# Consistent page size across all listing views in the UI +page_size = 100 + +[email] +email_backend = airflow.utils.email.send_email_smtp + + +[sendgrid] +# Recommend an API key with Mail.send permission only. +sendgrid_api_key = +sendgrid_mail_from = airflow@example.com + + +[smtp] +# If you want airflow to send emails on retries, failure, and you want to use +# the airflow.utils.email.send_email_smtp function, you have to configure an +# smtp server here +smtp_host = localhost +smtp_starttls = True +smtp_ssl = False +# Uncomment and set the user/pass settings if you want to use SMTP AUTH +# smtp_user = airflow +# smtp_password = airflow +smtp_port = 25 +smtp_mail_from = airflow@example.com + + +[celery] +# This section only applies if you are using the CeleryExecutor in +# [core] section above + +# The app name that will be used by celery +celery_app_name = airflow.executors.celery_executor + +# The concurrency that will be used when starting workers with the +# "airflow worker" command. This defines the number of task instances that +# a worker will take, so size up your workers based on the resources on +# your worker box and the nature of your tasks +celeryd_concurrency = 16 + +# When you start an airflow worker, airflow starts a tiny web server +# subprocess to serve the workers local log files to the airflow main +# web server, who then builds pages and sends them to users. This defines +# the port on which the logs are served. It needs to be unused, and open +# visible from the main web server to connect into the workers. +worker_log_server_port = 8793 + +# The Celery broker URL. Celery supports RabbitMQ, Redis and experimentally +# a sqlalchemy database. Refer to the Celery documentation for more +# information. +broker_url = sqla+mysql://airflow:airflow@localhost:3306/airflow + +# Another key Celery setting +celery_result_backend = db+mysql://airflow:airflow@localhost:3306/airflow + +# Celery Flower is a sweet UI for Celery. Airflow has a shortcut to start +# it `airflow flower`. This defines the IP that Celery Flower runs on +flower_host = 0.0.0.0 + +# This defines the port that Celery Flower runs on +flower_port = 5555 + +# Default queue that tasks get assigned to and that worker listen on. +default_queue = default + +# Import path for celery configuration options +celery_config_options = airflow.config_templates.default_celery.DEFAULT_CELERY_CONFIG + +[dask] +# This section only applies if you are using the DaskExecutor in +# [core] section above + +# The IP address and port of the Dask cluster's scheduler. +cluster_address = 127.0.0.1:8786 + + +[scheduler] +# Task instances listen for external kill signal (when you clear tasks +# from the CLI or the UI), this defines the frequency at which they should +# listen (in seconds). +job_heartbeat_sec = 5 + +# The scheduler constantly tries to trigger new tasks (look at the +# scheduler section in the docs for more information). This defines +# how often the scheduler should run (in seconds). +scheduler_heartbeat_sec = 5 + +# after how much time should the scheduler terminate in seconds +# -1 indicates to run continuously (see also num_runs) +run_duration = -1 + +# after how much time a new DAGs should be picked up from the filesystem +min_file_process_interval = 0 + +dag_dir_list_interval = 300 + +# How often should stats be printed to the logs +print_stats_interval = 30 + +child_process_log_directory = {AIRFLOW_HOME}/logs/scheduler + +# Local task jobs periodically heartbeat to the DB. If the job has +# not heartbeat in this many seconds, the scheduler will mark the +# associated task instance as failed and will re-schedule the task. +scheduler_zombie_task_threshold = 300 + +# Turn off scheduler catchup by setting this to False. +# Default behavior is unchanged and +# Command Line Backfills still work, but the scheduler +# will not do scheduler catchup if this is False, +# however it can be set on a per DAG basis in the +# DAG definition (catchup) +catchup_by_default = True + +# This changes the batch size of queries in the scheduling main loop. +# This depends on query length limits and how long you are willing to hold locks. +# 0 for no limit +max_tis_per_query = 0 + +# Statsd (https://github.com/etsy/statsd) integration settings +statsd_on = False +statsd_host = localhost +statsd_port = 8125 +statsd_prefix = airflow + +# The scheduler can run multiple threads in parallel to schedule dags. +# This defines how many threads will run. +max_threads = 2 + +authenticate = False + +[ldap] +# set this to ldaps://: +uri = +user_filter = objectClass=* +user_name_attr = uid +group_member_attr = memberOf +superuser_filter = +data_profiler_filter = +bind_user = cn=Manager,dc=example,dc=com +bind_password = insecure +basedn = dc=example,dc=com +cacert = /etc/ca/ldap_ca.crt +search_scope = LEVEL + +[mesos] +# Mesos master address which MesosExecutor will connect to. +master = localhost:5050 + +# The framework name which Airflow scheduler will register itself as on mesos +framework_name = Airflow + +# Number of cpu cores required for running one task instance using +# 'airflow run --local -p ' +# command on a mesos slave +task_cpu = 1 + +# Memory in MB required for running one task instance using +# 'airflow run --local -p ' +# command on a mesos slave +task_memory = 256 + +# Enable framework checkpointing for mesos +# See http://mesos.apache.org/documentation/latest/slave-recovery/ +checkpoint = False + +# Failover timeout in milliseconds. +# When checkpointing is enabled and this option is set, Mesos waits +# until the configured timeout for +# the MesosExecutor framework to re-register after a failover. Mesos +# shuts down running tasks if the +# MesosExecutor framework fails to re-register within this timeframe. +# failover_timeout = 604800 + +# Enable framework authentication for mesos +# See http://mesos.apache.org/documentation/latest/configuration/ +authenticate = False + +# Mesos credentials, if authentication is enabled +# default_principal = admin +# default_secret = admin + + +[kerberos] +ccache = /tmp/airflow_krb5_ccache +# gets augmented with fqdn +principal = airflow +reinit_frequency = 3600 +kinit_path = kinit +keytab = airflow.keytab + + +[github_enterprise] +api_rev = v3 + + +[admin] +# UI to hide sensitive variable fields when set to True +hide_sensitive_variable_fields = True diff --git a/airflow/config_templates/default_celery.py b/airflow/config_templates/default_celery.py new file mode 100644 index 0000000000000..48611cb1897b0 --- /dev/null +++ b/airflow/config_templates/default_celery.py @@ -0,0 +1,58 @@ +# -*- coding: utf-8 -*- +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import ssl + +from airflow.exceptions import AirflowConfigException, AirflowException +from airflow import configuration +from airflow.utils.log.logging_mixin import LoggingMixin + + +DEFAULT_CELERY_CONFIG = { + 'accept_content': ['json', 'pickle'], + 'event_serializer': 'json', + 'result_serializer': 'pickle', + 'worker_prefetch_multiplier': 1, + 'task_acks_late': True, + 'task_default_queue': configuration.get('celery', 'DEFAULT_QUEUE'), + 'task_default_exchange': configuration.get('celery', 'DEFAULT_QUEUE'), + 'broker_url': configuration.get('celery', 'BROKER_URL'), + 'broker_transport_options': {'visibility_timeout': 21600}, + 'result_backend': configuration.get('celery', 'CELERY_RESULT_BACKEND'), + 'worker_concurrency': configuration.getint('celery', 'CELERYD_CONCURRENCY'), +} + +celery_ssl_active = False +try: + celery_ssl_active = configuration.getboolean('celery', 'CELERY_SSL_ACTIVE') +except AirflowConfigException as e: + log = LoggingMixin().log + log.warning("Celery Executor will run without SSL") + +try: + if celery_ssl_active: + broker_use_ssl = {'keyfile': configuration.get('celery', 'CELERY_SSL_KEY'), + 'certfile': configuration.get('celery', 'CELERY_SSL_CERT'), + 'ca_certs': configuration.get('celery', 'CELERY_SSL_CACERT'), + 'cert_reqs': ssl.CERT_REQUIRED} + DEFAULT_CELERY_CONFIG['broker_use_ssl'] = broker_use_ssl +except AirflowConfigException as e: + raise AirflowException('AirflowConfigException: CELERY_SSL_ACTIVE is True, ' + 'please ensure CELERY_SSL_KEY, ' + 'CELERY_SSL_CERT and CELERY_SSL_CACERT are set') +except Exception as e: + raise AirflowException('Exception: There was an unknown Celery SSL Error. ' + 'Please ensure you want to use ' + 'SSL and/or have all necessary certs and key ({}).'.format(e)) + diff --git a/airflow/config_templates/default_test.cfg b/airflow/config_templates/default_test.cfg new file mode 100644 index 0000000000000..b065313c17aa2 --- /dev/null +++ b/airflow/config_templates/default_test.cfg @@ -0,0 +1,94 @@ +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + + +# This is the template for Airflow's unit test configuration. When Airflow runs +# unit tests, it looks for a configuration file at $AIRFLOW_HOME/unittests.cfg. +# If it doesn't exist, Airflow uses this template to generate it by replacing +# variables in curly braces with their global values from configuration.py. + +# Users should not modify this file; they should customize the generated +# unittests.cfg instead. + + +# ----------------------- TEMPLATE BEGINS HERE ----------------------- + +[core] +unit_test_mode = True +airflow_home = {AIRFLOW_HOME} +dags_folder = {TEST_DAGS_FOLDER} +plugins_folder = {TEST_PLUGINS_FOLDER} +base_log_folder = {AIRFLOW_HOME}/logs +logging_level = INFO +executor = SequentialExecutor +sql_alchemy_conn = sqlite:///{AIRFLOW_HOME}/unittests.db +load_examples = True +donot_pickle = False +dag_concurrency = 16 +dags_are_paused_at_creation = False +fernet_key = {FERNET_KEY} +non_pooled_task_slot_count = 128 +enable_xcom_pickling = False +killed_task_cleanup_time = 5 + +[cli] +api_client = airflow.api.client.local_client +endpoint_url = http://localhost:8080 + +[api] +auth_backend = airflow.api.auth.backend.default + +[operators] +default_owner = airflow + +[webserver] +base_url = http://localhost:8080 +web_server_host = 0.0.0.0 +web_server_port = 8080 +dag_orientation = LR +dag_default_view = tree +log_fetch_timeout_sec = 5 +hide_paused_dags_by_default = False +page_size = 100 + +[email] +email_backend = airflow.utils.email.send_email_smtp + +[smtp] +smtp_host = localhost +smtp_user = airflow +smtp_port = 25 +smtp_password = airflow +smtp_mail_from = airflow@example.com + +[celery] +celery_app_name = airflow.executors.celery_executor +celeryd_concurrency = 16 +worker_log_server_port = 8793 +broker_url = sqla+mysql://airflow:airflow@localhost:3306/airflow +celery_result_backend = db+mysql://airflow:airflow@localhost:3306/airflow +flower_host = 0.0.0.0 +flower_port = 5555 +default_queue = default + +[scheduler] +job_heartbeat_sec = 1 +scheduler_heartbeat_sec = 5 +authenticate = true +max_threads = 2 +catchup_by_default = True +scheduler_zombie_task_threshold = 300 +dag_dir_list_interval = 0 +max_tis_per_query = 0 + +[admin] +hide_sensitive_variable_fields = True diff --git a/airflow/configuration.py b/airflow/configuration.py new file mode 100644 index 0000000000000..ff81d9827ba00 --- /dev/null +++ b/airflow/configuration.py @@ -0,0 +1,438 @@ +# -*- coding: utf-8 -*- +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +from __future__ import absolute_import +from __future__ import division +from __future__ import print_function +from __future__ import unicode_literals + +import copy +import errno +import os +import six +import subprocess +import warnings +import shlex +import sys + +from future import standard_library + +from airflow.utils.log.logging_mixin import LoggingMixin + +standard_library.install_aliases() + +from builtins import str +from collections import OrderedDict +from six.moves import configparser + +from airflow.exceptions import AirflowConfigException + +log = LoggingMixin().log + +# show Airflow's deprecation warnings +warnings.filterwarnings( + action='default', category=DeprecationWarning, module='airflow') +warnings.filterwarnings( + action='default', category=PendingDeprecationWarning, module='airflow') + +if six.PY3: + ConfigParser = configparser.ConfigParser +else: + ConfigParser = configparser.SafeConfigParser + + +def generate_fernet_key(): + try: + from cryptography.fernet import Fernet + except ImportError: + pass + try: + key = Fernet.generate_key().decode() + except NameError: + key = "cryptography_not_found_storing_passwords_in_plain_text" + return key + + +def expand_env_var(env_var): + """ + Expands (potentially nested) env vars by repeatedly applying + `expandvars` and `expanduser` until interpolation stops having + any effect. + """ + if not env_var: + return env_var + while True: + interpolated = os.path.expanduser(os.path.expandvars(str(env_var))) + if interpolated == env_var: + return interpolated + else: + env_var = interpolated + + +def run_command(command): + """ + Runs command and returns stdout + """ + process = subprocess.Popen( + shlex.split(command), stdout=subprocess.PIPE, stderr=subprocess.PIPE) + output, stderr = [stream.decode(sys.getdefaultencoding(), 'ignore') + for stream in process.communicate()] + + if process.returncode != 0: + raise AirflowConfigException( + "Cannot execute {}. Error code is: {}. Output: {}, Stderr: {}" + .format(command, process.returncode, output, stderr) + ) + + return output + +_templates_dir = os.path.join(os.path.dirname(__file__), 'config_templates') +with open(os.path.join(_templates_dir, 'default_airflow.cfg')) as f: + DEFAULT_CONFIG = f.read() +with open(os.path.join(_templates_dir, 'default_test.cfg')) as f: + TEST_CONFIG = f.read() + + +class AirflowConfigParser(ConfigParser): + + # These configuration elements can be fetched as the stdout of commands + # following the "{section}__{name}__cmd" pattern, the idea behind this + # is to not store password on boxes in text files. + as_command_stdout = { + ('core', 'sql_alchemy_conn'), + ('core', 'fernet_key'), + ('celery', 'broker_url'), + ('celery', 'celery_result_backend') + } + + def __init__(self, *args, **kwargs): + ConfigParser.__init__(self, *args, **kwargs) + self.read_string(parameterized_config(DEFAULT_CONFIG)) + self.is_validated = False + + def read_string(self, string, source=''): + """ + Read configuration from a string. + + A backwards-compatible version of the ConfigParser.read_string() + method that was introduced in Python 3. + """ + # Python 3 added read_string() method + if six.PY3: + ConfigParser.read_string(self, string, source=source) + # Python 2 requires StringIO buffer + else: + import StringIO + self.readfp(StringIO.StringIO(string)) + + def _validate(self): + if ( + self.get("core", "executor") != 'SequentialExecutor' and + "sqlite" in self.get('core', 'sql_alchemy_conn')): + raise AirflowConfigException( + "error: cannot use sqlite with the {}".format( + self.get('core', 'executor'))) + + elif ( + self.getboolean("webserver", "authenticate") and + self.get("webserver", "owner_mode") not in ['user', 'ldapgroup'] + ): + raise AirflowConfigException( + "error: owner_mode option should be either " + "'user' or 'ldapgroup' when filtering by owner is set") + + elif ( + self.getboolean("webserver", "authenticate") and + self.get("webserver", "owner_mode").lower() == 'ldapgroup' and + self.get("webserver", "auth_backend") != ( + 'airflow.contrib.auth.backends.ldap_auth') + ): + raise AirflowConfigException( + "error: attempt at using ldapgroup " + "filtering without using the Ldap backend") + + self.is_validated = True + + def _get_env_var_option(self, section, key): + # must have format AIRFLOW__{SECTION}__{KEY} (note double underscore) + env_var = 'AIRFLOW__{S}__{K}'.format(S=section.upper(), K=key.upper()) + if env_var in os.environ: + return expand_env_var(os.environ[env_var]) + + def _get_cmd_option(self, section, key): + fallback_key = key + '_cmd' + # if this is a valid command key... + if (section, key) in AirflowConfigParser.as_command_stdout: + # if the original key is present, return it no matter what + if self.has_option(section, key): + return ConfigParser.get(self, section, key) + # otherwise, execute the fallback key + elif self.has_option(section, fallback_key): + command = self.get(section, fallback_key) + return run_command(command) + + def get(self, section, key, **kwargs): + section = str(section).lower() + key = str(key).lower() + + # first check environment variables + option = self._get_env_var_option(section, key) + if option is not None: + return option + + # ...then the config file + if self.has_option(section, key): + return expand_env_var( + ConfigParser.get(self, section, key, **kwargs)) + + # ...then commands + option = self._get_cmd_option(section, key) + if option: + return option + + else: + log.warning( + "section/key [{section}/{key}] not found in config".format(**locals()) + ) + + raise AirflowConfigException( + "section/key [{section}/{key}] not found " + "in config".format(**locals())) + + def getboolean(self, section, key): + val = str(self.get(section, key)).lower().strip() + if '#' in val: + val = val.split('#')[0].strip() + if val.lower() in ('t', 'true', '1'): + return True + elif val.lower() in ('f', 'false', '0'): + return False + else: + raise AirflowConfigException( + 'The value for configuration option "{}:{}" is not a ' + 'boolean (received "{}").'.format(section, key, val)) + + def getint(self, section, key): + return int(self.get(section, key)) + + def getfloat(self, section, key): + return float(self.get(section, key)) + + def read(self, filenames): + ConfigParser.read(self, filenames) + self._validate() + + def as_dict(self, display_source=False, display_sensitive=False): + """ + Returns the current configuration as an OrderedDict of OrderedDicts. + :param display_source: If False, the option value is returned. If True, + a tuple of (option_value, source) is returned. Source is either + 'airflow.cfg' or 'default'. + :type display_source: bool + :param display_sensitive: If True, the values of options set by env + vars and bash commands will be displayed. If False, those options + are shown as '< hidden >' + :type display_sensitive: bool + """ + cfg = copy.deepcopy(self._sections) + + # remove __name__ (affects Python 2 only) + for options in cfg.values(): + options.pop('__name__', None) + + # add source + if display_source: + for section in cfg: + for k, v in cfg[section].items(): + cfg[section][k] = (v, 'airflow config') + + # add env vars and overwrite because they have priority + for ev in [ev for ev in os.environ if ev.startswith('AIRFLOW__')]: + try: + _, section, key = ev.split('__') + opt = self._get_env_var_option(section, key) + except ValueError: + opt = None + if opt: + if ( + not display_sensitive + and ev != 'AIRFLOW__CORE__UNIT_TEST_MODE'): + opt = '< hidden >' + if display_source: + opt = (opt, 'env var') + cfg.setdefault(section.lower(), OrderedDict()).update( + {key.lower(): opt}) + + # add bash commands + for (section, key) in AirflowConfigParser.as_command_stdout: + opt = self._get_cmd_option(section, key) + if opt: + if not display_sensitive: + opt = '< hidden >' + if display_source: + opt = (opt, 'bash cmd') + cfg.setdefault(section, OrderedDict()).update({key: opt}) + + return cfg + + def load_test_config(self): + """ + Load the unit test configuration. + + Note: this is not reversible. + """ + # override any custom settings with defaults + self.read_string(parameterized_config(DEFAULT_CONFIG)) + # then read test config + self.read_string(parameterized_config(TEST_CONFIG)) + # then read any "custom" test settings + self.read(TEST_CONFIG_FILE) + + +def mkdir_p(path): + try: + os.makedirs(path) + except OSError as exc: # Python >2.5 + if exc.errno == errno.EEXIST and os.path.isdir(path): + pass + else: + raise AirflowConfigException('Had trouble creating a directory') + + +# Setting AIRFLOW_HOME and AIRFLOW_CONFIG from environment variables, using +# "~/airflow" and "~/airflow/airflow.cfg" respectively as defaults. + +if 'AIRFLOW_HOME' not in os.environ: + AIRFLOW_HOME = expand_env_var('~/airflow') +else: + AIRFLOW_HOME = expand_env_var(os.environ['AIRFLOW_HOME']) + +mkdir_p(AIRFLOW_HOME) + +if 'AIRFLOW_CONFIG' not in os.environ: + if os.path.isfile(expand_env_var('~/airflow.cfg')): + AIRFLOW_CONFIG = expand_env_var('~/airflow.cfg') + else: + AIRFLOW_CONFIG = AIRFLOW_HOME + '/airflow.cfg' +else: + AIRFLOW_CONFIG = expand_env_var(os.environ['AIRFLOW_CONFIG']) + +# Set up dags folder for unit tests +# this directory won't exist if users install via pip +_TEST_DAGS_FOLDER = os.path.join( + os.path.dirname(os.path.dirname(os.path.realpath(__file__))), + 'tests', + 'dags') +if os.path.exists(_TEST_DAGS_FOLDER): + TEST_DAGS_FOLDER = _TEST_DAGS_FOLDER +else: + TEST_DAGS_FOLDER = os.path.join(AIRFLOW_HOME, 'dags') + +# Set up plugins folder for unit tests +_TEST_PLUGINS_FOLDER = os.path.join( + os.path.dirname(os.path.dirname(os.path.realpath(__file__))), + 'tests', + 'plugins') +if os.path.exists(_TEST_PLUGINS_FOLDER): + TEST_PLUGINS_FOLDER = _TEST_PLUGINS_FOLDER +else: + TEST_PLUGINS_FOLDER = os.path.join(AIRFLOW_HOME, 'plugins') + + +def parameterized_config(template): + """ + Generates a configuration from the provided template + variables defined in + current scope + :param template: a config content templated with {{variables}} + """ + all_vars = {k: v for d in [globals(), locals()] for k, v in d.items()} + return template.format(**all_vars) + + +TEST_CONFIG_FILE = AIRFLOW_HOME + '/unittests.cfg' + +# only generate a Fernet key if we need to create a new config file +if not os.path.isfile(TEST_CONFIG_FILE) or not os.path.isfile(AIRFLOW_CONFIG): + FERNET_KEY = generate_fernet_key() +else: + FERNET_KEY = '' + +TEMPLATE_START = ( + '# ----------------------- TEMPLATE BEGINS HERE -----------------------') +if not os.path.isfile(TEST_CONFIG_FILE): + log.info( + 'Creating new Airflow config file for unit tests in: %s', TEST_CONFIG_FILE + ) + with open(TEST_CONFIG_FILE, 'w') as f: + cfg = parameterized_config(TEST_CONFIG) + f.write(cfg.split(TEMPLATE_START)[-1].strip()) +if not os.path.isfile(AIRFLOW_CONFIG): + log.info( + 'Creating new Airflow config file in: %s', + AIRFLOW_CONFIG + ) + with open(AIRFLOW_CONFIG, 'w') as f: + cfg = parameterized_config(DEFAULT_CONFIG) + f.write(cfg.split(TEMPLATE_START)[-1].strip()) + +log.info("Reading the config from %s", AIRFLOW_CONFIG) + +conf = AirflowConfigParser() +conf.read(AIRFLOW_CONFIG) + + +def load_test_config(): + """ + Load the unit test configuration. + + Note: this is not reversible. + """ + conf.load_test_config() + +if conf.getboolean('core', 'unit_test_mode'): + load_test_config() + + +def get(section, key, **kwargs): + return conf.get(section, key, **kwargs) + + +def getboolean(section, key): + return conf.getboolean(section, key) + + +def getfloat(section, key): + return conf.getfloat(section, key) + + +def getint(section, key): + return conf.getint(section, key) + + +def has_option(section, key): + return conf.has_option(section, key) + + +def remove_option(section, option): + return conf.remove_option(section, option) + + +def as_dict(display_source=False, display_sensitive=False): + return conf.as_dict( + display_source=display_source, display_sensitive=display_sensitive) +as_dict.__doc__ = conf.as_dict.__doc__ + + +def set(section, option, value): # noqa + return conf.set(section, option, value) diff --git a/airflow/contrib/__init__.py b/airflow/contrib/__init__.py new file mode 100644 index 0000000000000..c82f5790fe906 --- /dev/null +++ b/airflow/contrib/__init__.py @@ -0,0 +1,14 @@ +# -*- coding: utf-8 -*- +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + diff --git a/airflow/contrib/auth/__init__.py b/airflow/contrib/auth/__init__.py new file mode 100644 index 0000000000000..c82f5790fe906 --- /dev/null +++ b/airflow/contrib/auth/__init__.py @@ -0,0 +1,14 @@ +# -*- coding: utf-8 -*- +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + diff --git a/airflow/contrib/auth/backends/__init__.py b/airflow/contrib/auth/backends/__init__.py new file mode 100644 index 0000000000000..c82f5790fe906 --- /dev/null +++ b/airflow/contrib/auth/backends/__init__.py @@ -0,0 +1,14 @@ +# -*- coding: utf-8 -*- +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + diff --git a/airflow/contrib/auth/backends/github_enterprise_auth.py b/airflow/contrib/auth/backends/github_enterprise_auth.py new file mode 100644 index 0000000000000..28c3cfc8405af --- /dev/null +++ b/airflow/contrib/auth/backends/github_enterprise_auth.py @@ -0,0 +1,236 @@ +# Copyright 2015 Matthew Pelland (matt@pelland.io) +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +import flask_login + +# Need to expose these downstream +# pylint: disable=unused-import +from flask_login import (current_user, + logout_user, + login_required, + login_user) +# pylint: enable=unused-import + +from flask import url_for, redirect, request + +from flask_oauthlib.client import OAuth + +from airflow import models, configuration, settings +from airflow.configuration import AirflowConfigException +from airflow.utils.log.logging_mixin import LoggingMixin + +log = LoggingMixin().log + + +def get_config_param(param): + return str(configuration.get('github_enterprise', param)) + + +class GHEUser(models.User): + + def __init__(self, user): + self.user = user + + def is_active(self): + '''Required by flask_login''' + return True + + def is_authenticated(self): + '''Required by flask_login''' + return True + + def is_anonymous(self): + '''Required by flask_login''' + return False + + def get_id(self): + '''Returns the current user id as required by flask_login''' + return self.user.get_id() + + def data_profiling(self): + '''Provides access to data profiling tools''' + return True + + def is_superuser(self): + '''Access all the things''' + return True + + +class AuthenticationError(Exception): + pass + + +class GHEAuthBackend(object): + + def __init__(self): + self.ghe_host = get_config_param('host') + self.login_manager = flask_login.LoginManager() + self.login_manager.login_view = 'airflow.login' + self.flask_app = None + self.ghe_oauth = None + self.api_rev = None + + def ghe_api_route(self, leaf): + if not self.api_rev: + self.api_rev = get_config_param('api_rev') + + return '/'.join(['https:/', + self.ghe_host, + 'api', + self.api_rev, + leaf.strip('/')]) + + def init_app(self, flask_app): + self.flask_app = flask_app + + self.login_manager.init_app(self.flask_app) + + self.ghe_oauth = OAuth(self.flask_app).remote_app( + 'ghe', + consumer_key=get_config_param('client_id'), + consumer_secret=get_config_param('client_secret'), + # need read:org to get team member list + request_token_params={'scope': 'user:email,read:org'}, + base_url=self.ghe_host, + request_token_url=None, + access_token_method='POST', + access_token_url=''.join(['https://', + self.ghe_host, + '/login/oauth/access_token']), + authorize_url=''.join(['https://', + self.ghe_host, + '/login/oauth/authorize'])) + + self.login_manager.user_loader(self.load_user) + + self.flask_app.add_url_rule(get_config_param('oauth_callback_route'), + 'ghe_oauth_callback', + self.oauth_callback) + + def login(self, request): + _log.debug('Redirecting user to GHE login') + return self.ghe_oauth.authorize(callback=url_for( + 'ghe_oauth_callback', + _external=True, + next=request.args.get('next') or request.referrer or None)) + + def get_ghe_user_profile_info(self, ghe_token): + resp = self.ghe_oauth.get(self.ghe_api_route('/user'), + token=(ghe_token, '')) + + if not resp or resp.status != 200: + raise AuthenticationError( + 'Failed to fetch user profile, status ({0})'.format( + resp.status if resp else 'None')) + + return resp.data['login'], resp.data['email'] + + def ghe_team_check(self, username, ghe_token): + try: + # the response from ghe returns the id of the team as an integer + try: + allowed_teams = [int(team.strip()) + for team in + get_config_param('allowed_teams').split(',')] + except ValueError: + # this is to deprecate using the string name for a team + raise ValueError('it appears that you are using the string name for a team, ' + 'please use the id number instead') + + except AirflowConfigException: + # No allowed teams defined, let anyone in GHE in. + return True + + # https://developer.github.com/v3/orgs/teams/#list-user-teams + resp = self.ghe_oauth.get(self.ghe_api_route('/user/teams'), + token=(ghe_token, '')) + + if not resp or resp.status != 200: + raise AuthenticationError( + 'Bad response from GHE ({0})'.format( + resp.status if resp else 'None')) + + for team in resp.data: + # mylons: previously this line used to be if team['slug'] in teams + # however, teams are part of organizations. organizations are unique, + # but teams are not therefore 'slug' for a team is not necessarily unique. + # use id instead + if team['id'] in allowed_teams: + return True + + _log.debug('Denying access for user "%s", not a member of "%s"', + username, + str(allowed_teams)) + + return False + + def load_user(self, userid): + if not userid or userid == 'None': + return None + + session = settings.Session() + user = session.query(models.User).filter( + models.User.id == int(userid)).first() + session.expunge_all() + session.commit() + session.close() + return GHEUser(user) + + def oauth_callback(self): + _log.debug('GHE OAuth callback called') + + next_url = request.args.get('next') or url_for('admin.index') + + resp = self.ghe_oauth.authorized_response() + + try: + if resp is None: + raise AuthenticationError( + 'Null response from GHE, denying access.' + ) + + ghe_token = resp['access_token'] + + username, email = self.get_ghe_user_profile_info(ghe_token) + + if not self.ghe_team_check(username, ghe_token): + return redirect(url_for('airflow.noaccess')) + + except AuthenticationError: + _log.exception('') + return redirect(url_for('airflow.noaccess')) + + session = settings.Session() + + user = session.query(models.User).filter( + models.User.username == username).first() + + if not user: + user = models.User( + username=username, + email=email, + is_superuser=False) + + session.merge(user) + session.commit() + login_user(GHEUser(user)) + session.commit() + session.close() + + return redirect(next_url) + +login_manager = GHEAuthBackend() + + +def login(self, request): + return login_manager.login(request) diff --git a/airflow/contrib/auth/backends/google_auth.py b/airflow/contrib/auth/backends/google_auth.py new file mode 100644 index 0000000000000..e6eab944a1a78 --- /dev/null +++ b/airflow/contrib/auth/backends/google_auth.py @@ -0,0 +1,189 @@ +# Copyright 2016 Ananya Mishra (am747@cornell.edu) +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +import flask_login + +# Need to expose these downstream +# pylint: disable=unused-import +from flask_login import (current_user, + logout_user, + login_required, + login_user) +# pylint: enable=unused-import + +from flask import url_for, redirect, request + +from flask_oauthlib.client import OAuth + +from airflow import models, configuration, settings +from airflow.utils.log.logging_mixin import LoggingMixin + +log = LoggingMixin().log + + +def get_config_param(param): + return str(configuration.get('google', param)) + + +class GoogleUser(models.User): + + def __init__(self, user): + self.user = user + + def is_active(self): + '''Required by flask_login''' + return True + + def is_authenticated(self): + '''Required by flask_login''' + return True + + def is_anonymous(self): + '''Required by flask_login''' + return False + + def get_id(self): + '''Returns the current user id as required by flask_login''' + return self.user.get_id() + + def data_profiling(self): + '''Provides access to data profiling tools''' + return True + + def is_superuser(self): + '''Access all the things''' + return True + + +class AuthenticationError(Exception): + pass + + +class GoogleAuthBackend(object): + + def __init__(self): + # self.google_host = get_config_param('host') + self.login_manager = flask_login.LoginManager() + self.login_manager.login_view = 'airflow.login' + self.flask_app = None + self.google_oauth = None + self.api_rev = None + + def init_app(self, flask_app): + self.flask_app = flask_app + + self.login_manager.init_app(self.flask_app) + + self.google_oauth = OAuth(self.flask_app).remote_app( + 'google', + consumer_key=get_config_param('client_id'), + consumer_secret=get_config_param('client_secret'), + request_token_params={'scope': [ + 'https://www.googleapis.com/auth/userinfo.profile', + 'https://www.googleapis.com/auth/userinfo.email']}, + base_url='https://www.google.com/accounts/', + request_token_url=None, + access_token_method='POST', + access_token_url='https://accounts.google.com/o/oauth2/token', + authorize_url='https://accounts.google.com/o/oauth2/auth') + + self.login_manager.user_loader(self.load_user) + + self.flask_app.add_url_rule(get_config_param('oauth_callback_route'), + 'google_oauth_callback', + self.oauth_callback) + + def login(self, request): + log.debug('Redirecting user to Google login') + return self.google_oauth.authorize(callback=url_for( + 'google_oauth_callback', + _external=True, + next=request.args.get('next') or request.referrer or None)) + + def get_google_user_profile_info(self, google_token): + resp = self.google_oauth.get('https://www.googleapis.com/oauth2/v1/userinfo', + token=(google_token, '')) + + if not resp or resp.status != 200: + raise AuthenticationError( + 'Failed to fetch user profile, status ({0})'.format( + resp.status if resp else 'None')) + + return resp.data['name'], resp.data['email'] + + def domain_check(self, email): + domain = email.split('@')[1] + if domain == get_config_param('domain'): + return True + return False + + def load_user(self, userid): + if not userid or userid == 'None': + return None + + session = settings.Session() + user = session.query(models.User).filter( + models.User.id == int(userid)).first() + session.expunge_all() + session.commit() + session.close() + return GoogleUser(user) + + def oauth_callback(self): + log.debug('Google OAuth callback called') + + next_url = request.args.get('next') or url_for('admin.index') + + resp = self.google_oauth.authorized_response() + + try: + if resp is None: + raise AuthenticationError( + 'Null response from Google, denying access.' + ) + + google_token = resp['access_token'] + + username, email = self.get_google_user_profile_info(google_token) + + if not self.domain_check(email): + return redirect(url_for('airflow.noaccess')) + + except AuthenticationError: + return redirect(url_for('airflow.noaccess')) + + session = settings.Session() + + user = session.query(models.User).filter( + models.User.username == username).first() + + if not user: + user = models.User( + username=username, + email=email, + is_superuser=False) + + session.merge(user) + session.commit() + login_user(GoogleUser(user)) + session.commit() + session.close() + + return redirect(next_url) + +login_manager = GoogleAuthBackend() + + +def login(self, request): + return login_manager.login(request) + diff --git a/airflow/contrib/auth/backends/kerberos_auth.py b/airflow/contrib/auth/backends/kerberos_auth.py new file mode 100644 index 0000000000000..908ebc90e0f99 --- /dev/null +++ b/airflow/contrib/auth/backends/kerberos_auth.py @@ -0,0 +1,148 @@ +# -*- coding: utf-8 -*- +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import flask_login +from flask_login import login_required, current_user, logout_user +from flask import flash +from wtforms import ( + Form, PasswordField, StringField) +from wtforms.validators import InputRequired + +# pykerberos should be used as it verifies the KDC, the "kerberos" module does not do so +# and make it possible to spoof the KDC +import kerberos +import airflow.security.utils as utils + +from flask import url_for, redirect + +from airflow import settings +from airflow import models +from airflow import configuration +from airflow.utils.log.logging_mixin import LoggingMixin + +login_manager = flask_login.LoginManager() +login_manager.login_view = 'airflow.login' # Calls login() below +login_manager.login_message = None + + +class AuthenticationError(Exception): + pass + + +class KerberosUser(models.User, LoggingMixin): + def __init__(self, user): + self.user = user + + @staticmethod + def authenticate(username, password): + service_principal = "%s/%s" % (configuration.get('kerberos', 'principal'), utils.get_fqdn()) + realm = configuration.get("kerberos", "default_realm") + user_principal = utils.principal_from_username(username) + + try: + # this is pykerberos specific, verify = True is needed to prevent KDC spoofing + if not kerberos.checkPassword(user_principal, password, service_principal, realm, True): + raise AuthenticationError() + except kerberos.KrbError as e: + logging.error('Password validation for principal %s failed %s', user_principal, e) + raise AuthenticationError(e) + + return + + def is_active(self): + '''Required by flask_login''' + return True + + def is_authenticated(self): + '''Required by flask_login''' + return True + + def is_anonymous(self): + '''Required by flask_login''' + return False + + def get_id(self): + '''Returns the current user id as required by flask_login''' + return self.user.get_id() + + def data_profiling(self): + '''Provides access to data profiling tools''' + return True + + def is_superuser(self): + '''Access all the things''' + return True + + +@login_manager.user_loader +def load_user(userid): + if not userid or userid == 'None': + return None + + session = settings.Session() + user = session.query(models.User).filter(models.User.id == int(userid)).first() + session.expunge_all() + session.commit() + session.close() + return KerberosUser(user) + + +def login(self, request): + if current_user.is_authenticated(): + flash("You are already logged in") + return redirect(url_for('index')) + + username = None + password = None + + form = LoginForm(request.form) + + if request.method == 'POST' and form.validate(): + username = request.form.get("username") + password = request.form.get("password") + + if not username or not password: + return self.render('airflow/login.html', + title="Airflow - Login", + form=form) + + try: + KerberosUser.authenticate(username, password) + + session = settings.Session() + user = session.query(models.User).filter( + models.User.username == username).first() + + if not user: + user = models.User( + username=username, + is_superuser=False) + + session.merge(user) + session.commit() + flask_login.login_user(KerberosUser(user)) + session.commit() + session.close() + + return redirect(request.args.get("next") or url_for("admin.index")) + except AuthenticationError: + flash("Incorrect login details") + return self.render('airflow/login.html', + title="Airflow - Login", + form=form) + + +class LoginForm(Form): + username = StringField('Username', [InputRequired()]) + password = PasswordField('Password', [InputRequired()]) diff --git a/airflow/contrib/auth/backends/ldap_auth.py b/airflow/contrib/auth/backends/ldap_auth.py new file mode 100644 index 0000000000000..df29de3db6867 --- /dev/null +++ b/airflow/contrib/auth/backends/ldap_auth.py @@ -0,0 +1,321 @@ +# -*- coding: utf-8 -*- +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +from future.utils import native + +import flask_login +from flask_login import login_required, current_user, logout_user +from flask import flash +from wtforms import ( + Form, PasswordField, StringField) +from wtforms.validators import InputRequired + +from ldap3 import Server, Connection, Tls, LEVEL, SUBTREE, BASE +import ssl + +from flask import url_for, redirect + +from airflow import settings +from airflow import models +from airflow import configuration +from airflow.configuration import AirflowConfigException + +import traceback +import re + +from airflow.utils.log.logging_mixin import LoggingMixin + +login_manager = flask_login.LoginManager() +login_manager.login_view = 'airflow.login' # Calls login() below +login_manager.login_message = None + +log = LoggingMixin().log + + +class AuthenticationError(Exception): + pass + + +class LdapException(Exception): + pass + + +def get_ldap_connection(dn=None, password=None): + tls_configuration = None + use_ssl = False + try: + cacert = configuration.get("ldap", "cacert") + tls_configuration = Tls(validate=ssl.CERT_REQUIRED, ca_certs_file=cacert) + use_ssl = True + except: + pass + + server = Server(configuration.get("ldap", "uri"), use_ssl, tls_configuration) + conn = Connection(server, native(dn), native(password)) + + if not conn.bind(): + log.error("Cannot bind to ldap server: %s ", conn.last_error) + raise AuthenticationError("Cannot bind to ldap server") + + return conn + + +def group_contains_user(conn, search_base, group_filter, user_name_attr, username): + search_filter = '(&({0}))'.format(group_filter) + if not conn.search(native(search_base), native(search_filter), + attributes=[native(user_name_attr)]): + log.warning("Unable to find group for %s %s", search_base, search_filter) + else: + for resp in conn.response: + if ( + 'attributes' in resp and ( + resp['attributes'].get(user_name_attr)[0] == username or + resp['attributes'].get(user_name_attr) == username + ) + ): + return True + return False + + +def groups_user(conn, search_base, user_filter, user_name_att, username): + search_filter = "(&({0})({1}={2}))".format(user_filter, user_name_att, username) + try: + memberof_attr = configuration.get("ldap", "group_member_attr") + except: + memberof_attr = "memberOf" + res = conn.search(native(search_base), native(search_filter), + attributes=[native(memberof_attr)]) + if not res: + log.info("Cannot find user %s", username) + raise AuthenticationError("Invalid username or password") + + if conn.response and memberof_attr not in conn.response[0]["attributes"]: + log.warning("""Missing attribute "%s" when looked-up in Ldap database. + The user does not seem to be a member of a group and therefore won't see any dag + if the option filter_by_owner=True and owner_mode=ldapgroup are set""", + memberof_attr) + return [] + + user_groups = conn.response[0]["attributes"][memberof_attr] + + regex = re.compile("cn=([^,]*).*", re.IGNORECASE) + groups_list = [] + try: + groups_list = [regex.search(i).group(1) for i in user_groups] + except IndexError: + log.warning("Parsing error when retrieving the user's group(s)." + " Check if the user belongs to at least one group" + " or if the user's groups name do not contain special characters") + + return groups_list + + +class LdapUser(models.User): + def __init__(self, user): + self.user = user + self.ldap_groups = [] + + # Load and cache superuser and data_profiler settings. + conn = get_ldap_connection(configuration.get("ldap", "bind_user"), + configuration.get("ldap", "bind_password")) + + superuser_filter = None + data_profiler_filter = None + try: + superuser_filter = configuration.get("ldap", "superuser_filter") + except AirflowConfigException: + pass + + if not superuser_filter: + self.superuser = True + log.debug("Missing configuration for superuser settings or empty. Skipping.") + else: + self.superuser = group_contains_user(conn, + configuration.get("ldap", "basedn"), + superuser_filter, + configuration.get("ldap", + "user_name_attr"), + user.username) + + try: + data_profiler_filter = configuration.get("ldap", "data_profiler_filter") + except AirflowConfigException: + pass + + if not data_profiler_filter: + self.data_profiler = True + log.debug("Missing configuration for data profiler settings or empty. " + "Skipping.") + else: + self.data_profiler = group_contains_user(conn, + configuration.get("ldap", "basedn"), + data_profiler_filter, + configuration.get("ldap", + "user_name_attr"), + user.username) + + # Load the ldap group(s) a user belongs to + try: + self.ldap_groups = groups_user(conn, + configuration.get("ldap", "basedn"), + configuration.get("ldap", "user_filter"), + configuration.get("ldap", "user_name_attr"), + user.username) + except AirflowConfigException: + log.debug("Missing configuration for ldap settings. Skipping") + + @staticmethod + def try_login(username, password): + conn = get_ldap_connection(configuration.get("ldap", "bind_user"), + configuration.get("ldap", "bind_password")) + + search_filter = "(&({0})({1}={2}))".format( + configuration.get("ldap", "user_filter"), + configuration.get("ldap", "user_name_attr"), + username + ) + + search_scopes = { + "LEVEL": LEVEL, + "SUBTREE": SUBTREE, + "BASE": BASE + } + + search_scope = LEVEL + if configuration.has_option("ldap", "search_scope"): + search_scope = SUBTREE if configuration.get("ldap", "search_scope") == "SUBTREE" else LEVEL + + # todo: BASE or ONELEVEL? + + res = conn.search(native(configuration.get("ldap", "basedn")), + native(search_filter), + search_scope=native(search_scope)) + + # todo: use list or result? + if not res: + log.info("Cannot find user %s", username) + raise AuthenticationError("Invalid username or password") + + entry = conn.response[0] + + conn.unbind() + + if 'dn' not in entry: + # The search filter for the user did not return any values, so an + # invalid user was used for credentials. + raise AuthenticationError("Invalid username or password") + + try: + conn = get_ldap_connection(entry['dn'], password) + except KeyError as e: + log.error(""" + Unable to parse LDAP structure. If you're using Active Directory and not specifying an OU, you must set search_scope=SUBTREE in airflow.cfg. + %s + """ % traceback.format_exc()) + raise LdapException("Could not parse LDAP structure. Try setting search_scope in airflow.cfg, or check logs") + + if not conn: + log.info("Password incorrect for user %s", username) + raise AuthenticationError("Invalid username or password") + + def is_active(self): + '''Required by flask_login''' + return True + + def is_authenticated(self): + '''Required by flask_login''' + return True + + def is_anonymous(self): + '''Required by flask_login''' + return False + + def get_id(self): + '''Returns the current user id as required by flask_login''' + return self.user.get_id() + + def data_profiling(self): + '''Provides access to data profiling tools''' + return self.data_profiler + + def is_superuser(self): + '''Access all the things''' + return self.superuser + + +@login_manager.user_loader +def load_user(userid): + log.debug("Loading user %s", userid) + if not userid or userid == 'None': + return None + + session = settings.Session() + user = session.query(models.User).filter(models.User.id == int(userid)).first() + session.expunge_all() + session.commit() + session.close() + return LdapUser(user) + + +def login(self, request): + if current_user.is_authenticated(): + flash("You are already logged in") + return redirect(url_for('admin.index')) + + username = None + password = None + + form = LoginForm(request.form) + + if request.method == 'POST' and form.validate(): + username = request.form.get("username") + password = request.form.get("password") + + if not username or not password: + return self.render('airflow/login.html', + title="Airflow - Login", + form=form) + + try: + LdapUser.try_login(username, password) + log.info("User %s successfully authenticated", username) + + session = settings.Session() + user = session.query(models.User).filter( + models.User.username == username).first() + + if not user: + user = models.User( + username=username, + is_superuser=False) + + session.merge(user) + session.commit() + flask_login.login_user(LdapUser(user)) + session.commit() + session.close() + + return redirect(request.args.get("next") or url_for("admin.index")) + except (LdapException, AuthenticationError) as e: + if type(e) == LdapException: + flash(e, "error") + else: + flash("Incorrect login details") + return self.render('airflow/login.html', + title="Airflow - Login", + form=form) + + +class LoginForm(Form): + username = StringField('Username', [InputRequired()]) + password = PasswordField('Password', [InputRequired()]) diff --git a/airflow/contrib/auth/backends/password_auth.py b/airflow/contrib/auth/backends/password_auth.py new file mode 100644 index 0000000000000..8adb1f404d53f --- /dev/null +++ b/airflow/contrib/auth/backends/password_auth.py @@ -0,0 +1,154 @@ +# -*- coding: utf-8 -*- +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from __future__ import unicode_literals + +from sys import version_info + +import flask_login +from flask_login import login_required, current_user, logout_user +from flask import flash +from wtforms import ( + Form, PasswordField, StringField) +from wtforms.validators import InputRequired + +from flask import url_for, redirect +from flask_bcrypt import generate_password_hash, check_password_hash + +from sqlalchemy import ( + Column, String, DateTime) +from sqlalchemy.ext.hybrid import hybrid_property + +from airflow import settings +from airflow import models +from airflow.utils.log.logging_mixin import LoggingMixin + +login_manager = flask_login.LoginManager() +login_manager.login_view = 'airflow.login' # Calls login() below +login_manager.login_message = None + +log = LoggingMixin().log +PY3 = version_info[0] == 3 + + +class AuthenticationError(Exception): + pass + + +class PasswordUser(models.User): + _password = Column('password', String(255)) + + def __init__(self, user): + self.user = user + + @hybrid_property + def password(self): + return self._password + + @password.setter + def _set_password(self, plaintext): + self._password = generate_password_hash(plaintext, 12) + if PY3: + self._password = str(self._password, 'utf-8') + + def authenticate(self, plaintext): + return check_password_hash(self._password, plaintext) + + def is_active(self): + '''Required by flask_login''' + return True + + def is_authenticated(self): + '''Required by flask_login''' + return True + + def is_anonymous(self): + '''Required by flask_login''' + return False + + def get_id(self): + '''Returns the current user id as required by flask_login''' + return str(self.id) + + def data_profiling(self): + '''Provides access to data profiling tools''' + return True + + def is_superuser(self): + '''Access all the things''' + return True + + +@login_manager.user_loader +def load_user(userid): + log.debug("Loading user %s", userid) + if not userid or userid == 'None': + return None + + session = settings.Session() + user = session.query(models.User).filter(models.User.id == int(userid)).first() + session.expunge_all() + session.commit() + session.close() + return PasswordUser(user) + + +def login(self, request): + if current_user.is_authenticated(): + flash("You are already logged in") + return redirect(url_for('admin.index')) + + username = None + password = None + + form = LoginForm(request.form) + + if request.method == 'POST' and form.validate(): + username = request.form.get("username") + password = request.form.get("password") + + if not username or not password: + return self.render('airflow/login.html', + title="Airflow - Login", + form=form) + + try: + session = settings.Session() + user = session.query(PasswordUser).filter( + PasswordUser.username == username).first() + + if not user: + session.close() + raise AuthenticationError() + + if not user.authenticate(password): + session.close() + raise AuthenticationError() + log.info("User %s successfully authenticated", username) + + flask_login.login_user(user) + session.commit() + session.close() + + return redirect(request.args.get("next") or url_for("admin.index")) + except AuthenticationError: + flash("Incorrect login details") + return self.render('airflow/login.html', + title="Airflow - Login", + form=form) + + +class LoginForm(Form): + username = StringField('Username', [InputRequired()]) + password = PasswordField('Password', [InputRequired()]) diff --git a/airflow/contrib/example_dags/example_databricks_operator.py b/airflow/contrib/example_dags/example_databricks_operator.py new file mode 100644 index 0000000000000..abf6844d900bc --- /dev/null +++ b/airflow/contrib/example_dags/example_databricks_operator.py @@ -0,0 +1,82 @@ +# -*- coding: utf-8 -*- +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import airflow + +from airflow import DAG +from airflow.contrib.operators.databricks_operator import DatabricksSubmitRunOperator + +# This is an example DAG which uses the DatabricksSubmitRunOperator. +# In this example, we create two tasks which execute sequentially. +# The first task is to run a notebook at the workspace path "/test" +# and the second task is to run a JAR uploaded to DBFS. Both, +# tasks use new clusters. +# +# Because we have set a downstream dependency on the notebook task, +# the spark jar task will NOT run until the notebook task completes +# successfully. +# +# The definition of a succesful run is if the run has a result_state of "SUCCESS". +# For more information about the state of a run refer to +# https://docs.databricks.com/api/latest/jobs.html#runstate + +args = { + 'owner': 'airflow', + 'email': ['airflow@example.com'], + 'depends_on_past': False, + 'start_date': airflow.utils.dates.days_ago(2) +} + +dag = DAG( + dag_id='example_databricks_operator', default_args=args, + schedule_interval='@daily') + +new_cluster = { + 'spark_version': '2.1.0-db3-scala2.11', + 'node_type_id': 'r3.xlarge', + 'aws_attributes': { + 'availability': 'ON_DEMAND' + }, + 'num_workers': 8 +} + +notebook_task_params = { + 'new_cluster': new_cluster, + 'notebook_task': { + 'notebook_path': '/Users/airflow@example.com/PrepareData', + }, +} +# Example of using the JSON parameter to initialize the operator. +notebook_task = DatabricksSubmitRunOperator( + task_id='notebook_task', + dag=dag, + json=notebook_task_params) + +# Example of using the named parameters of DatabricksSubmitRunOperator +# to initialize the operator. +spark_jar_task = DatabricksSubmitRunOperator( + task_id='spark_jar_task', + dag=dag, + new_cluster=new_cluster, + spark_jar_task={ + 'main_class_name': 'com.example.ProcessData' + }, + libraries=[ + { + 'jar': 'dbfs:/lib/etl-0.1.jar' + } + ] +) + +notebook_task.set_downstream(spark_jar_task) diff --git a/airflow/contrib/example_dags/example_emr_job_flow_automatic_steps.py b/airflow/contrib/example_dags/example_emr_job_flow_automatic_steps.py new file mode 100644 index 0000000000000..b03b36fb146c5 --- /dev/null +++ b/airflow/contrib/example_dags/example_emr_job_flow_automatic_steps.py @@ -0,0 +1,72 @@ +# -*- coding: utf-8 -*- +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from datetime import timedelta +import airflow +from airflow import DAG +from airflow.contrib.operators.emr_create_job_flow_operator import EmrCreateJobFlowOperator +from airflow.contrib.sensors.emr_job_flow_sensor import EmrJobFlowSensor + +DEFAULT_ARGS = { + 'owner': 'airflow', + 'depends_on_past': False, + 'start_date': airflow.utils.dates.days_ago(2), + 'email': ['airflow@example.com'], + 'email_on_failure': False, + 'email_on_retry': False +} + +SPARK_TEST_STEPS = [ + { + 'Name': 'calculate_pi', + 'ActionOnFailure': 'CONTINUE', + 'HadoopJarStep': { + 'Jar': 'command-runner.jar', + 'Args': [ + '/usr/lib/spark/bin/run-example', + 'SparkPi', + '10' + ] + } + } +] + +JOB_FLOW_OVERRIDES = { + 'Name': 'PiCalc', + 'Steps': SPARK_TEST_STEPS +} + +dag = DAG( + 'emr_job_flow_automatic_steps_dag', + default_args=DEFAULT_ARGS, + dagrun_timeout=timedelta(hours=2), + schedule_interval='0 3 * * *' +) + +job_flow_creator = EmrCreateJobFlowOperator( + task_id='create_job_flow', + job_flow_overrides=JOB_FLOW_OVERRIDES, + aws_conn_id='aws_default', + emr_conn_id='emr_default', + dag=dag +) + +job_sensor = EmrJobFlowSensor( + task_id='check_job_flow', + job_flow_id="{{ task_instance.xcom_pull('create_job_flow', key='return_value') }}", + aws_conn_id='aws_default', + dag=dag +) + +job_flow_creator.set_downstream(job_sensor) diff --git a/airflow/contrib/example_dags/example_emr_job_flow_manual_steps.py b/airflow/contrib/example_dags/example_emr_job_flow_manual_steps.py new file mode 100644 index 0000000000000..d123be0b8ec84 --- /dev/null +++ b/airflow/contrib/example_dags/example_emr_job_flow_manual_steps.py @@ -0,0 +1,93 @@ +# -*- coding: utf-8 -*- +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from datetime import timedelta + +import airflow +from airflow import DAG +from airflow.contrib.operators.emr_create_job_flow_operator import EmrCreateJobFlowOperator +from airflow.contrib.operators.emr_add_steps_operator import EmrAddStepsOperator +from airflow.contrib.sensors.emr_step_sensor import EmrStepSensor +from airflow.contrib.operators.emr_terminate_job_flow_operator import EmrTerminateJobFlowOperator + +DEFAULT_ARGS = { + 'owner': 'airflow', + 'depends_on_past': False, + 'start_date': airflow.utils.dates.days_ago(2), + 'email': ['airflow@example.com'], + 'email_on_failure': False, + 'email_on_retry': False +} + +SPARK_TEST_STEPS = [ + { + 'Name': 'calculate_pi', + 'ActionOnFailure': 'CONTINUE', + 'HadoopJarStep': { + 'Jar': 'command-runner.jar', + 'Args': [ + '/usr/lib/spark/bin/run-example', + 'SparkPi', + '10' + ] + } + } +] + +JOB_FLOW_OVERRIDES = { + 'Name': 'PiCalc', + 'KeepJobFlowAliveWhenNoSteps': True +} + +dag = DAG( + 'emr_job_flow_manual_steps_dag', + default_args=DEFAULT_ARGS, + dagrun_timeout=timedelta(hours=2), + schedule_interval='0 3 * * *' +) + +cluster_creator = EmrCreateJobFlowOperator( + task_id='create_job_flow', + job_flow_overrides=JOB_FLOW_OVERRIDES, + aws_conn_id='aws_default', + emr_conn_id='emr_default', + dag=dag +) + +step_adder = EmrAddStepsOperator( + task_id='add_steps', + job_flow_id="{{ task_instance.xcom_pull('create_job_flow', key='return_value') }}", + aws_conn_id='aws_default', + steps=SPARK_TEST_STEPS, + dag=dag +) + +step_checker = EmrStepSensor( + task_id='watch_step', + job_flow_id="{{ task_instance.xcom_pull('create_job_flow', key='return_value') }}", + step_id="{{ task_instance.xcom_pull('add_steps', key='return_value')[0] }}", + aws_conn_id='aws_default', + dag=dag +) + +cluster_remover = EmrTerminateJobFlowOperator( + task_id='remove_cluster', + job_flow_id="{{ task_instance.xcom_pull('create_job_flow', key='return_value') }}", + aws_conn_id='aws_default', + dag=dag +) + +cluster_creator.set_downstream(step_adder) +step_adder.set_downstream(step_checker) +step_checker.set_downstream(cluster_remover) diff --git a/airflow/contrib/example_dags/example_qubole_operator.py b/airflow/contrib/example_dags/example_qubole_operator.py new file mode 100644 index 0000000000000..bf32788631510 --- /dev/null +++ b/airflow/contrib/example_dags/example_qubole_operator.py @@ -0,0 +1,194 @@ +# -*- coding: utf-8 -*- +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from airflow import DAG +from airflow.operators.dummy_operator import DummyOperator +from airflow.operators.python_operator import PythonOperator, BranchPythonOperator +from airflow.contrib.operators.qubole_operator import QuboleOperator +import filecmp +import random + + + +default_args = { + 'owner': 'airflow', + 'depends_on_past': False, + 'start_date': airflow.utils.dates.days_ago(2) + 'email': ['airflow@example.com'], + 'email_on_failure': False, + 'email_on_retry': False +} + +# NOTE:: This is only an example DAG to highlight usage of QuboleOperator in various scenarios, +# some of the tasks may or may not work based on your QDS account setup +dag = DAG('example_qubole_operator', default_args=default_args, schedule_interval='@daily') + +def compare_result(ds, **kwargs): + ti = kwargs['ti'] + r1 = t1.get_results(ti) + r2 = t2.get_results(ti) + return filecmp.cmp(r1, r2) + +t1 = QuboleOperator( + task_id='hive_show_table', + command_type='hivecmd', + query='show tables', + cluster_label='default', + fetch_logs=True, # If true, will fetch qubole command logs and concatenate them into corresponding airflow task logs + tags='aiflow_example_run', # To attach tags to qubole command, auto attach 3 tags - dag_id, task_id, run_id + qubole_conn_id='qubole_default', # Connection id to submit commands inside QDS, if not set "qubole_default" is used + dag=dag) + +t2 = QuboleOperator( + task_id='hive_s3_location', + command_type="hivecmd", + script_location="s3n://public-qubole/qbol-library/scripts/show_table.hql", + notfiy=True, + tags=['tag1', 'tag2'], + # If the script at s3 location has any qubole specific macros to be replaced + # macros='[{"date": "{{ ds }}"}, {"name" : "abc"}]', + trigger_rule="all_done", + dag=dag) + +t3 = PythonOperator( + task_id='compare_result', + provide_context=True, + python_callable=compare_result, + trigger_rule="all_done", + dag=dag) + +t3.set_upstream(t1) +t3.set_upstream(t2) + +options = ['hadoop_jar_cmd', 'presto_cmd', 'db_query', 'spark_cmd'] + +branching = BranchPythonOperator( + task_id='branching', + python_callable=lambda: random.choice(options), + dag=dag) +branching.set_upstream(t3) + +join = DummyOperator( + task_id='join', + trigger_rule='one_success', + dag=dag +) + +t4 = QuboleOperator( + task_id='hadoop_jar_cmd', + command_type='hadoopcmd', + sub_command='jar s3://paid-qubole/HadoopAPIExamples/jars/hadoop-0.20.1-dev-streaming.jar -mapper wc -numReduceTasks 0 -input s3://paid-qubole/HadoopAPITests/data/3.tsv -output s3://paid-qubole/HadoopAPITests/data/3_wc', + cluster_label='default', + fetch_logs=True, + dag=dag) + +t5 = QuboleOperator( + task_id='pig_cmd', + command_type="pigcmd", + script_location="s3://public-qubole/qbol-library/scripts/script1-hadoop-s3-small.pig", + parameters="key1=value1 key2=value2", + trigger_rule="all_done", + dag=dag) + +t4.set_upstream(branching) +t5.set_upstream(t4) +t5.set_downstream(join) + +t6 = QuboleOperator( + task_id='presto_cmd', + command_type='prestocmd', + query='show tables', + dag=dag) + +t7 = QuboleOperator( + task_id='shell_cmd', + command_type="shellcmd", + script_location="s3://public-qubole/qbol-library/scripts/shellx.sh", + parameters="param1 param2", + trigger_rule="all_done", + dag=dag) + +t6.set_upstream(branching) +t7.set_upstream(t6) +t7.set_downstream(join) + +t8 = QuboleOperator( + task_id='db_query', + command_type='dbtapquerycmd', + query='show tables', + db_tap_id=2064, + dag=dag) + +t9 = QuboleOperator( + task_id='db_export', + command_type='dbexportcmd', + mode=1, + hive_table='default_qubole_airline_origin_destination', + db_table='exported_airline_origin_destination', + partition_spec='dt=20110104-02', + dbtap_id=2064, + trigger_rule="all_done", + dag=dag) + +t8.set_upstream(branching) +t9.set_upstream(t8) +t9.set_downstream(join) + +t10 = QuboleOperator( + task_id='db_import', + command_type='dbimportcmd', + mode=1, + hive_table='default_qubole_airline_origin_destination', + db_table='exported_airline_origin_destination', + where_clause='id < 10', + db_parallelism=2, + dbtap_id=2064, + trigger_rule="all_done", + dag=dag) + +prog = ''' +import scala.math.random + +import org.apache.spark._ + +/** Computes an approximation to pi */ +object SparkPi { + def main(args: Array[String]) { + val conf = new SparkConf().setAppName("Spark Pi") + val spark = new SparkContext(conf) + val slices = if (args.length > 0) args(0).toInt else 2 + val n = math.min(100000L * slices, Int.MaxValue).toInt // avoid overflow + val count = spark.parallelize(1 until n, slices).map { i => + val x = random * 2 - 1 + val y = random * 2 - 1 + if (x*x + y*y < 1) 1 else 0 + }.reduce(_ + _) + println("Pi is roughly " + 4.0 * count / n) + spark.stop() + } +} +''' + +t11 = QuboleOperator( + task_id='spark_cmd', + command_type="sparkcmd", + program=prog, + language='scala', + arguments='--class SparkPi', + tags='aiflow_example_run', + dag=dag) + +t11.set_upstream(branching) +t11.set_downstream(t10) +t10.set_downstream(join) diff --git a/airflow/contrib/example_dags/example_twitter_README.md b/airflow/contrib/example_dags/example_twitter_README.md new file mode 100644 index 0000000000000..319eac39f6953 --- /dev/null +++ b/airflow/contrib/example_dags/example_twitter_README.md @@ -0,0 +1,36 @@ +# Example Twitter DAG + +***Introduction:*** This example dag depicts a typical ETL process and is a perfect use case automation scenario for Airflow. Please note that the main scripts associated with the tasks are returning None. The purpose of this DAG is to demonstrate how to write a functional DAG within Airflow. + +**Background:** Twitter is a social networking platform that enables users to send or broadcast short messages (140 Characters). A user has a user ID, i.e. JohnDoe, which is also known as a Twitter Handle. A short message, or tweet, can either be sent directed at another user using the @ symbol (i.e. @JohnDoe) or can be broadcast with a hashtag # followed by the topic name. *As most of the data on twitter is public, and twitter provides a generous API to retrieve these data, Twitter is the so called Gold Mine for Text Mining based data analytic.* This example DAG was driven out of our real use case, where we have used the SEARCH API from twitter to retrieve tweets from yesterday. The DAG is scheduled to run each day, and therefore works in an ETL fashion. + +***Overview:*** At first, we need tasks that will get the tweets of our interest and save them on the hard-disk. Then, we need subsequent tasks that will clean and analyze the tweets. Then we want to store these files into HDFS, and load them into a Data Warehousing platform like Hive or HBase. The main reason we have selected Hive here is because it gives us a familiar SQL like interface, and makes our life of writing different queries a lot easier. Finally, the DAG needs to store a summarized result to a traditional database, i.e. MySQL or PostgreSQL, which is used by a reporting or business intelligence application. In other words, we basically want to achieve the following steps: + +1. Fetch Tweets +2. Clean Tweets +3. Analyze Tweets +4. Put Tweets to HDFS +5. Load data to Hive +6. Save Summary to MySQL + +***Screenshot:*** + + +***Example Structure:*** In this example dag, we are collecting tweets for four users account or twitter handle. Each twitter handle has two channels, incoming tweets and outgoing tweets. Hence, in this example, by running the fetch_tweet task, we should have eight output files. For better management, each of the eight output files should be saved with the yesterday's date (we are collecting tweets from yesterday), i.e. toTwitter_A_2016-03-21.csv. We are using three kind of operators: PythonOperator, BashOperator, and HiveOperator. However, for this example only the Python scripts are stored externally. Hence this example DAG only has the following directory structure: + +The python functions here are just placeholders. In case you are interested to actually make this DAG fully functional, first start with filling out the scripts as separate files and importing them into the DAG with absolute or relative import. My approach was to store the retrieved data in memory using Pandas dataframe first, and then use the built in method to save the CSV file on hard-disk. +The eight different CSV files are then put into eight different folders within HDFS. Each of the newly inserted files are then loaded into eight different external hive tables. Hive tables can be external or internal. In this case, we are inserting the data right into the table, and so we are making our tables internal. Each file is inserted into the respected Hive table named after the twitter channel, i.e. toTwitter_A or fromTwitter_A. It is also important to note that when we created the tables, we facilitated for partitioning by date using the variable dt and declared comma as the row deliminator. The partitioning is very handy and ensures our query execution time remains constant even with growing volume of data. +As most probably these folders and hive tables doesn't exist in your system, you will get an error for these tasks within the DAG. If you rebuild a function DAG from this example, make sure those folders and hive tables exists. When you create the table, keep the consideration of table partitioning and declaring comma as the row deliminator in your mind. Furthermore, you may also need to skip headers on each read and ensure that the user under which you have Airflow running has the right permission access. Below is a sample HQL snippet on creating such table: +``` +CREATE TABLE toTwitter_A(id BIGINT, id_str STRING + created_at STRING, text STRING) + PARTITIONED BY (dt STRING) + ROW FORMAT DELIMITED FIELDS TERMINATED BY ',' + STORED AS TEXTFILE; + alter table toTwitter_A SET serdeproperties ('skip.header.line.count' = '1'); +``` +When you review the code for the DAG, you will notice that these tasks are generated using for loop. These two for loops could be combined into one loop. However, in most cases, you will be running different analysis on your incoming incoming and outgoing tweets, and hence they are kept separated in this example. +Final step is a running the broker script, brokerapi.py, which will run queries in Hive and store the summarized data to MySQL in our case. To connect to Hive, pyhs2 library is extremely useful and easy to use. To insert data into MySQL from Python, sqlalchemy is also a good one to use. +I hope you find this tutorial useful. If you have question feel free to ask me on [Twitter](https://twitter.com/EkhtiarSyed) or via the live Airflow chatroom room in [Gitter](https://gitter.im/airbnb/airflow).

+-Ekhtiar Syed +Last Update: 8-April-2016 diff --git a/airflow/contrib/example_dags/example_twitter_dag.py b/airflow/contrib/example_dags/example_twitter_dag.py new file mode 100644 index 0000000000000..2205b9a108164 --- /dev/null +++ b/airflow/contrib/example_dags/example_twitter_dag.py @@ -0,0 +1,185 @@ +# -*- coding: utf-8 -*- +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# -------------------------------------------------------------------------------- +# Written By: Ekhtiar Syed +# Last Update: 8th April 2016 +# Caveat: This Dag will not run because of missing scripts. +# The purpose of this is to give you a sample of a real world example DAG! +# -------------------------------------------------------------------------------- + +# -------------------------------------------------------------------------------- +# Load The Dependencies +# -------------------------------------------------------------------------------- + +import airflow +from airflow import DAG +from airflow.operators.bash_operator import BashOperator +from airflow.operators.python_operator import PythonOperator +from airflow.operators.hive_operator import HiveOperator +from datetime import date, timedelta + +# -------------------------------------------------------------------------------- +# Create a few placeholder scripts. In practice these would be different python +# script files, which are imported in this section with absolute or relative imports +# -------------------------------------------------------------------------------- + + +def fetchtweets(): + return None + + +def cleantweets(): + return None + + +def analyzetweets(): + return None + + +def transfertodb(): + return None + + +# -------------------------------------------------------------------------------- +# set default arguments +# -------------------------------------------------------------------------------- + +default_args = { + 'owner': 'Ekhtiar', + 'depends_on_past': False, + 'start_date': airflow.utils.dates.days_ago(5), + 'email': ['airflow@example.com'], + 'email_on_failure': False, + 'email_on_retry': False, + 'retries': 1, + 'retry_delay': timedelta(minutes=5), + # 'queue': 'bash_queue', + # 'pool': 'backfill', + # 'priority_weight': 10, + # 'end_date': datetime(2016, 1, 1), +} + +dag = DAG( + 'example_twitter_dag', default_args=default_args, + schedule_interval="@daily") + +# -------------------------------------------------------------------------------- +# This task should call Twitter API and retrieve tweets from yesterday from and to +# for the four twitter users (Twitter_A,..,Twitter_D) There should be eight csv +# output files generated by this task and naming convention +# is direction(from or to)_twitterHandle_date.csv +# -------------------------------------------------------------------------------- + +fetch_tweets = PythonOperator( + task_id='fetch_tweets', + python_callable=fetchtweets, + dag=dag) + +# -------------------------------------------------------------------------------- +# Clean the eight files. In this step you can get rid of or cherry pick columns +# and different parts of the text +# -------------------------------------------------------------------------------- + +clean_tweets = PythonOperator( + task_id='clean_tweets', + python_callable=cleantweets, + dag=dag) + +clean_tweets.set_upstream(fetch_tweets) + +# -------------------------------------------------------------------------------- +# In this section you can use a script to analyze the twitter data. Could simply +# be a sentiment analysis through algorithms like bag of words or something more +# complicated. You can also take a look at Web Services to do such tasks +# -------------------------------------------------------------------------------- + +analyze_tweets = PythonOperator( + task_id='analyze_tweets', + python_callable=analyzetweets, + dag=dag) + +analyze_tweets.set_upstream(clean_tweets) + +# -------------------------------------------------------------------------------- +# Although this is the last task, we need to declare it before the next tasks as we +# will use set_downstream This task will extract summary from Hive data and store +# it to MySQL +# -------------------------------------------------------------------------------- + +hive_to_mysql = PythonOperator( + task_id='hive_to_mysql', + python_callable=transfertodb, + dag=dag) + +# -------------------------------------------------------------------------------- +# The following tasks are generated using for loop. The first task puts the eight +# csv files to HDFS. The second task loads these files from HDFS to respected Hive +# tables. These two for loops could be combined into one loop. However, in most cases, +# you will be running different analysis on your incoming incoming and outgoing tweets, +# and hence they are kept separated in this example. +# -------------------------------------------------------------------------------- + +from_channels = ['fromTwitter_A', 'fromTwitter_B', 'fromTwitter_C', 'fromTwitter_D'] +to_channels = ['toTwitter_A', 'toTwitter_B', 'toTwitter_C', 'toTwitter_D'] +yesterday = date.today() - timedelta(days=1) +dt = yesterday.strftime("%Y-%m-%d") +# define where you want to store the tweets csv file in your local directory +local_dir = "/tmp/" +# define the location where you want to store in HDFS +hdfs_dir = " /tmp/" + +for channel in to_channels: + + file_name = "to_" + channel + "_" + yesterday.strftime("%Y-%m-%d") + ".csv" + + load_to_hdfs = BashOperator( + task_id="put_" + channel + "_to_hdfs", + bash_command="HADOOP_USER_NAME=hdfs hadoop fs -put -f " + + local_dir + file_name + + hdfs_dir + channel + "/", + dag=dag) + + load_to_hdfs.set_upstream(analyze_tweets) + + load_to_hive = HiveOperator( + task_id="load_" + channel + "_to_hive", + hql="LOAD DATA INPATH '" + + hdfs_dir + channel + "/" + file_name + "' " + "INTO TABLE " + channel + " " + "PARTITION(dt='" + dt + "')", + dag=dag) + load_to_hive.set_upstream(load_to_hdfs) + load_to_hive.set_downstream(hive_to_mysql) + +for channel in from_channels: + file_name = "from_" + channel + "_" + yesterday.strftime("%Y-%m-%d") + ".csv" + load_to_hdfs = BashOperator( + task_id="put_" + channel + "_to_hdfs", + bash_command="HADOOP_USER_NAME=hdfs hadoop fs -put -f " + + local_dir + file_name + + hdfs_dir + channel + "/", + dag=dag) + + load_to_hdfs.set_upstream(analyze_tweets) + + load_to_hive = HiveOperator( + task_id="load_" + channel + "_to_hive", + hql="LOAD DATA INPATH '" + + hdfs_dir + channel + "/" + file_name + "' " + "INTO TABLE " + channel + " " + "PARTITION(dt='" + dt + "')", + dag=dag) + + load_to_hive.set_upstream(load_to_hdfs) + load_to_hive.set_downstream(hive_to_mysql) diff --git a/airflow/contrib/executors/__init__.py b/airflow/contrib/executors/__init__.py new file mode 100644 index 0000000000000..c82f5790fe906 --- /dev/null +++ b/airflow/contrib/executors/__init__.py @@ -0,0 +1,14 @@ +# -*- coding: utf-8 -*- +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + diff --git a/airflow/contrib/executors/mesos_executor.py b/airflow/contrib/executors/mesos_executor.py new file mode 100644 index 0000000000000..87285664a2f49 --- /dev/null +++ b/airflow/contrib/executors/mesos_executor.py @@ -0,0 +1,290 @@ +# -*- coding: utf-8 -*- +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from future import standard_library + +from airflow.utils.log.logging_mixin import LoggingMixin +from airflow.www.utils import LoginMixin + +standard_library.install_aliases() +from builtins import str +from queue import Queue + +import mesos.interface +from mesos.interface import mesos_pb2 +import mesos.native + +from airflow import configuration +from airflow.executors.base_executor import BaseExecutor +from airflow.settings import Session +from airflow.utils.state import State +from airflow.exceptions import AirflowException + + +DEFAULT_FRAMEWORK_NAME = 'Airflow' +FRAMEWORK_CONNID_PREFIX = 'mesos_framework_' + + +def get_framework_name(): + if not configuration.get('mesos', 'FRAMEWORK_NAME'): + return DEFAULT_FRAMEWORK_NAME + return configuration.get('mesos', 'FRAMEWORK_NAME') + + +# AirflowMesosScheduler, implements Mesos Scheduler interface +# To schedule airflow jobs on mesos +class AirflowMesosScheduler(mesos.interface.Scheduler, LoggingMixin): + """ + Airflow Mesos scheduler implements mesos scheduler interface + to schedule airflow tasks on mesos. + Basically, it schedules a command like + 'airflow run --local -p=' + to run on a mesos slave. + """ + def __init__(self, + task_queue, + result_queue, + task_cpu=1, + task_mem=256): + self.task_queue = task_queue + self.result_queue = result_queue + self.task_cpu = task_cpu + self.task_mem = task_mem + self.task_counter = 0 + self.task_key_map = {} + + def registered(self, driver, frameworkId, masterInfo): + self.log.info("AirflowScheduler registered to Mesos with framework ID %s", frameworkId.value) + + if configuration.getboolean('mesos', 'CHECKPOINT') and configuration.get('mesos', 'FAILOVER_TIMEOUT'): + # Import here to work around a circular import error + from airflow.models import Connection + + # Update the Framework ID in the database. + session = Session() + conn_id = FRAMEWORK_CONNID_PREFIX + get_framework_name() + connection = Session.query(Connection).filter_by(conn_id=conn_id).first() + if connection is None: + connection = Connection(conn_id=conn_id, conn_type='mesos_framework-id', + extra=frameworkId.value) + else: + connection.extra = frameworkId.value + + session.add(connection) + session.commit() + Session.remove() + + def reregistered(self, driver, masterInfo): + self.log.info("AirflowScheduler re-registered to mesos") + + def disconnected(self, driver): + self.log.info("AirflowScheduler disconnected from mesos") + + def offerRescinded(self, driver, offerId): + self.log.info("AirflowScheduler offer %s rescinded", str(offerId)) + + def frameworkMessage(self, driver, executorId, slaveId, message): + self.log.info("AirflowScheduler received framework message %s", message) + + def executorLost(self, driver, executorId, slaveId, status): + self.log.warning("AirflowScheduler executor %s lost", str(executorId)) + + def slaveLost(self, driver, slaveId): + self.log.warning("AirflowScheduler slave %s lost", str(slaveId)) + + def error(self, driver, message): + self.log.error("AirflowScheduler driver aborted %s", message) + raise AirflowException("AirflowScheduler driver aborted %s" % message) + + def resourceOffers(self, driver, offers): + for offer in offers: + tasks = [] + offerCpus = 0 + offerMem = 0 + for resource in offer.resources: + if resource.name == "cpus": + offerCpus += resource.scalar.value + elif resource.name == "mem": + offerMem += resource.scalar.value + + self.log.info("Received offer %s with cpus: %s and mem: %s", offer.id.value, offerCpus, offerMem) + + remainingCpus = offerCpus + remainingMem = offerMem + + while (not self.task_queue.empty()) and \ + remainingCpus >= self.task_cpu and \ + remainingMem >= self.task_mem: + key, cmd = self.task_queue.get() + tid = self.task_counter + self.task_counter += 1 + self.task_key_map[str(tid)] = key + + self.log.info("Launching task %d using offer %s", tid, offer.id.value) + + task = mesos_pb2.TaskInfo() + task.task_id.value = str(tid) + task.slave_id.value = offer.slave_id.value + task.name = "AirflowTask %d" % tid + + cpus = task.resources.add() + cpus.name = "cpus" + cpus.type = mesos_pb2.Value.SCALAR + cpus.scalar.value = self.task_cpu + + mem = task.resources.add() + mem.name = "mem" + mem.type = mesos_pb2.Value.SCALAR + mem.scalar.value = self.task_mem + + command = mesos_pb2.CommandInfo() + command.shell = True + command.value = cmd + task.command.MergeFrom(command) + + tasks.append(task) + + remainingCpus -= self.task_cpu + remainingMem -= self.task_mem + + driver.launchTasks(offer.id, tasks) + + def statusUpdate(self, driver, update): + self.log.info( + "Task %s is in state %s, data %s", + update.task_id.value, mesos_pb2.TaskState.Name(update.state), str(update.data) + ) + + try: + key = self.task_key_map[update.task_id.value] + except KeyError: + # The map may not contain an item if the framework re-registered after a failover. + # Discard these tasks. + self.log.warning("Unrecognised task key %s", update.task_id.value) + return + + if update.state == mesos_pb2.TASK_FINISHED: + self.result_queue.put((key, State.SUCCESS)) + self.task_queue.task_done() + + if update.state == mesos_pb2.TASK_LOST or \ + update.state == mesos_pb2.TASK_KILLED or \ + update.state == mesos_pb2.TASK_FAILED: + self.result_queue.put((key, State.FAILED)) + self.task_queue.task_done() + + +class MesosExecutor(BaseExecutor, LoginMixin): + """ + MesosExecutor allows distributing the execution of task + instances to multiple mesos workers. + + Apache Mesos is a distributed systems kernel which abstracts + CPU, memory, storage, and other compute resources away from + machines (physical or virtual), enabling fault-tolerant and + elastic distributed systems to easily be built and run effectively. + See http://mesos.apache.org/ + """ + def start(self): + self.task_queue = Queue() + self.result_queue = Queue() + framework = mesos_pb2.FrameworkInfo() + framework.user = '' + + if not configuration.get('mesos', 'MASTER'): + self.log.error("Expecting mesos master URL for mesos executor") + raise AirflowException("mesos.master not provided for mesos executor") + + master = configuration.get('mesos', 'MASTER') + + framework.name = get_framework_name() + + if not configuration.get('mesos', 'TASK_CPU'): + task_cpu = 1 + else: + task_cpu = configuration.getint('mesos', 'TASK_CPU') + + if not configuration.get('mesos', 'TASK_MEMORY'): + task_memory = 256 + else: + task_memory = configuration.getint('mesos', 'TASK_MEMORY') + + if configuration.getboolean('mesos', 'CHECKPOINT'): + framework.checkpoint = True + + if configuration.get('mesos', 'FAILOVER_TIMEOUT'): + # Import here to work around a circular import error + from airflow.models import Connection + + # Query the database to get the ID of the Mesos Framework, if available. + conn_id = FRAMEWORK_CONNID_PREFIX + framework.name + session = Session() + connection = session.query(Connection).filter_by(conn_id=conn_id).first() + if connection is not None: + # Set the Framework ID to let the scheduler reconnect with running tasks. + framework.id.value = connection.extra + + framework.failover_timeout = configuration.getint('mesos', 'FAILOVER_TIMEOUT') + else: + framework.checkpoint = False + + self.log.info( + 'MesosFramework master : %s, name : %s, cpu : %s, mem : %s, checkpoint : %s', + master, framework.name, str(task_cpu), str(task_memory), str(framework.checkpoint) + ) + + implicit_acknowledgements = 1 + + if configuration.getboolean('mesos', 'AUTHENTICATE'): + if not configuration.get('mesos', 'DEFAULT_PRINCIPAL'): + self.log.error("Expecting authentication principal in the environment") + raise AirflowException("mesos.default_principal not provided in authenticated mode") + if not configuration.get('mesos', 'DEFAULT_SECRET'): + self.log.error("Expecting authentication secret in the environment") + raise AirflowException("mesos.default_secret not provided in authenticated mode") + + credential = mesos_pb2.Credential() + credential.principal = configuration.get('mesos', 'DEFAULT_PRINCIPAL') + credential.secret = configuration.get('mesos', 'DEFAULT_SECRET') + + framework.principal = credential.principal + + driver = mesos.native.MesosSchedulerDriver( + AirflowMesosScheduler(self.task_queue, self.result_queue, task_cpu, task_memory), + framework, + master, + implicit_acknowledgements, + credential) + else: + framework.principal = 'Airflow' + driver = mesos.native.MesosSchedulerDriver( + AirflowMesosScheduler(self.task_queue, self.result_queue, task_cpu, task_memory), + framework, + master, + implicit_acknowledgements) + + self.mesos_driver = driver + self.mesos_driver.start() + + def execute_async(self, key, command, queue=None): + self.task_queue.put((key, command)) + + def sync(self): + while not self.result_queue.empty(): + results = self.result_queue.get() + self.change_state(*results) + + def end(self): + self.task_queue.join() + self.mesos_driver.stop() diff --git a/airflow/contrib/hooks/__init__.py b/airflow/contrib/hooks/__init__.py new file mode 100644 index 0000000000000..2891980d11f79 --- /dev/null +++ b/airflow/contrib/hooks/__init__.py @@ -0,0 +1,57 @@ +# -*- coding: utf-8 -*- +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + + +# Contrib hooks are not imported by default. They should be accessed +# directly: from airflow.contrib.hooks.hook_module import Hook + + +import sys + + +# ------------------------------------------------------------------------ +# +# #TODO #FIXME Airflow 2.0 +# +# Old import machinary below. +# +# This is deprecated but should be kept until Airflow 2.0 +# for compatibility. +# +# ------------------------------------------------------------------------ +_hooks = { + 'ftp_hook': ['FTPHook'], + 'ftps_hook': ['FTPSHook'], + 'vertica_hook': ['VerticaHook'], + 'ssh_hook': ['SSHHook'], + 'bigquery_hook': ['BigQueryHook'], + 'qubole_hook': ['QuboleHook'], + 'gcs_hook': ['GoogleCloudStorageHook'], + 'datastore_hook': ['DatastoreHook'], + 'gcp_cloudml_hook': ['CloudMLHook'], + 'gcp_dataproc_hook': ['DataProcHook'], + 'gcp_dataflow_hook': ['DataFlowHook'], + 'spark_submit_operator': ['SparkSubmitOperator'], + 'cloudant_hook': ['CloudantHook'], + 'fs_hook': ['FSHook'], + 'wasb_hook': ['WasbHook'], + 'gcp_pubsub_hook': ['PubSubHook'], + 'aws_dynamodb_hook': ['AwsDynamoDBHook'] +} + +import os as _os +if not _os.environ.get('AIRFLOW_USE_NEW_IMPORTS', False): + from airflow.utils.helpers import AirflowImporter + airflow_importer = AirflowImporter(sys.modules[__name__], _hooks) diff --git a/airflow/contrib/hooks/aws_dynamodb_hook.py b/airflow/contrib/hooks/aws_dynamodb_hook.py new file mode 100644 index 0000000000000..bb50ada637102 --- /dev/null +++ b/airflow/contrib/hooks/aws_dynamodb_hook.py @@ -0,0 +1,60 @@ +# -*- coding: utf-8 -*- +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from airflow.exceptions import AirflowException +from airflow.contrib.hooks.aws_hook import AwsHook + + +class AwsDynamoDBHook(AwsHook): + """ + Interact with AWS DynamoDB. + + :param table_keys: partition key and sort key + :type table_keys: list + :param table_name: target DynamoDB table + :type table_name: str + :param region_name: aws region name (example: us-east-1) + :type region_name: str + """ + + def __init__(self, table_keys=None, table_name=None, region_name=None, *args, **kwargs): + self.table_keys = table_keys + self.table_name = table_name + self.region_name = region_name + super(AwsDynamoDBHook, self).__init__(*args, **kwargs) + + def get_conn(self): + self.conn = self.get_resource_type('dynamodb', self.region_name) + return self.conn + + def write_batch_data(self, items): + """ + Write batch items to dynamodb table with provisioned throughout capacity. + """ + + dynamodb_conn = self.get_conn() + + try: + table = dynamodb_conn.Table(self.table_name) + + with table.batch_writer(overwrite_by_pkeys=self.table_keys) as batch: + for item in items: + batch.put_item(Item=item) + return True + except Exception as general_error: + raise AirflowException( + 'Failed to insert items in dynamodb, error: {error}'.format( + error=str(general_error) + ) + ) diff --git a/airflow/contrib/hooks/aws_hook.py b/airflow/contrib/hooks/aws_hook.py new file mode 100644 index 0000000000000..ca2ee054e2d97 --- /dev/null +++ b/airflow/contrib/hooks/aws_hook.py @@ -0,0 +1,139 @@ +# -*- coding: utf-8 -*- +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + + +import boto3 +import configparser + +from airflow.exceptions import AirflowException +from airflow.hooks.base_hook import BaseHook + + +def _parse_s3_config(config_file_name, config_format='boto', profile=None): + """ + Parses a config file for s3 credentials. Can currently + parse boto, s3cmd.conf and AWS SDK config formats + + :param config_file_name: path to the config file + :type config_file_name: str + :param config_format: config type. One of "boto", "s3cmd" or "aws". + Defaults to "boto" + :type config_format: str + :param profile: profile name in AWS type config file + :type profile: str + """ + Config = configparser.ConfigParser() + if Config.read(config_file_name): # pragma: no cover + sections = Config.sections() + else: + raise AirflowException("Couldn't read {0}".format(config_file_name)) + # Setting option names depending on file format + if config_format is None: + config_format = 'boto' + conf_format = config_format.lower() + if conf_format == 'boto': # pragma: no cover + if profile is not None and 'profile ' + profile in sections: + cred_section = 'profile ' + profile + else: + cred_section = 'Credentials' + elif conf_format == 'aws' and profile is not None: + cred_section = profile + else: + cred_section = 'default' + # Option names + if conf_format in ('boto', 'aws'): # pragma: no cover + key_id_option = 'aws_access_key_id' + secret_key_option = 'aws_secret_access_key' + # security_token_option = 'aws_security_token' + else: + key_id_option = 'access_key' + secret_key_option = 'secret_key' + # Actual Parsing + if cred_section not in sections: + raise AirflowException("This config file format is not recognized") + else: + try: + access_key = Config.get(cred_section, key_id_option) + secret_key = Config.get(cred_section, secret_key_option) + except: + logging.warning("Option Error in parsing s3 config file") + raise + return (access_key, secret_key) + + +class AwsHook(BaseHook): + """ + Interact with AWS. + This class is a thin wrapper around the boto3 python library. + """ + + def __init__(self, aws_conn_id='aws_default'): + self.aws_conn_id = aws_conn_id + + def _get_credentials(self, region_name): + aws_access_key_id = None + aws_secret_access_key = None + s3_endpoint_url = None + + if self.aws_conn_id: + try: + connection_object = self.get_connection(self.aws_conn_id) + if connection_object.login: + aws_access_key_id = connection_object.login + aws_secret_access_key = connection_object.password + + elif 'aws_secret_access_key' in connection_object.extra_dejson: + aws_access_key_id = connection_object.extra_dejson['aws_access_key_id'] + aws_secret_access_key = connection_object.extra_dejson['aws_secret_access_key'] + + elif 's3_config_file' in connection_object.extra_dejson: + aws_access_key_id, aws_secret_access_key = \ + _parse_s3_config(connection_object.extra_dejson['s3_config_file'], + connection_object.extra_dejson.get('s3_config_format')) + + if region_name is None: + region_name = connection_object.extra_dejson.get('region_name') + + s3_endpoint_url = connection_object.extra_dejson.get('host') + + except AirflowException: + # No connection found: fallback on boto3 credential strategy + # http://boto3.readthedocs.io/en/latest/guide/configuration.html + pass + + return aws_access_key_id, aws_secret_access_key, region_name, s3_endpoint_url + + def get_client_type(self, client_type, region_name=None): + aws_access_key_id, aws_secret_access_key, region_name, endpoint_url = \ + self._get_credentials(region_name) + + return boto3.client( + client_type, + region_name=region_name, + aws_access_key_id=aws_access_key_id, + aws_secret_access_key=aws_secret_access_key, + endpoint_url=endpoint_url + ) + + def get_resource_type(self, resource_type, region_name=None): + aws_access_key_id, aws_secret_access_key, region_name, endpoint_url = \ + self._get_credentials(region_name) + + return boto3.resource( + resource_type, + region_name=region_name, + aws_access_key_id=aws_access_key_id, + aws_secret_access_key=aws_secret_access_key, + endpoint_url=endpoint_url + ) diff --git a/airflow/contrib/hooks/aws_lambda_hook.py b/airflow/contrib/hooks/aws_lambda_hook.py new file mode 100644 index 0000000000000..bcd1c7fd7091a --- /dev/null +++ b/airflow/contrib/hooks/aws_lambda_hook.py @@ -0,0 +1,62 @@ +# -*- coding: utf-8 -*- +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from airflow.contrib.hooks.aws_hook import AwsHook + + +class AwsLambdaHook(AwsHook): + """ + Interact with AWS Lambda + + :param function_name: AWS Lambda Function Name + :type function_name: str + :param region_name: AWS Region Name (example: us-west-2) + :type region_name: str + :param log_type: Tail Invocation Request + :type log_type: str + :param qualifier: AWS Lambda Function Version or Alias Name + :type qualifier: str + :param invocation_type: AWS Lambda Invocation Type (RequestResponse, Event etc) + :type invocation_type: str + """ + + def __init__(self, function_name, region_name=None, log_type='None', qualifier='$LATEST', + invocation_type='RequestResponse', *args, **kwargs): + self.function_name = function_name + self.region_name = region_name + self.log_type = log_type + self.invocation_type = invocation_type + self.qualifier = qualifier + super(AwsLambdaHook, self).__init__(*args, **kwargs) + + def get_conn(self): + self.conn = self.get_client_type('lambda', self.region_name) + return self.conn + + def invoke_lambda(self, payload): + """ + Invoke Lambda Function + """ + + awslambda_conn = self.get_conn() + + response = awslambda_conn.invoke( + FunctionName=self.function_name, + InvocationType=self.invocation_type, + LogType=self.log_type, + Payload=payload, + Qualifier=self.qualifier + ) + + return response diff --git a/airflow/contrib/hooks/bigquery_hook.py b/airflow/contrib/hooks/bigquery_hook.py new file mode 100644 index 0000000000000..2cfef57b80ed4 --- /dev/null +++ b/airflow/contrib/hooks/bigquery_hook.py @@ -0,0 +1,1087 @@ +# -*- coding: utf-8 -*- +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +""" +This module contains a BigQuery Hook, as well as a very basic PEP 249 +implementation for BigQuery. +""" + +import time + +from apiclient.discovery import build, HttpError +from googleapiclient import errors +from builtins import range +from pandas_gbq.gbq import GbqConnector, \ + _parse_data as gbq_parse_data, \ + _check_google_client_version as gbq_check_google_client_version, \ + _test_google_api_imports as gbq_test_google_api_imports +from pandas.tools.merge import concat +from past.builtins import basestring + +from airflow.contrib.hooks.gcp_api_base_hook import GoogleCloudBaseHook +from airflow.hooks.dbapi_hook import DbApiHook +from airflow.utils.log.logging_mixin import LoggingMixin + + +class BigQueryHook(GoogleCloudBaseHook, DbApiHook, LoggingMixin): + """ + Interact with BigQuery. This hook uses the Google Cloud Platform + connection. + """ + conn_name_attr = 'bigquery_conn_id' + + def __init__(self, + bigquery_conn_id='bigquery_default', + delegate_to=None): + super(BigQueryHook, self).__init__( + conn_id=bigquery_conn_id, + delegate_to=delegate_to) + + def get_conn(self): + """ + Returns a BigQuery PEP 249 connection object. + """ + service = self.get_service() + project = self._get_field('project') + return BigQueryConnection(service=service, project_id=project) + + def get_service(self): + """ + Returns a BigQuery service object. + """ + http_authorized = self._authorize() + return build('bigquery', 'v2', http=http_authorized) + + def insert_rows(self, table, rows, target_fields=None, commit_every=1000): + """ + Insertion is currently unsupported. Theoretically, you could use + BigQuery's streaming API to insert rows into a table, but this hasn't + been implemented. + """ + raise NotImplementedError() + + def get_pandas_df(self, bql, parameters=None, dialect='legacy'): + """ + Returns a Pandas DataFrame for the results produced by a BigQuery + query. The DbApiHook method must be overridden because Pandas + doesn't support PEP 249 connections, except for SQLite. See: + + https://github.com/pydata/pandas/blob/master/pandas/io/sql.py#L447 + https://github.com/pydata/pandas/issues/6900 + + :param bql: The BigQuery SQL to execute. + :type bql: string + :param parameters: The parameters to render the SQL query with (not used, leave to override superclass method) + :type parameters: mapping or iterable + :param dialect: Dialect of BigQuery SQL – legacy SQL or standard SQL + :type dialect: string in {'legacy', 'standard'}, default 'legacy' + """ + service = self.get_service() + project = self._get_field('project') + connector = BigQueryPandasConnector(project, service, dialect=dialect) + schema, pages = connector.run_query(bql) + dataframe_list = [] + + while len(pages) > 0: + page = pages.pop() + dataframe_list.append(gbq_parse_data(schema, page)) + + if len(dataframe_list) > 0: + return concat(dataframe_list, ignore_index=True) + else: + return gbq_parse_data(schema, []) + + def table_exists(self, project_id, dataset_id, table_id): + """ + Checks for the existence of a table in Google BigQuery. + + :param project_id: The Google cloud project in which to look for the table. The connection supplied to the hook + must provide access to the specified project. + :type project_id: string + :param dataset_id: The name of the dataset in which to look for the table. + storage bucket. + :type dataset_id: string + :param table_id: The name of the table to check the existence of. + :type table_id: string + """ + service = self.get_service() + try: + service.tables().get( + projectId=project_id, + datasetId=dataset_id, + tableId=table_id + ).execute() + return True + except errors.HttpError as e: + if e.resp['status'] == '404': + return False + raise + + +class BigQueryPandasConnector(GbqConnector): + """ + This connector behaves identically to GbqConnector (from Pandas), except + that it allows the service to be injected, and disables a call to + self.get_credentials(). This allows Airflow to use BigQuery with Pandas + without forcing a three legged OAuth connection. Instead, we can inject + service account credentials into the binding. + """ + def __init__(self, project_id, service, reauth=False, verbose=False, dialect='legacy'): + gbq_check_google_client_version() + gbq_test_google_api_imports() + self.project_id = project_id + self.reauth = reauth + self.service = service + self.verbose = verbose + self.dialect = dialect + + +class BigQueryConnection(object): + """ + BigQuery does not have a notion of a persistent connection. Thus, these + objects are small stateless factories for cursors, which do all the real + work. + """ + + def __init__(self, *args, **kwargs): + self._args = args + self._kwargs = kwargs + + def close(self): + """ BigQueryConnection does not have anything to close. """ + pass + + def commit(self): + """ BigQueryConnection does not support transactions. """ + pass + + def cursor(self): + """ Return a new :py:class:`Cursor` object using the connection. """ + return BigQueryCursor(*self._args, **self._kwargs) + + def rollback(self): + raise NotImplementedError( + "BigQueryConnection does not have transactions") + + +class BigQueryBaseCursor(LoggingMixin): + """ + The BigQuery base cursor contains helper methods to execute queries against + BigQuery. The methods can be used directly by operators, in cases where a + PEP 249 cursor isn't needed. + """ + def __init__(self, service, project_id): + self.service = service + self.project_id = project_id + self.running_job_id = None + + def run_query( + self, bql, destination_dataset_table = False, + write_disposition = 'WRITE_EMPTY', + allow_large_results=False, + udf_config = False, + use_legacy_sql=True, + maximum_billing_tier=None, + create_disposition='CREATE_IF_NEEDED', + query_params=None): + """ + Executes a BigQuery SQL query. Optionally persists results in a BigQuery + table. See here: + + https://cloud.google.com/bigquery/docs/reference/v2/jobs + + For more details about these parameters. + + :param bql: The BigQuery SQL to execute. + :type bql: string + :param destination_dataset_table: The dotted . + BigQuery table to save the query results. + :param write_disposition: What to do if the table already exists in + BigQuery. + :type write_disposition: string + :param create_disposition: Specifies whether the job is allowed to create new tables. + :type create_disposition: string + :param allow_large_results: Whether to allow large results. + :type allow_large_results: boolean + :param udf_config: The User Defined Function configuration for the query. + See https://cloud.google.com/bigquery/user-defined-functions for details. + :type udf_config: list + :param use_legacy_sql: Whether to use legacy SQL (true) or standard SQL (false). + :type use_legacy_sql: boolean + :param maximum_billing_tier: Positive integer that serves as a multiplier of the basic price. + :type maximum_billing_tier: integer + """ + configuration = { + 'query': { + 'query': bql, + 'useLegacySql': use_legacy_sql, + 'maximumBillingTier': maximum_billing_tier + } + } + + if destination_dataset_table: + assert '.' in destination_dataset_table, ( + 'Expected destination_dataset_table in the format of ' + '.
. Got: {}').format(destination_dataset_table) + destination_project, destination_dataset, destination_table = \ + _split_tablename(table_input=destination_dataset_table, + default_project_id=self.project_id) + configuration['query'].update({ + 'allowLargeResults': allow_large_results, + 'writeDisposition': write_disposition, + 'createDisposition': create_disposition, + 'destinationTable': { + 'projectId': destination_project, + 'datasetId': destination_dataset, + 'tableId': destination_table, + } + }) + if udf_config: + assert isinstance(udf_config, list) + configuration['query'].update({ + 'userDefinedFunctionResources': udf_config + }) + + if query_params: + configuration['query']['queryParameters'] = query_params + + return self.run_with_configuration(configuration) + + def run_extract( # noqa + self, source_project_dataset_table, destination_cloud_storage_uris, + compression='NONE', export_format='CSV', field_delimiter=',', + print_header=True): + """ + Executes a BigQuery extract command to copy data from BigQuery to + Google Cloud Storage. See here: + + https://cloud.google.com/bigquery/docs/reference/v2/jobs + + For more details about these parameters. + + :param source_project_dataset_table: The dotted .
+ BigQuery table to use as the source data. + :type source_project_dataset_table: string + :param destination_cloud_storage_uris: The destination Google Cloud + Storage URI (e.g. gs://some-bucket/some-file.txt). Follows + convention defined here: + https://cloud.google.com/bigquery/exporting-data-from-bigquery#exportingmultiple + :type destination_cloud_storage_uris: list + :param compression: Type of compression to use. + :type compression: string + :param export_format: File format to export. + :type export_format: string + :param field_delimiter: The delimiter to use when extracting to a CSV. + :type field_delimiter: string + :param print_header: Whether to print a header for a CSV file extract. + :type print_header: boolean + """ + + source_project, source_dataset, source_table = \ + _split_tablename(table_input=source_project_dataset_table, + default_project_id=self.project_id, + var_name='source_project_dataset_table') + + configuration = { + 'extract': { + 'sourceTable': { + 'projectId': source_project, + 'datasetId': source_dataset, + 'tableId': source_table, + }, + 'compression': compression, + 'destinationUris': destination_cloud_storage_uris, + 'destinationFormat': export_format, + } + } + + if export_format == 'CSV': + # Only set fieldDelimiter and printHeader fields if using CSV. + # Google does not like it if you set these fields for other export + # formats. + configuration['extract']['fieldDelimiter'] = field_delimiter + configuration['extract']['printHeader'] = print_header + + return self.run_with_configuration(configuration) + + def run_copy(self, + source_project_dataset_tables, + destination_project_dataset_table, + write_disposition='WRITE_EMPTY', + create_disposition='CREATE_IF_NEEDED'): + """ + Executes a BigQuery copy command to copy data from one BigQuery table + to another. See here: + + https://cloud.google.com/bigquery/docs/reference/v2/jobs#configuration.copy + + For more details about these parameters. + + :param source_project_dataset_tables: One or more dotted + (project:|project.).
+ BigQuery tables to use as the source data. Use a list if there are + multiple source tables. + If is not included, project will be the project defined + in the connection json. + :type source_project_dataset_tables: list|string + :param destination_project_dataset_table: The destination BigQuery + table. Format is: (project:|project.).
+ :type destination_project_dataset_table: string + :param write_disposition: The write disposition if the table already exists. + :type write_disposition: string + :param create_disposition: The create disposition if the table doesn't exist. + :type create_disposition: string + """ + source_project_dataset_tables = ( + [source_project_dataset_tables] + if not isinstance(source_project_dataset_tables, list) + else source_project_dataset_tables) + + source_project_dataset_tables_fixup = [] + for source_project_dataset_table in source_project_dataset_tables: + source_project, source_dataset, source_table = \ + _split_tablename(table_input=source_project_dataset_table, + default_project_id=self.project_id, + var_name='source_project_dataset_table') + source_project_dataset_tables_fixup.append({ + 'projectId': source_project, + 'datasetId': source_dataset, + 'tableId': source_table + }) + + destination_project, destination_dataset, destination_table = \ + _split_tablename(table_input=destination_project_dataset_table, + default_project_id=self.project_id) + configuration = { + 'copy': { + 'createDisposition': create_disposition, + 'writeDisposition': write_disposition, + 'sourceTables': source_project_dataset_tables_fixup, + 'destinationTable': { + 'projectId': destination_project, + 'datasetId': destination_dataset, + 'tableId': destination_table + } + } + } + + return self.run_with_configuration(configuration) + + def run_load(self, + destination_project_dataset_table, + schema_fields, source_uris, + source_format='CSV', + create_disposition='CREATE_IF_NEEDED', + skip_leading_rows=0, + write_disposition='WRITE_EMPTY', + field_delimiter=',', + max_bad_records=0, + quote_character=None, + allow_quoted_newlines=False, + allow_jagged_rows=False, + schema_update_options=(), + src_fmt_configs={}): + """ + Executes a BigQuery load command to load data from Google Cloud Storage + to BigQuery. See here: + + https://cloud.google.com/bigquery/docs/reference/v2/jobs + + For more details about these parameters. + + :param destination_project_dataset_table: + The dotted (.|:).
BigQuery table to load + data into. If is not included, project will be the project defined + in the connection json. + :type destination_project_dataset_table: string + :param schema_fields: The schema field list as defined here: + https://cloud.google.com/bigquery/docs/reference/v2/jobs#configuration.load + :type schema_fields: list + :param source_uris: The source Google Cloud + Storage URI (e.g. gs://some-bucket/some-file.txt). A single wild + per-object name can be used. + :type source_uris: list + :param source_format: File format to export. + :type source_format: string + :param create_disposition: The create disposition if the table doesn't exist. + :type create_disposition: string + :param skip_leading_rows: Number of rows to skip when loading from a CSV. + :type skip_leading_rows: int + :param write_disposition: The write disposition if the table already exists. + :type write_disposition: string + :param field_delimiter: The delimiter to use when loading from a CSV. + :type field_delimiter: string + :param max_bad_records: The maximum number of bad records that BigQuery can + ignore when running the job. + :type max_bad_records: int + :param quote_character: The value that is used to quote data sections in a CSV file. + :type quote_character: string + :param allow_quoted_newlines: Whether to allow quoted newlines (true) or not (false). + :type allow_quoted_newlines: boolean + :param allow_jagged_rows: Accept rows that are missing trailing optional columns. + The missing values are treated as nulls. If false, records with missing trailing columns + are treated as bad records, and if there are too many bad records, an invalid error is + returned in the job result. Only applicable when soure_format is CSV. + :type allow_jagged_rows: bool + :param schema_update_options: Allows the schema of the desitination + table to be updated as a side effect of the load job. + :type schema_update_options: list + :param src_fmt_configs: configure optional fields specific to the source format + :type src_fmt_configs: dict + """ + + # bigquery only allows certain source formats + # we check to make sure the passed source format is valid + # if it's not, we raise a ValueError + # Refer to this link for more details: + # https://cloud.google.com/bigquery/docs/reference/rest/v2/jobs#configuration.query.tableDefinitions.(key).sourceFormat + source_format = source_format.upper() + allowed_formats = ["CSV", "NEWLINE_DELIMITED_JSON", "AVRO", "GOOGLE_SHEETS", "DATASTORE_BACKUP"] + if source_format not in allowed_formats: + raise ValueError("{0} is not a valid source format. " + "Please use one of the following types: {1}" + .format(source_format, allowed_formats)) + + # bigquery also allows you to define how you want a table's schema to change + # as a side effect of a load + # for more details: + # https://cloud.google.com/bigquery/docs/reference/rest/v2/jobs#configuration.load.schemaUpdateOptions + allowed_schema_update_options = [ + 'ALLOW_FIELD_ADDITION', + "ALLOW_FIELD_RELAXATION" + ] + if not set(allowed_schema_update_options).issuperset(set(schema_update_options)): + raise ValueError( + "{0} contains invalid schema update options. " + "Please only use one or more of the following options: {1}" + .format(schema_update_options, allowed_schema_update_options) + ) + + destination_project, destination_dataset, destination_table = \ + _split_tablename(table_input=destination_project_dataset_table, + default_project_id=self.project_id, + var_name='destination_project_dataset_table') + + configuration = { + 'load': { + 'createDisposition': create_disposition, + 'destinationTable': { + 'projectId': destination_project, + 'datasetId': destination_dataset, + 'tableId': destination_table, + }, + 'sourceFormat': source_format, + 'sourceUris': source_uris, + 'writeDisposition': write_disposition, + } + } + if schema_fields: + configuration['load']['schema'] = { + 'fields': schema_fields + } + + if schema_update_options: + if write_disposition not in ["WRITE_APPEND", "WRITE_TRUNCATE"]: + raise ValueError( + "schema_update_options is only " + "allowed if write_disposition is " + "'WRITE_APPEND' or 'WRITE_TRUNCATE'." + ) + else: + self.log.info( + "Adding experimental " + "'schemaUpdateOptions': {0}".format(schema_update_options) + ) + configuration['load']['schemaUpdateOptions'] = schema_update_options + + if max_bad_records: + configuration['load']['maxBadRecords'] = max_bad_records + + # if following fields are not specified in src_fmt_configs, + # honor the top-level params for backward-compatibility + if 'skipLeadingRows' not in src_fmt_configs: + src_fmt_configs['skipLeadingRows'] = skip_leading_rows + if 'fieldDelimiter' not in src_fmt_configs: + src_fmt_configs['fieldDelimiter'] = field_delimiter + if quote_character: + src_fmt_configs['quote'] = quote_character + if allow_quoted_newlines: + src_fmt_configs['allowQuotedNewlines'] = allow_quoted_newlines + + src_fmt_to_configs_mapping = { + 'CSV': ['allowJaggedRows', 'allowQuotedNewlines', 'autodetect', + 'fieldDelimiter', 'skipLeadingRows', 'ignoreUnknownValues', + 'nullMarker', 'quote'], + 'DATASTORE_BACKUP': ['projectionFields'], + 'NEWLINE_DELIMITED_JSON': ['autodetect', 'ignoreUnknownValues'], + 'AVRO': [], + } + valid_configs = src_fmt_to_configs_mapping[source_format] + src_fmt_configs = {k: v for k, v in src_fmt_configs.items() + if k in valid_configs} + configuration['load'].update(src_fmt_configs) + + if allow_jagged_rows: + configuration['load']['allowJaggedRows'] = allow_jagged_rows + + return self.run_with_configuration(configuration) + + def run_with_configuration(self, configuration): + """ + Executes a BigQuery SQL query. See here: + + https://cloud.google.com/bigquery/docs/reference/v2/jobs + + For more details about the configuration parameter. + + :param configuration: The configuration parameter maps directly to + BigQuery's configuration field in the job object. See + https://cloud.google.com/bigquery/docs/reference/v2/jobs for + details. + """ + jobs = self.service.jobs() + job_data = { + 'configuration': configuration + } + + # Send query and wait for reply. + query_reply = jobs \ + .insert(projectId=self.project_id, body=job_data) \ + .execute() + self.running_job_id = query_reply['jobReference']['jobId'] + + # Wait for query to finish. + keep_polling_job = True + while (keep_polling_job): + try: + job = jobs.get(projectId=self.project_id, jobId=self.running_job_id).execute() + if (job['status']['state'] == 'DONE'): + keep_polling_job = False + # Check if job had errors. + if 'errorResult' in job['status']: + raise Exception( + 'BigQuery job failed. Final error was: {}. The job was: {}'.format( + job['status']['errorResult'], job + ) + ) + else: + self.log.info('Waiting for job to complete : %s, %s', self.project_id, self.running_job_id) + time.sleep(5) + + except HttpError as err: + if err.resp.status in [500, 503]: + self.log.info('%s: Retryable error, waiting for job to complete: %s', err.resp.status, self.running_job_id) + time.sleep(5) + else: + raise Exception( + 'BigQuery job status check failed. Final error was: %s', err.resp.status) + + return self.running_job_id + + def poll_job_complete(self, job_id): + jobs = self.service.jobs() + try: + job = jobs.get(projectId=self.project_id, jobId=job_id).execute() + if (job['status']['state'] == 'DONE'): + return True + except HttpError as err: + if err.resp.status in [500, 503]: + self.log.info('%s: Retryable error while polling job with id %s', err.resp.status, job_id) + else: + raise Exception( + 'BigQuery job status check failed. Final error was: %s', err.resp.status) + return False + + + def cancel_query(self): + """ + Cancel all started queries that have not yet completed + """ + jobs = self.service.jobs() + if (self.running_job_id and not self.poll_job_complete(self.running_job_id)): + self.log.info('Attempting to cancel job : %s, %s', self.project_id, self.running_job_id) + jobs.cancel(projectId=self.project_id, jobId=self.running_job_id).execute() + else: + self.log.info('No running BigQuery jobs to cancel.') + return + + # Wait for all the calls to cancel to finish + max_polling_attempts = 12 + polling_attempts = 0 + + job_complete = False + while (polling_attempts < max_polling_attempts and not job_complete): + polling_attempts = polling_attempts+1 + job_complete = self.poll_job_complete(self.running_job_id) + if (job_complete): + self.log.info('Job successfully canceled: %s, %s', self.project_id, self.running_job_id) + elif(polling_attempts == max_polling_attempts): + self.log.info('Stopping polling due to timeout. Job with id %s has not completed cancel and may or may not finish.', self.running_job_id) + else: + self.log.info('Waiting for canceled job with id %s to finish.', self.running_job_id) + time.sleep(5) + + def get_schema(self, dataset_id, table_id): + """ + Get the schema for a given datset.table. + see https://cloud.google.com/bigquery/docs/reference/v2/tables#resource + + :param dataset_id: the dataset ID of the requested table + :param table_id: the table ID of the requested table + :return: a table schema + """ + tables_resource = self.service.tables() \ + .get(projectId=self.project_id, datasetId=dataset_id, tableId=table_id) \ + .execute() + return tables_resource['schema'] + + def get_tabledata(self, dataset_id, table_id, + max_results=None, page_token=None, start_index=None): + """ + Get the data of a given dataset.table. + see https://cloud.google.com/bigquery/docs/reference/v2/tabledata/list + + :param dataset_id: the dataset ID of the requested table. + :param table_id: the table ID of the requested table. + :param max_results: the maximum results to return. + :param page_token: page token, returned from a previous call, + identifying the result set. + :param start_index: zero based index of the starting row to read. + :return: map containing the requested rows. + """ + optional_params = {} + if max_results: + optional_params['maxResults'] = max_results + if page_token: + optional_params['pageToken'] = page_token + if start_index: + optional_params['startIndex'] = start_index + return ( + self.service.tabledata() + .list( + projectId=self.project_id, datasetId=dataset_id, + tableId=table_id, **optional_params) + .execute() + ) + + def run_table_delete(self, deletion_dataset_table, ignore_if_missing=False): + """ + Delete an existing table from the dataset; + If the table does not exist, return an error unless ignore_if_missing + is set to True. + :param deletion_dataset_table: A dotted + (.|:).
that indicates which table + will be deleted. + :type deletion_dataset_table: str + :param ignore_if_missing: if True, then return success even if the + requested table does not exist. + :type ignore_if_missing: boolean + :return: + """ + + assert '.' in deletion_dataset_table, ( + 'Expected deletion_dataset_table in the format of ' + '.
. Got: {}').format(deletion_dataset_table) + deletion_project, deletion_dataset, deletion_table = \ + _split_tablename(table_input=deletion_dataset_table, + default_project_id=self.project_id) + + try: + tables_resource = self.service.tables() \ + .delete(projectId=deletion_project, + datasetId=deletion_dataset, + tableId=deletion_table) \ + .execute() + self.log.info('Deleted table %s:%s.%s.', + deletion_project, deletion_dataset, deletion_table) + except HttpError: + if not ignore_if_missing: + raise Exception( + 'Table deletion failed. Table does not exist.') + else: + self.log.info('Table does not exist. Skipping.') + + + def run_table_upsert(self, dataset_id, table_resource, project_id=None): + """ + creates a new, empty table in the dataset; + If the table already exists, update the existing table. + Since BigQuery does not natively allow table upserts, this is not an + atomic operation. + :param dataset_id: the dataset to upsert the table into. + :type dataset_id: str + :param table_resource: a table resource. see + https://cloud.google.com/bigquery/docs/reference/v2/tables#resource + :type table_resource: dict + :param project_id: the project to upsert the table into. If None, + project will be self.project_id. + :return: + """ + # check to see if the table exists + table_id = table_resource['tableReference']['tableId'] + project_id = project_id if project_id is not None else self.project_id + tables_list_resp = self.service.tables().list(projectId=project_id, + datasetId=dataset_id).execute() + while True: + for table in tables_list_resp.get('tables', []): + if table['tableReference']['tableId'] == table_id: + # found the table, do update + self.log.info( + 'Table %s:%s.%s exists, updating.', + project_id, dataset_id, table_id + ) + return self.service.tables().update(projectId=project_id, + datasetId=dataset_id, + tableId=table_id, + body=table_resource).execute() + # If there is a next page, we need to check the next page. + if 'nextPageToken' in tables_list_resp: + tables_list_resp = self.service.tables()\ + .list(projectId=project_id, + datasetId=dataset_id, + pageToken=tables_list_resp['nextPageToken'])\ + .execute() + # If there is no next page, then the table doesn't exist. + else: + # do insert + self.log.info( + 'Table %s:%s.%s does not exist. creating.', + project_id, dataset_id, table_id + ) + return self.service.tables().insert(projectId=project_id, + datasetId=dataset_id, + body=table_resource).execute() + + def run_grant_dataset_view_access(self, + source_dataset, + view_dataset, + view_table, + source_project = None, + view_project = None): + """ + Grant authorized view access of a dataset to a view table. + If this view has already been granted access to the dataset, do nothing. + This method is not atomic. Running it may clobber a simultaneous update. + :param source_dataset: the source dataset + :type source_dataset: str + :param view_dataset: the dataset that the view is in + :type view_dataset: str + :param view_table: the table of the view + :type view_table: str + :param source_project: the project of the source dataset. If None, + self.project_id will be used. + :type source_project: str + :param view_project: the project that the view is in. If None, + self.project_id will be used. + :type view_project: str + :return: the datasets resource of the source dataset. + """ + + # Apply default values to projects + source_project = source_project if source_project else self.project_id + view_project = view_project if view_project else self.project_id + + # we don't want to clobber any existing accesses, so we have to get + # info on the dataset before we can add view access + source_dataset_resource = self.service.datasets().get(projectId=source_project, + datasetId=source_dataset).execute() + access = source_dataset_resource['access'] if 'access' in source_dataset_resource else [] + view_access = {'view': {'projectId': view_project, + 'datasetId': view_dataset, + 'tableId': view_table}} + # check to see if the view we want to add already exists. + if view_access not in access: + self.log.info( + 'Granting table %s:%s.%s authorized view access to %s:%s dataset.', + view_project, view_dataset, view_table, source_project, source_dataset + ) + access.append(view_access) + return self.service.datasets().patch(projectId=source_project, + datasetId=source_dataset, + body={'access': access}).execute() + else: + # if view is already in access, do nothing. + self.log.info( + 'Table %s:%s.%s already has authorized view access to %s:%s dataset.', + view_project, view_dataset, view_table, source_project, source_dataset + ) + return source_dataset_resource + + +class BigQueryCursor(BigQueryBaseCursor): + """ + A very basic BigQuery PEP 249 cursor implementation. The PyHive PEP 249 + implementation was used as a reference: + + https://github.com/dropbox/PyHive/blob/master/pyhive/presto.py + https://github.com/dropbox/PyHive/blob/master/pyhive/common.py + """ + + def __init__(self, service, project_id): + super(BigQueryCursor, self).__init__(service=service, project_id=project_id) + self.buffersize = None + self.page_token = None + self.job_id = None + self.buffer = [] + self.all_pages_loaded = False + + @property + def description(self): + """ The schema description method is not currently implemented. """ + raise NotImplementedError + + def close(self): + """ By default, do nothing """ + pass + + @property + def rowcount(self): + """ By default, return -1 to indicate that this is not supported. """ + return -1 + + def execute(self, operation, parameters=None): + """ + Executes a BigQuery query, and returns the job ID. + + :param operation: The query to execute. + :type operation: string + :param parameters: Parameters to substitute into the query. + :type parameters: dict + """ + bql = _bind_parameters(operation, parameters) if parameters else operation + self.job_id = self.run_query(bql) + + def executemany(self, operation, seq_of_parameters): + """ + Execute a BigQuery query multiple times with different parameters. + + :param operation: The query to execute. + :type operation: string + :param parameters: List of dictionary parameters to substitute into the + query. + :type parameters: list + """ + for parameters in seq_of_parameters: + self.execute(operation, parameters) + + def fetchone(self): + """ Fetch the next row of a query result set. """ + return self.next() + + def next(self): + """ + Helper method for fetchone, which returns the next row from a buffer. + If the buffer is empty, attempts to paginate through the result set for + the next page, and load it into the buffer. + """ + if not self.job_id: + return None + + if len(self.buffer) == 0: + if self.all_pages_loaded: + return None + + query_results = ( + self.service.jobs() + .getQueryResults( + projectId=self.project_id, + jobId=self.job_id, + pageToken=self.page_token) + .execute() + ) + + if 'rows' in query_results and query_results['rows']: + self.page_token = query_results.get('pageToken') + fields = query_results['schema']['fields'] + col_types = [field['type'] for field in fields] + rows = query_results['rows'] + + for dict_row in rows: + typed_row = ([ + _bq_cast(vs['v'], col_types[idx]) + for idx, vs in enumerate(dict_row['f']) + ]) + self.buffer.append(typed_row) + + if not self.page_token: + self.all_pages_loaded = True + + else: + # Reset all state since we've exhausted the results. + self.page_token = None + self.job_id = None + self.page_token = None + return None + + return self.buffer.pop(0) + + def fetchmany(self, size=None): + """ + Fetch the next set of rows of a query result, returning a sequence of sequences (e.g. a + list of tuples). An empty sequence is returned when no more rows are available. + The number of rows to fetch per call is specified by the parameter. If it is not given, the + cursor's arraysize determines the number of rows to be fetched. The method should try to + fetch as many rows as indicated by the size parameter. If this is not possible due to the + specified number of rows not being available, fewer rows may be returned. + An :py:class:`~pyhive.exc.Error` (or subclass) exception is raised if the previous call to + :py:meth:`execute` did not produce any result set or no call was issued yet. + """ + if size is None: + size = self.arraysize + result = [] + for _ in range(size): + one = self.fetchone() + if one is None: + break + else: + result.append(one) + return result + + def fetchall(self): + """ + Fetch all (remaining) rows of a query result, returning them as a sequence of sequences + (e.g. a list of tuples). + """ + result = [] + while True: + one = self.fetchone() + if one is None: + break + else: + result.append(one) + return result + + def get_arraysize(self): + """ Specifies the number of rows to fetch at a time with .fetchmany() """ + return self._buffersize if self.buffersize else 1 + + def set_arraysize(self, arraysize): + """ Specifies the number of rows to fetch at a time with .fetchmany() """ + self.buffersize = arraysize + + arraysize = property(get_arraysize, set_arraysize) + + def setinputsizes(self, sizes): + """ Does nothing by default """ + pass + + def setoutputsize(self, size, column=None): + """ Does nothing by default """ + pass + + +def _bind_parameters(operation, parameters): + """ Helper method that binds parameters to a SQL query. """ + # inspired by MySQL Python Connector (conversion.py) + string_parameters = {} + for (name, value) in parameters.iteritems(): + if value is None: + string_parameters[name] = 'NULL' + elif isinstance(value, basestring): + string_parameters[name] = "'" + _escape(value) + "'" + else: + string_parameters[name] = str(value) + return operation % string_parameters + + +def _escape(s): + """ Helper method that escapes parameters to a SQL query. """ + e = s + e = e.replace('\\', '\\\\') + e = e.replace('\n', '\\n') + e = e.replace('\r', '\\r') + e = e.replace("'", "\\'") + e = e.replace('"', '\\"') + return e + + +def _bq_cast(string_field, bq_type): + """ + Helper method that casts a BigQuery row to the appropriate data types. + This is useful because BigQuery returns all fields as strings. + """ + if string_field is None: + return None + elif bq_type == 'INTEGER' or bq_type == 'TIMESTAMP': + return int(string_field) + elif bq_type == 'FLOAT': + return float(string_field) + elif bq_type == 'BOOLEAN': + assert string_field in set(['true', 'false']) + return string_field == 'true' + else: + return string_field + + +def _split_tablename(table_input, default_project_id, var_name=None): + assert default_project_id is not None, "INTERNAL: No default project is specified" + + def var_print(var_name): + if var_name is None: + return "" + else: + return "Format exception for {var}: ".format(var=var_name) + + if table_input.count('.') + table_input.count(':') > 3: + raise Exception(( + '{var}Use either : or . to specify project ' + 'got {input}' + ).format(var=var_print(var_name), input=table_input)) + + cmpt = table_input.rsplit(':', 1) + project_id = None + rest = table_input + if len(cmpt) == 1: + project_id = None + rest = cmpt[0] + elif len(cmpt) == 2 and cmpt[0].count(':') <= 1: + if cmpt[-1].count('.') != 2: + project_id = cmpt[0] + rest = cmpt[1] + else: + raise Exception(( + '{var}Expect format of (.
, ' + 'got {input}' + ).format(var=var_print(var_name), input=table_input)) + + cmpt = rest.split('.') + if len(cmpt) == 3: + assert project_id is None, ( + "{var}Use either : or . to specify project" + ).format(var=var_print(var_name)) + project_id = cmpt[0] + dataset_id = cmpt[1] + table_id = cmpt[2] + + elif len(cmpt) == 2: + dataset_id = cmpt[0] + table_id = cmpt[1] + else: + raise Exception(( + '{var}Expect format of (.
, ' + 'got {input}' + ).format(var=var_print(var_name), input=table_input)) + + if project_id is None: + if var_name is not None: + log = LoggingMixin().log + log.info( + 'Project not included in {var}: {input}; using project "{project}"'.format( + var=var_name, input=table_input, project=default_project_id + ) + ) + project_id = default_project_id + + return project_id, dataset_id, table_id diff --git a/airflow/contrib/hooks/cloudant_hook.py b/airflow/contrib/hooks/cloudant_hook.py new file mode 100644 index 0000000000000..cbb0ccad43992 --- /dev/null +++ b/airflow/contrib/hooks/cloudant_hook.py @@ -0,0 +1,78 @@ +# -*- coding: utf-8 -*- +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from past.builtins import unicode + +import cloudant + +from airflow.exceptions import AirflowException +from airflow.hooks.base_hook import BaseHook +from airflow.utils.log.logging_mixin import LoggingMixin + + +class CloudantHook(BaseHook): + """Interact with Cloudant. + + This class is a thin wrapper around the cloudant python library. See the + documentation `here `_. + """ + def __init__(self, cloudant_conn_id='cloudant_default'): + super(CloudantHook, self).__init__('cloudant') + self.cloudant_conn_id = cloudant_conn_id + + def get_conn(self): + def _str(s): + # cloudant-python doesn't support unicode. + if isinstance(s, unicode): + log = LoggingMixin().log + log.debug( + 'cloudant-python does not support unicode. Encoding %s as ascii using "ignore".', + s + ) + return s.encode('ascii', 'ignore') + + return s + + conn = self.get_connection(self.cloudant_conn_id) + + for conn_param in ['host', 'password', 'schema']: + if not hasattr(conn, conn_param) or not getattr(conn, conn_param): + raise AirflowException( + 'missing connection parameter {0}'.format(conn_param) + ) + + # In the connection form: + # - 'host' is renamed to 'Account' + # - 'login' is renamed 'Username (or API Key)' + # - 'schema' is renamed to 'Database' + # + # So, use the 'host' attribute as the account name, and, if login is + # defined, use that as the username. + account = cloudant.Account(_str(conn.host)) + + username = _str(conn.login or conn.host) + + account.login( + username, + _str(conn.password)).raise_for_status() + + return account.database(_str(conn.schema)) + + def db(self): + """Returns the Database object for this hook. + + See the documentation for cloudant-python here + https://github.com/cloudant-labs/cloudant-python. + """ + return self.get_conn() diff --git a/airflow/contrib/hooks/databricks_hook.py b/airflow/contrib/hooks/databricks_hook.py new file mode 100644 index 0000000000000..cd9dc547c0bc8 --- /dev/null +++ b/airflow/contrib/hooks/databricks_hook.py @@ -0,0 +1,222 @@ +# -*- coding: utf-8 -*- +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +import requests + +from airflow import __version__ +from airflow.exceptions import AirflowException +from airflow.hooks.base_hook import BaseHook +from requests import exceptions as requests_exceptions +from requests.auth import AuthBase + +from airflow.utils.log.logging_mixin import LoggingMixin + +try: + from urllib import parse as urlparse +except ImportError: + import urlparse + + +SUBMIT_RUN_ENDPOINT = ('POST', 'api/2.0/jobs/runs/submit') +GET_RUN_ENDPOINT = ('GET', 'api/2.0/jobs/runs/get') +CANCEL_RUN_ENDPOINT = ('POST', 'api/2.0/jobs/runs/cancel') +USER_AGENT_HEADER = {'user-agent': 'airflow-{v}'.format(v=__version__)} + + +class DatabricksHook(BaseHook, LoggingMixin): + """ + Interact with Databricks. + """ + def __init__( + self, + databricks_conn_id='databricks_default', + timeout_seconds=180, + retry_limit=3): + """ + :param databricks_conn_id: The name of the databricks connection to use. + :type databricks_conn_id: string + :param timeout_seconds: The amount of time in seconds the requests library + will wait before timing-out. + :type timeout_seconds: int + :param retry_limit: The number of times to retry the connection in case of + service outages. + :type retry_limit: int + """ + self.databricks_conn_id = databricks_conn_id + self.databricks_conn = self.get_connection(databricks_conn_id) + self.timeout_seconds = timeout_seconds + assert retry_limit >= 1, 'Retry limit must be greater than equal to 1' + self.retry_limit = retry_limit + + def _parse_host(self, host): + """ + The purpose of this function is to be robust to improper connections + settings provided by users, specifically in the host field. + + + For example -- when users supply ``https://xx.cloud.databricks.com`` as the + host, we must strip out the protocol to get the host. + >>> h = DatabricksHook() + >>> assert h._parse_host('https://xx.cloud.databricks.com') == \ + 'xx.cloud.databricks.com' + + In the case where users supply the correct ``xx.cloud.databricks.com`` as the + host, this function is a no-op. + >>> assert h._parse_host('xx.cloud.databricks.com') == 'xx.cloud.databricks.com' + """ + urlparse_host = urlparse.urlparse(host).hostname + if urlparse_host: + # In this case, host = https://xx.cloud.databricks.com + return urlparse_host + else: + # In this case, host = xx.cloud.databricks.com + return host + + def _do_api_call(self, endpoint_info, json): + """ + Utility function to perform an API call with retries + :param endpoint_info: Tuple of method and endpoint + :type endpoint_info: (string, string) + :param json: Parameters for this API call. + :type json: dict + :return: If the api call returns a OK status code, + this function returns the response in JSON. Otherwise, + we throw an AirflowException. + :rtype: dict + """ + method, endpoint = endpoint_info + url = 'https://{host}/{endpoint}'.format( + host=self._parse_host(self.databricks_conn.host), + endpoint=endpoint) + if 'token' in self.databricks_conn.extra_dejson: + self.log.info('Using token auth.') + auth = _TokenAuth(self.databricks_conn.extra_dejson['token']) + else: + self.log.info('Using basic auth.') + auth = (self.databricks_conn.login, self.databricks_conn.password) + if method == 'GET': + request_func = requests.get + elif method == 'POST': + request_func = requests.post + else: + raise AirflowException('Unexpected HTTP Method: ' + method) + + for attempt_num in range(1, self.retry_limit+1): + try: + response = request_func( + url, + json=json, + auth=auth, + headers=USER_AGENT_HEADER, + timeout=self.timeout_seconds) + if response.status_code == requests.codes.ok: + return response.json() + else: + # In this case, the user probably made a mistake. + # Don't retry. + raise AirflowException('Response: {0}, Status Code: {1}'.format( + response.content, response.status_code)) + except (requests_exceptions.ConnectionError, + requests_exceptions.Timeout) as e: + self.log.error( + 'Attempt %s API Request to Databricks failed with reason: %s', + attempt_num, e + ) + raise AirflowException(('API requests to Databricks failed {} times. ' + + 'Giving up.').format(self.retry_limit)) + + def submit_run(self, json): + """ + Utility function to call the ``api/2.0/jobs/runs/submit`` endpoint. + + :param json: The data used in the body of the request to the ``submit`` endpoint. + :type json: dict + :return: the run_id as a string + :rtype: string + """ + response = self._do_api_call(SUBMIT_RUN_ENDPOINT, json) + return response['run_id'] + + def get_run_page_url(self, run_id): + json = {'run_id': run_id} + response = self._do_api_call(GET_RUN_ENDPOINT, json) + return response['run_page_url'] + + def get_run_state(self, run_id): + json = {'run_id': run_id} + response = self._do_api_call(GET_RUN_ENDPOINT, json) + state = response['state'] + life_cycle_state = state['life_cycle_state'] + # result_state may not be in the state if not terminal + result_state = state.get('result_state', None) + state_message = state['state_message'] + return RunState(life_cycle_state, result_state, state_message) + + def cancel_run(self, run_id): + json = {'run_id': run_id} + self._do_api_call(CANCEL_RUN_ENDPOINT, json) + + +RUN_LIFE_CYCLE_STATES = [ + 'PENDING', + 'RUNNING', + 'TERMINATING', + 'TERMINATED', + 'SKIPPED', + 'INTERNAL_ERROR' +] + + +class RunState: + """ + Utility class for the run state concept of Databricks runs. + """ + def __init__(self, life_cycle_state, result_state, state_message): + self.life_cycle_state = life_cycle_state + self.result_state = result_state + self.state_message = state_message + + @property + def is_terminal(self): + if self.life_cycle_state not in RUN_LIFE_CYCLE_STATES: + raise AirflowException(('Unexpected life cycle state: {}: If the state has ' + 'been introduced recently, please check the Databricks user ' + 'guide for troubleshooting information').format( + self.life_cycle_state)) + return self.life_cycle_state in ('TERMINATED', 'SKIPPED', 'INTERNAL_ERROR') + + @property + def is_successful(self): + return self.result_state == 'SUCCESS' + + def __eq__(self, other): + return self.life_cycle_state == other.life_cycle_state and \ + self.result_state == other.result_state and \ + self.state_message == other.state_message + + def __repr__(self): + return str(self.__dict__) + + +class _TokenAuth(AuthBase): + """ + Helper class for requests Auth field. AuthBase requires you to implement the __call__ + magic function. + """ + def __init__(self, token): + self.token = token + + def __call__(self, r): + r.headers['Authorization'] = 'Bearer ' + self.token + return r diff --git a/airflow/contrib/hooks/datadog_hook.py b/airflow/contrib/hooks/datadog_hook.py new file mode 100644 index 0000000000000..6caf611adbb61 --- /dev/null +++ b/airflow/contrib/hooks/datadog_hook.py @@ -0,0 +1,135 @@ +# -*- coding: utf-8 -*- +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import time +from airflow.hooks.base_hook import BaseHook +from airflow.exceptions import AirflowException +from datadog import initialize, api + +from airflow.utils.log.logging_mixin import LoggingMixin + + +class DatadogHook(BaseHook, LoggingMixin): + """ + Uses datadog API to send metrics of practically anything measurable, + so it's possible to track # of db records inserted/deleted, records read + from file and many other useful metrics. + + Depends on the datadog API, which has to be deployed on the same server where + Airflow runs. + + :param datadog_conn_id: The connection to datadog, containing metadata for api keys. + :param datadog_conn_id: string + """ + def __init__(self, datadog_conn_id='datadog_default'): + conn = self.get_connection(datadog_conn_id) + self.api_key = conn.extra_dejson.get('api_key', None) + self.app_key = conn.extra_dejson.get('app_key', None) + self.source_type_name = conn.extra_dejson.get('source_type_name', None) + + # If the host is populated, it will use that hostname instead. + # for all metric submissions. + self.host = conn.host + + if self.api_key is None: + raise AirflowException("api_key must be specified in the Datadog connection details") + if self.app_key is None: + raise AirflowException("app_key must be specified in the Datadog connection details") + + self.log.info("Setting up api keys for Datadog") + options = { + 'api_key': self.api_key, + 'app_key': self.app_key + } + initialize(**options) + + def validate_response(self, response): + if response['status'] != 'ok': + self.log.error("Datadog returned: %s", response) + raise AirflowException("Error status received from Datadog") + + def send_metric(self, metric_name, datapoint, tags=None): + """ + Sends a single datapoint metric to DataDog + + :param metric_name: The name of the metric + :type metric_name: string + :param datapoint: A single integer or float related to the metric + :type datapoint: integer or float + :param tags: A list of tags associated with the metric + :type tags: list + """ + response = api.Metric.send( + metric=metric_name, + points=datapoint, + host=self.host, + tags=tags) + + self.validate_response(response) + return response + + def query_metric(self, + query, + from_seconds_ago, + to_seconds_ago): + """ + Queries datadog for a specific metric, potentially with some function applied to it + and returns the results. + + :param query: The datadog query to execute (see datadog docs) + :type query: string + :param from_seconds_ago: How many seconds ago to start querying for. + :type from_seconds_ago: int + :param to_seconds_ago: Up to how many seconds ago to query for. + :type to_seconds_ago: int + """ + now = int(time.time()) + + response = api.Metric.query( + start=now - from_seconds_ago, + end=now - to_seconds_ago, + query=query) + + self.validate_response(response) + return response + + def post_event(self, title, text, tags=None, alert_type=None, aggregation_key=None): + """ + Posts an event to datadog (processing finished, potentially alerts, other issues) + Think about this as a means to maintain persistence of alerts, rather than alerting + itself. + + :param title: The title of the event + :type title: string + :param text: The body of the event (more information) + :type text: string + :param tags: List of string tags to apply to the event + :type tags: list + :param alert_type: The alert type for the event, one of + ["error", "warning", "info", "success"] + :type alert_type: string + :param aggregation_key: Key that can be used to aggregate this event in a stream + :type aggregation_key: string + """ + response = api.Event.create( + title=title, + text=text, + host=self.host, + tags=tags, + alert_type=alert_type, + aggregation_key=aggregation_key, + source_type_name=self.source_type_name) + + self.validate_response(response) + return response diff --git a/airflow/contrib/hooks/datastore_hook.py b/airflow/contrib/hooks/datastore_hook.py new file mode 100644 index 0000000000000..cf98dc749f2e4 --- /dev/null +++ b/airflow/contrib/hooks/datastore_hook.py @@ -0,0 +1,177 @@ +# -*- coding: utf-8 -*- +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +import json +import time +from apiclient.discovery import build +from airflow.contrib.hooks.gcp_api_base_hook import GoogleCloudBaseHook + + +class DatastoreHook(GoogleCloudBaseHook): + """ + Interact with Google Cloud Datastore. This hook uses the Google Cloud Platform + connection. + + This object is not threads safe. If you want to make multiple requests + simultaniously, you will need to create a hook per thread. + """ + + def __init__(self, + datastore_conn_id='google_cloud_datastore_default', + delegate_to=None): + super(DatastoreHook, self).__init__(datastore_conn_id, delegate_to) + self.connection = self.get_conn() + self.admin_connection = self.get_conn('v1beta1') + + def get_conn(self, version='v1'): + """ + Returns a Google Cloud Storage service object. + """ + http_authorized = self._authorize() + return build('datastore', version, http=http_authorized) + + def allocate_ids(self, partialKeys): + """ + Allocate IDs for incomplete keys. + see https://cloud.google.com/datastore/docs/reference/rest/v1/projects/allocateIds + + :param partialKeys: a list of partial keys + :return: a list of full keys. + """ + resp = self.connection.projects().allocateIds(projectId=self.project_id, body={'keys': partialKeys}).execute() + return resp['keys'] + + def begin_transaction(self): + """ + Get a new transaction handle + see https://cloud.google.com/datastore/docs/reference/rest/v1/projects/beginTransaction + + :return: a transaction handle + """ + resp = self.connection.projects().beginTransaction(projectId=self.project_id, body={}).execute() + return resp['transaction'] + + def commit(self, body): + """ + Commit a transaction, optionally creating, deleting or modifying some entities. + see https://cloud.google.com/datastore/docs/reference/rest/v1/projects/commit + + :param body: the body of the commit request + :return: the response body of the commit request + """ + resp = self.connection.projects().commit(projectId=self.project_id, body=body).execute() + return resp + + def lookup(self, keys, read_consistency=None, transaction=None): + """ + Lookup some entities by key + see https://cloud.google.com/datastore/docs/reference/rest/v1/projects/lookup + :param keys: the keys to lookup + :param read_consistency: the read consistency to use. default, strong or eventual. + Cannot be used with a transaction. + :param transaction: the transaction to use, if any. + :return: the response body of the lookup request. + """ + body = {'keys': keys} + if read_consistency: + body['readConsistency'] = read_consistency + if transaction: + body['transaction'] = transaction + return self.connection.projects().lookup(projectId=self.project_id, body=body).execute() + + def rollback(self, transaction): + """ + Roll back a transaction + see https://cloud.google.com/datastore/docs/reference/rest/v1/projects/rollback + :param transaction: the transaction to roll back + """ + self.connection.projects().rollback(projectId=self.project_id, body={'transaction': transaction})\ + .execute() + + def run_query(self, body): + """ + Run a query for entities. + see https://cloud.google.com/datastore/docs/reference/rest/v1/projects/runQuery + :param body: the body of the query request + :return: the batch of query results. + """ + resp = self.connection.projects().runQuery(projectId=self.project_id, body=body).execute() + return resp['batch'] + + def get_operation(self, name): + """ + Gets the latest state of a long-running operation + + :param name: the name of the operation resource + """ + resp = self.connection.projects().operations().get(name=name).execute() + return resp + + def delete_operation(self, name): + """ + Deletes the long-running operation + + :param name: the name of the operation resource + """ + resp = self.connection.projects().operations().delete(name=name).execute() + return resp + + def poll_operation_until_done(self, name, polling_interval_in_seconds): + """ + Poll backup operation state until it's completed + """ + while True: + result = self.get_operation(name) + state = result['metadata']['common']['state'] + if state == 'PROCESSING': + self.log.info('Operation is processing. Re-polling state in {} seconds' + .format(polling_interval_in_seconds)) + time.sleep(polling_interval_in_seconds) + else: + return result + + def export_to_storage_bucket(self, bucket, namespace=None, entity_filter=None, labels=None): + """ + Export entities from Cloud Datastore to Cloud Storage for backup + """ + output_uri_prefix = 'gs://' + ('/').join(filter(None, [bucket, namespace])) + if not entity_filter: + entity_filter = {} + if not labels: + labels = {} + body = { + 'outputUrlPrefix': output_uri_prefix, + 'entityFilter': entity_filter, + 'labels': labels, + } + resp = self.admin_connection.projects().export(projectId=self.project_id, body=body).execute() + return resp + + def import_from_storage_bucket(self, bucket, file, namespace=None, entity_filter=None, labels=None): + """ + Import a backup from Cloud Storage to Cloud Datastore + """ + input_url = 'gs://' + ('/').join(filter(None, [bucket, namespace, file])) + if not entity_filter: + entity_filter = {} + if not labels: + labels = {} + body = { + 'inputUrl': input_url, + 'entityFilter': entity_filter, + 'labels': labels, + } + resp = self.admin_connection.projects().import_(projectId=self.project_id, body=body).execute() + return resp diff --git a/airflow/contrib/hooks/emr_hook.py b/airflow/contrib/hooks/emr_hook.py new file mode 100644 index 0000000000000..cee239867ca8d --- /dev/null +++ b/airflow/contrib/hooks/emr_hook.py @@ -0,0 +1,62 @@ +# -*- coding: utf-8 -*- +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from airflow.exceptions import AirflowException +from airflow.contrib.hooks.aws_hook import AwsHook + + +class EmrHook(AwsHook): + """ + Interact with AWS EMR. emr_conn_id is only neccessary for using the create_job_flow method. + """ + + def __init__(self, emr_conn_id=None, *args, **kwargs): + self.emr_conn_id = emr_conn_id + super(EmrHook, self).__init__(*args, **kwargs) + + def get_conn(self): + self.conn = self.get_client_type('emr') + return self.conn + + def create_job_flow(self, job_flow_overrides): + """ + Creates a job flow using the config from the EMR connection. + Keys of the json extra hash may have the arguments of the boto3 run_job_flow method. + Overrides for this config may be passed as the job_flow_overrides. + """ + + if not self.emr_conn_id: + raise AirflowException('emr_conn_id must be present to use create_job_flow') + + emr_conn = self.get_connection(self.emr_conn_id) + + config = emr_conn.extra_dejson.copy() + config.update(job_flow_overrides) + + response = self.get_conn().run_job_flow( + Name=config.get('Name'), + LogUri=config.get('LogUri'), + ReleaseLabel=config.get('ReleaseLabel'), + Instances=config.get('Instances'), + Steps=config.get('Steps', []), + BootstrapActions=config.get('BootstrapActions', []), + Applications=config.get('Applications'), + Configurations=config.get('Configurations', []), + VisibleToAllUsers=config.get('VisibleToAllUsers'), + JobFlowRole=config.get('JobFlowRole'), + ServiceRole=config.get('ServiceRole'), + Tags=config.get('Tags') + ) + + return response diff --git a/airflow/contrib/hooks/fs_hook.py b/airflow/contrib/hooks/fs_hook.py new file mode 100644 index 0000000000000..bee60cedba88b --- /dev/null +++ b/airflow/contrib/hooks/fs_hook.py @@ -0,0 +1,41 @@ +# -*- coding: utf-8 -*- +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +from airflow.hooks.base_hook import BaseHook + + +class FSHook(BaseHook): + ''' + Allows for interaction with an file server. + + Connection should have a name and a path specified under extra: + + example: + Conn Id: fs_test + Conn Type: File (path) + Host, Shchema, Login, Password, Port: empty + Extra: {"path": "/tmp"} + ''' + + def __init__(self, conn_id='fs_default'): + conn = self.get_connection(conn_id) + self.basepath = conn.extra_dejson.get('path', '') + self.conn = conn + + def get_conn(self): + pass + + def get_path(self): + return self.basepath diff --git a/airflow/contrib/hooks/ftp_hook.py b/airflow/contrib/hooks/ftp_hook.py new file mode 100644 index 0000000000000..b1e224db6d6bc --- /dev/null +++ b/airflow/contrib/hooks/ftp_hook.py @@ -0,0 +1,253 @@ +# -*- coding: utf-8 -*- +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +import datetime +import ftplib +import os.path +from airflow.hooks.base_hook import BaseHook +from past.builtins import basestring + +from airflow.utils.log.logging_mixin import LoggingMixin + + +def mlsd(conn, path="", facts=None): + """ + BACKPORT FROM PYTHON3 FTPLIB. + + List a directory in a standardized format by using MLSD + command (RFC-3659). If path is omitted the current directory + is assumed. "facts" is a list of strings representing the type + of information desired (e.g. ["type", "size", "perm"]). + + Return a generator object yielding a tuple of two elements + for every file found in path. + First element is the file name, the second one is a dictionary + including a variable number of "facts" depending on the server + and whether "facts" argument has been provided. + """ + facts = facts or [] + if facts: + conn.sendcmd("OPTS MLST " + ";".join(facts) + ";") + if path: + cmd = "MLSD %s" % path + else: + cmd = "MLSD" + lines = [] + conn.retrlines(cmd, lines.append) + for line in lines: + facts_found, _, name = line.rstrip(ftplib.CRLF).partition(' ') + entry = {} + for fact in facts_found[:-1].split(";"): + key, _, value = fact.partition("=") + entry[key.lower()] = value + yield (name, entry) + + +class FTPHook(BaseHook, LoggingMixin): + """ + Interact with FTP. + + Errors that may occur throughout but should be handled + downstream. + """ + + def __init__(self, ftp_conn_id='ftp_default'): + self.ftp_conn_id = ftp_conn_id + self.conn = None + + def __enter__(self): + return self + + def __exit__(self, exc_type, exc_val, exc_tb): + if self.conn is not None: + self.close_conn() + + def get_conn(self): + """ + Returns a FTP connection object + """ + if self.conn is None: + params = self.get_connection(self.ftp_conn_id) + self.conn = ftplib.FTP(params.host, params.login, params.password) + + return self.conn + + def close_conn(self): + """ + Closes the connection. An error will occur if the + connection wasn't ever opened. + """ + conn = self.conn + conn.quit() + + def describe_directory(self, path): + """ + Returns a dictionary of {filename: {attributes}} for all files + on the remote system (where the MLSD command is supported). + + :param path: full path to the remote directory + :type path: str + """ + conn = self.get_conn() + conn.cwd(path) + try: + # only works in Python 3 + files = dict(conn.mlsd()) + except AttributeError: + files = dict(mlsd(conn)) + return files + + def list_directory(self, path, nlst=False): + """ + Returns a list of files on the remote system. + + :param path: full path to the remote directory to list + :type path: str + """ + conn = self.get_conn() + conn.cwd(path) + + files = conn.nlst() + return files + + def create_directory(self, path): + """ + Creates a directory on the remote system. + + :param path: full path to the remote directory to create + :type path: str + """ + conn = self.get_conn() + conn.mkd(path) + + def delete_directory(self, path): + """ + Deletes a directory on the remote system. + + :param path: full path to the remote directory to delete + :type path: str + """ + conn = self.get_conn() + conn.rmd(path) + + def retrieve_file(self, remote_full_path, local_full_path_or_buffer): + """ + Transfers the remote file to a local location. + + If local_full_path_or_buffer is a string path, the file will be put + at that location; if it is a file-like buffer, the file will + be written to the buffer but not closed. + + :param remote_full_path: full path to the remote file + :type remote_full_path: str + :param local_full_path_or_buffer: full path to the local file or a + file-like buffer + :type local_full_path: str or file-like buffer + """ + conn = self.get_conn() + + is_path = isinstance(local_full_path_or_buffer, basestring) + + if is_path: + output_handle = open(local_full_path_or_buffer, 'wb') + else: + output_handle = local_full_path_or_buffer + + remote_path, remote_file_name = os.path.split(remote_full_path) + conn.cwd(remote_path) + self.log.info('Retrieving file from FTP: %s', remote_full_path) + conn.retrbinary('RETR %s' % remote_file_name, output_handle.write) + self.log.info('Finished retrieving file from FTP: %s', remote_full_path) + + if is_path: + output_handle.close() + + def store_file(self, remote_full_path, local_full_path_or_buffer): + """ + Transfers a local file to the remote location. + + If local_full_path_or_buffer is a string path, the file will be read + from that location; if it is a file-like buffer, the file will + be read from the buffer but not closed. + + :param remote_full_path: full path to the remote file + :type remote_full_path: str + :param local_full_path_or_buffer: full path to the local file or a + file-like buffer + :type local_full_path_or_buffer: str or file-like buffer + """ + conn = self.get_conn() + + is_path = isinstance(local_full_path_or_buffer, basestring) + + if is_path: + input_handle = open(local_full_path_or_buffer, 'rb') + else: + input_handle = local_full_path_or_buffer + remote_path, remote_file_name = os.path.split(remote_full_path) + conn.cwd(remote_path) + conn.storbinary('STOR %s' % remote_file_name, input_handle) + + if is_path: + input_handle.close() + + def delete_file(self, path): + """ + Removes a file on the FTP Server. + + :param path: full path to the remote file + :type path: str + """ + conn = self.get_conn() + conn.delete(path) + + def rename(self, from_name, to_name): + """ + Rename a file. + + :param from_name: rename file from name + :param to_name: rename file to name + """ + conn = self.get_conn() + return conn.rename(from_name, to_name) + + def get_mod_time(self, path): + conn = self.get_conn() + ftp_mdtm = conn.sendcmd('MDTM ' + path) + time_val = ftp_mdtm[4:] + # time_val optionally has microseconds + try: + return datetime.datetime.strptime(time_val, "%Y%m%d%H%M%S.%f") + except ValueError: + return datetime.datetime.strptime(time_val, '%Y%m%d%H%M%S') + + +class FTPSHook(FTPHook): + + def get_conn(self): + """ + Returns a FTPS connection object. + """ + if self.conn is None: + params = self.get_connection(self.ftp_conn_id) + + if params.port: + ftplib.FTP_TLS.port=params.port + + self.conn = ftplib.FTP_TLS( + params.host, params.login, params.password + ) + + return self.conn diff --git a/airflow/contrib/hooks/gcp_api_base_hook.py b/airflow/contrib/hooks/gcp_api_base_hook.py new file mode 100644 index 0000000000000..e6ca2402c4a93 --- /dev/null +++ b/airflow/contrib/hooks/gcp_api_base_hook.py @@ -0,0 +1,140 @@ +# -*- coding: utf-8 -*- +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +import json + +import httplib2 +from oauth2client.client import GoogleCredentials +from oauth2client.service_account import ServiceAccountCredentials + +from airflow.exceptions import AirflowException +from airflow.hooks.base_hook import BaseHook +from airflow.utils.log.logging_mixin import LoggingMixin + + +class GoogleCloudBaseHook(BaseHook, LoggingMixin): + """ + A base hook for Google cloud-related hooks. Google cloud has a shared REST + API client that is built in the same way no matter which service you use. + This class helps construct and authorize the credentials needed to then + call apiclient.discovery.build() to actually discover and build a client + for a Google cloud service. + + The class also contains some miscellaneous helper functions. + + All hook derived from this base hook use the 'Google Cloud Platform' connection + type. Two ways of authentication are supported: + + Default credentials: Only specify 'Project Id'. Then you need to have executed + ``gcloud auth`` on the Airflow worker machine. + + JSON key file: Specify 'Project Id', 'Key Path' and 'Scope'. + + Legacy P12 key files are not supported. + """ + def __init__(self, conn_id, delegate_to=None): + """ + :param conn_id: The connection ID to use when fetching connection info. + :type conn_id: string + :param delegate_to: The account to impersonate, if any. + For this to work, the service account making the request must have + domain-wide delegation enabled. + :type delegate_to: string + """ + self.conn_id = conn_id + self.delegate_to = delegate_to + self.extras = self.get_connection(conn_id).extra_dejson + + def _get_credentials(self): + """ + Returns the Credentials object for Google API + """ + key_path = self._get_field('key_path', False) + keyfile_dict = self._get_field('keyfile_dict', False) + scope = self._get_field('scope', False) + + kwargs = {} + if self.delegate_to: + kwargs['sub'] = self.delegate_to + + if not key_path and not keyfile_dict: + self.log.info('Getting connection using `gcloud auth` user, since no key file ' + 'is defined for hook.') + credentials = GoogleCredentials.get_application_default() + elif key_path: + if not scope: + raise AirflowException('Scope should be defined when using a key file.') + scopes = [s.strip() for s in scope.split(',')] + + # Get credentials from a JSON file. + if key_path.endswith('.json'): + self.log.info('Getting connection using a JSON key file.') + credentials = ServiceAccountCredentials\ + .from_json_keyfile_name(key_path, scopes) + elif key_path.endswith('.p12'): + raise AirflowException('Legacy P12 key file are not supported, ' + 'use a JSON key file.') + else: + raise AirflowException('Unrecognised extension for key file.') + else: + if not scope: + raise AirflowException('Scope should be defined when using key JSON.') + scopes = [s.strip() for s in scope.split(',')] + + # Get credentials from JSON data provided in the UI. + try: + keyfile_dict = json.loads(keyfile_dict) + + # Depending on how the JSON was formatted, it may contain + # escaped newlines. Convert those to actual newlines. + keyfile_dict['private_key'] = keyfile_dict['private_key'].replace( + '\\n', '\n') + + credentials = ServiceAccountCredentials\ + .from_json_keyfile_dict(keyfile_dict, scopes) + except json.decoder.JSONDecodeError: + raise AirflowException('Invalid key JSON.') + return credentials + + def _get_access_token(self): + """ + Returns a valid access token from Google API Credentials + """ + return self._get_credentials().get_access_token().access_token + + def _authorize(self): + """ + Returns an authorized HTTP object to be used to build a Google cloud + service hook connection. + """ + credentials = self._get_credentials() + http = httplib2.Http() + return credentials.authorize(http) + + def _get_field(self, f, default=None): + """ + Fetches a field from extras, and returns it. This is some Airflow + magic. The google_cloud_platform hook type adds custom UI elements + to the hook page, which allow admins to specify service_account, + key_path, etc. They get formatted as shown below. + """ + long_f = 'extra__google_cloud_platform__{}'.format(f) + if long_f in self.extras: + return self.extras[long_f] + else: + return default + + @property + def project_id(self): + return self._get_field('project') diff --git a/airflow/contrib/hooks/gcp_dataflow_hook.py b/airflow/contrib/hooks/gcp_dataflow_hook.py new file mode 100644 index 0000000000000..b1a1e0e1f3e2e --- /dev/null +++ b/airflow/contrib/hooks/gcp_dataflow_hook.py @@ -0,0 +1,165 @@ +# -*- coding: utf-8 -*- +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +import select +import subprocess +import time +import uuid + +from apiclient.discovery import build + +from airflow.contrib.hooks.gcp_api_base_hook import GoogleCloudBaseHook +from airflow.utils.log.logging_mixin import LoggingMixin + + +class _DataflowJob(LoggingMixin): + def __init__(self, dataflow, project_number, name, poll_sleep=10): + self._dataflow = dataflow + self._project_number = project_number + self._job_name = name + self._job_id = None + self._job = self._get_job() + self._poll_sleep = poll_sleep + + def _get_job_id_from_name(self): + jobs = self._dataflow.projects().jobs().list( + projectId=self._project_number + ).execute() + for job in jobs['jobs']: + if job['name'] == self._job_name: + self._job_id = job['id'] + return job + return None + + def _get_job(self): + if self._job_id is None: + job = self._get_job_id_from_name() + else: + job = self._dataflow.projects().jobs().get(projectId=self._project_number, + jobId=self._job_id).execute() + if 'currentState' in job: + self.log.info( + 'Google Cloud DataFlow job %s is %s', + job['name'], job['currentState'] + ) + else: + self.log.info( + 'Google Cloud DataFlow with job_id %s has name %s', + self._job_id, job['name'] + ) + return job + + def wait_for_done(self): + while True: + if 'currentState' in self._job: + if 'JOB_STATE_DONE' == self._job['currentState']: + return True + elif 'JOB_STATE_FAILED' == self._job['currentState']: + raise Exception("Google Cloud Dataflow job {} has failed.".format( + self._job['name'])) + elif 'JOB_STATE_CANCELLED' == self._job['currentState']: + raise Exception("Google Cloud Dataflow job {} was cancelled.".format( + self._job['name'])) + elif 'JOB_STATE_RUNNING' == self._job['currentState']: + time.sleep(self._poll_sleep) + elif 'JOB_STATE_PENDING' == self._job['currentState']: + time.sleep(15) + else: + self.log.debug(str(self._job)) + raise Exception( + "Google Cloud Dataflow job {} was unknown state: {}".format( + self._job['name'], self._job['currentState'])) + else: + time.sleep(15) + + self._job = self._get_job() + + def get(self): + return self._job + + +class _Dataflow(LoggingMixin): + def __init__(self, cmd): + self._proc = subprocess.Popen(cmd, shell=False, stdout=subprocess.PIPE, + stderr=subprocess.PIPE) + + def _line(self, fd): + if fd == self._proc.stderr.fileno(): + line = self._proc.stderr.readline() + return line + if fd == self._proc.stdout.fileno(): + line = self._proc.stdout.readline() + return line + + @staticmethod + def _extract_job(line): + if line is not None: + if line.startswith("Submitted job: "): + return line[15:-1] + + def wait_for_done(self): + reads = [self._proc.stderr.fileno(), self._proc.stdout.fileno()] + self.log.info("Start waiting for DataFlow process to complete.") + while self._proc.poll() is None: + ret = select.select(reads, [], [], 5) + if ret is not None: + for fd in ret[0]: + line = self._line(fd) + self.log.debug(line[:-1]) + else: + self.log.info("Waiting for DataFlow process to complete.") + if self._proc.returncode is not 0: + raise Exception("DataFlow failed with return code {}".format( + self._proc.returncode)) + + +class DataFlowHook(GoogleCloudBaseHook): + + def __init__(self, + gcp_conn_id='google_cloud_default', + delegate_to=None, + poll_sleep=10): + self.poll_sleep = poll_sleep + super(DataFlowHook, self).__init__(gcp_conn_id, delegate_to) + + def get_conn(self): + """ + Returns a Google Cloud Storage service object. + """ + http_authorized = self._authorize() + return build('dataflow', 'v1b3', http=http_authorized) + + def _start_dataflow(self, task_id, variables, dataflow, name, command_prefix): + cmd = command_prefix + self._build_cmd(task_id, variables, dataflow) + _Dataflow(cmd).wait_for_done() + _DataflowJob( + self.get_conn(), variables['project'], name, self.poll_sleep).wait_for_done() + + def start_java_dataflow(self, task_id, variables, dataflow): + name = task_id + "-" + str(uuid.uuid1())[:8] + variables['jobName'] = name + self._start_dataflow( + task_id, variables, dataflow, name, ["java", "-jar"]) + + def start_python_dataflow(self, task_id, variables, dataflow, py_options): + name = task_id + "-" + str(uuid.uuid1())[:8] + variables["job_name"] = name + self._start_dataflow( + task_id, variables, dataflow, name, ["python"] + py_options) + + def _build_cmd(self, task_id, variables, dataflow): + command = [dataflow, "--runner=DataflowRunner"] + if variables is not None: + for attr, value in variables.iteritems(): + command.append("--" + attr + "=" + value) + return command diff --git a/airflow/contrib/hooks/gcp_dataproc_hook.py b/airflow/contrib/hooks/gcp_dataproc_hook.py new file mode 100644 index 0000000000000..5b96484bd8c95 --- /dev/null +++ b/airflow/contrib/hooks/gcp_dataproc_hook.py @@ -0,0 +1,164 @@ +# -*- coding: utf-8 -*- +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +import time +import uuid + +from apiclient.discovery import build + +from airflow.contrib.hooks.gcp_api_base_hook import GoogleCloudBaseHook +from airflow.utils.log.logging_mixin import LoggingMixin + + +class _DataProcJob(LoggingMixin): + def __init__(self, dataproc_api, project_id, job, region='global'): + self.dataproc_api = dataproc_api + self.project_id = project_id + self.region = region + self.job = dataproc_api.projects().regions().jobs().submit( + projectId=self.project_id, + region=self.region, + body=job).execute() + self.job_id = self.job['reference']['jobId'] + self.log.info( + 'DataProc job %s is %s', + self.job_id, str(self.job['status']['state']) + ) + + def wait_for_done(self): + while True: + self.job = self.dataproc_api.projects().regions().jobs().get( + projectId=self.project_id, + region=self.region, + jobId=self.job_id).execute(num_retries=5) + if 'ERROR' == self.job['status']['state']: + print(str(self.job)) + self.log.error('DataProc job %s has errors', self.job_id) + self.log.error(self.job['status']['details']) + self.log.debug(str(self.job)) + return False + if 'CANCELLED' == self.job['status']['state']: + print(str(self.job)) + self.log.warning('DataProc job %s is cancelled', self.job_id) + if 'details' in self.job['status']: + self.log.warning(self.job['status']['details']) + self.log.debug(str(self.job)) + return False + if 'DONE' == self.job['status']['state']: + return True + self.log.debug( + 'DataProc job %s is %s', + self.job_id, str(self.job['status']['state']) + ) + time.sleep(5) + + def raise_error(self, message=None): + if 'ERROR' == self.job['status']['state']: + if message is None: + message = "Google DataProc job has error" + raise Exception(message + ": " + str(self.job['status']['details'])) + + def get(self): + return self.job + + +class _DataProcJobBuilder: + def __init__(self, project_id, task_id, cluster_name, job_type, properties): + name = task_id + "_" + str(uuid.uuid1())[:8] + self.job_type = job_type + self.job = { + "job": { + "reference": { + "projectId": project_id, + "jobId": name, + }, + "placement": { + "clusterName": cluster_name + }, + job_type: { + } + } + } + if properties is not None: + self.job["job"][job_type]["properties"] = properties + + def add_variables(self, variables): + if variables is not None: + self.job["job"][self.job_type]["scriptVariables"] = variables + + def add_args(self, args): + if args is not None: + self.job["job"][self.job_type]["args"] = args + + def add_query(self, query): + self.job["job"][self.job_type]["queryList"] = {'queries': [query]} + + def add_query_uri(self, query_uri): + self.job["job"][self.job_type]["queryFileUri"] = query_uri + + def add_jar_file_uris(self, jars): + if jars is not None: + self.job["job"][self.job_type]["jarFileUris"] = jars + + def add_archive_uris(self, archives): + if archives is not None: + self.job["job"][self.job_type]["archiveUris"] = archives + + def add_file_uris(self, files): + if files is not None: + self.job["job"][self.job_type]["fileUris"] = files + + def add_python_file_uris(self, pyfiles): + if pyfiles is not None: + self.job["job"][self.job_type]["pythonFileUris"] = pyfiles + + def set_main(self, main_jar, main_class): + if main_class is not None and main_jar is not None: + raise Exception("Set either main_jar or main_class") + if main_jar: + self.job["job"][self.job_type]["mainJarFileUri"] = main_jar + else: + self.job["job"][self.job_type]["mainClass"] = main_class + + def set_python_main(self, main): + self.job["job"][self.job_type]["mainPythonFileUri"] = main + + def set_job_name(self, name): + self.job["job"]["reference"]["jobId"] = name + "_" + str(uuid.uuid1())[:8] + + def build(self): + return self.job + + +class DataProcHook(GoogleCloudBaseHook): + def __init__(self, + gcp_conn_id='google_cloud_default', + delegate_to=None): + super(DataProcHook, self).__init__(gcp_conn_id, delegate_to) + + def get_conn(self): + """ + Returns a Google Cloud DataProc service object. + """ + http_authorized = self._authorize() + return build('dataproc', 'v1', http=http_authorized) + + def submit(self, project_id, job, region='global'): + submitted = _DataProcJob(self.get_conn(), project_id, job, region) + if not submitted.wait_for_done(): + submitted.raise_error("DataProcTask has errors") + + def create_job_template(self, task_id, cluster_name, job_type, properties): + return _DataProcJobBuilder(self.project_id, task_id, cluster_name, job_type, + properties) diff --git a/airflow/contrib/hooks/gcp_mlengine_hook.py b/airflow/contrib/hooks/gcp_mlengine_hook.py new file mode 100644 index 0000000000000..c17b614a4a536 --- /dev/null +++ b/airflow/contrib/hooks/gcp_mlengine_hook.py @@ -0,0 +1,267 @@ +# +# Licensed to the Apache Software Foundation (ASF) under one or more +# contributor license agreements. See the NOTICE file distributed with +# this work for additional information regarding copyright ownership. +# The ASF licenses this file to You under the Apache License, Version 2.0 +# (the "License"); you may not use this file except in compliance with +# the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +import random +import time +from apiclient import errors +from apiclient.discovery import build +from oauth2client.client import GoogleCredentials + +from airflow.contrib.hooks.gcp_api_base_hook import GoogleCloudBaseHook +from airflow.utils.log.logging_mixin import LoggingMixin + + +def _poll_with_exponential_delay(request, max_n, is_done_func, is_error_func): + log = LoggingMixin().log + + for i in range(0, max_n): + try: + response = request.execute() + if is_error_func(response): + raise ValueError( + 'The response contained an error: {}'.format(response) + ) + elif is_done_func(response): + log.info('Operation is done: %s', response) + return response + else: + time.sleep((2**i) + (random.randint(0, 1000) / 1000)) + except errors.HttpError as e: + if e.resp.status != 429: + log.info('Something went wrong. Not retrying: %s', format(e)) + raise + else: + time.sleep((2**i) + (random.randint(0, 1000) / 1000)) + + +class MLEngineHook(GoogleCloudBaseHook): + def __init__(self, gcp_conn_id='google_cloud_default', delegate_to=None): + super(MLEngineHook, self).__init__(gcp_conn_id, delegate_to) + self._mlengine = self.get_conn() + + def get_conn(self): + """ + Returns a Google MLEngine service object. + """ + credentials = GoogleCredentials.get_application_default() + return build('ml', 'v1', credentials=credentials) + + def create_job(self, project_id, job, use_existing_job_fn=None): + """ + Launches a MLEngine job and wait for it to reach a terminal state. + + :param project_id: The Google Cloud project id within which MLEngine + job will be launched. + :type project_id: string + + :param job: MLEngine Job object that should be provided to the MLEngine + API, such as: + { + 'jobId': 'my_job_id', + 'trainingInput': { + 'scaleTier': 'STANDARD_1', + ... + } + } + :type job: dict + + :param use_existing_job_fn: In case that a MLEngine job with the same + job_id already exist, this method (if provided) will decide whether + we should use this existing job, continue waiting for it to finish + and returning the job object. It should accepts a MLEngine job + object, and returns a boolean value indicating whether it is OK to + reuse the existing job. If 'use_existing_job_fn' is not provided, + we by default reuse the existing MLEngine job. + :type use_existing_job_fn: function + + :return: The MLEngine job object if the job successfully reach a + terminal state (which might be FAILED or CANCELLED state). + :rtype: dict + """ + request = self._mlengine.projects().jobs().create( + parent='projects/{}'.format(project_id), + body=job) + job_id = job['jobId'] + + try: + request.execute() + except errors.HttpError as e: + # 409 means there is an existing job with the same job ID. + if e.resp.status == 409: + if use_existing_job_fn is not None: + existing_job = self._get_job(project_id, job_id) + if not use_existing_job_fn(existing_job): + self.log.error( + 'Job with job_id %s already exist, but it does ' + 'not match our expectation: %s', + job_id, existing_job + ) + raise + self.log.info( + 'Job with job_id %s already exist. Will waiting for it to finish', + job_id + ) + else: + self.log.error('Failed to create MLEngine job: {}'.format(e)) + raise + + return self._wait_for_job_done(project_id, job_id) + + def _get_job(self, project_id, job_id): + """ + Gets a MLEngine job based on the job name. + + :return: MLEngine job object if succeed. + :rtype: dict + + Raises: + apiclient.errors.HttpError: if HTTP error is returned from server + """ + job_name = 'projects/{}/jobs/{}'.format(project_id, job_id) + request = self._mlengine.projects().jobs().get(name=job_name) + while True: + try: + return request.execute() + except errors.HttpError as e: + if e.resp.status == 429: + # polling after 30 seconds when quota failure occurs + time.sleep(30) + else: + self.log.error('Failed to get MLEngine job: {}'.format(e)) + raise + + def _wait_for_job_done(self, project_id, job_id, interval=30): + """ + Waits for the Job to reach a terminal state. + + This method will periodically check the job state until the job reach + a terminal state. + + Raises: + apiclient.errors.HttpError: if HTTP error is returned when getting + the job + """ + assert interval > 0 + while True: + job = self._get_job(project_id, job_id) + if job['state'] in ['SUCCEEDED', 'FAILED', 'CANCELLED']: + return job + time.sleep(interval) + + def create_version(self, project_id, model_name, version_spec): + """ + Creates the Version on Google Cloud ML Engine. + + Returns the operation if the version was created successfully and + raises an error otherwise. + """ + parent_name = 'projects/{}/models/{}'.format(project_id, model_name) + create_request = self._mlengine.projects().models().versions().create( + parent=parent_name, body=version_spec) + response = create_request.execute() + get_request = self._mlengine.projects().operations().get( + name=response['name']) + + return _poll_with_exponential_delay( + request=get_request, + max_n=9, + is_done_func=lambda resp: resp.get('done', False), + is_error_func=lambda resp: resp.get('error', None) is not None) + + def set_default_version(self, project_id, model_name, version_name): + """ + Sets a version to be the default. Blocks until finished. + """ + full_version_name = 'projects/{}/models/{}/versions/{}'.format( + project_id, model_name, version_name) + request = self._mlengine.projects().models().versions().setDefault( + name=full_version_name, body={}) + + try: + response = request.execute() + self.log.info('Successfully set version: %s to default', response) + return response + except errors.HttpError as e: + self.log.error('Something went wrong: %s', e) + raise + + def list_versions(self, project_id, model_name): + """ + Lists all available versions of a model. Blocks until finished. + """ + result = [] + full_parent_name = 'projects/{}/models/{}'.format( + project_id, model_name) + request = self._mlengine.projects().models().versions().list( + parent=full_parent_name, pageSize=100) + + response = request.execute() + next_page_token = response.get('nextPageToken', None) + result.extend(response.get('versions', [])) + while next_page_token is not None: + next_request = self._mlengine.projects().models().versions().list( + parent=full_parent_name, + pageToken=next_page_token, + pageSize=100) + response = next_request.execute() + next_page_token = response.get('nextPageToken', None) + result.extend(response.get('versions', [])) + time.sleep(5) + return result + + def delete_version(self, project_id, model_name, version_name): + """ + Deletes the given version of a model. Blocks until finished. + """ + full_name = 'projects/{}/models/{}/versions/{}'.format( + project_id, model_name, version_name) + delete_request = self._mlengine.projects().models().versions().delete( + name=full_name) + response = delete_request.execute() + get_request = self._mlengine.projects().operations().get( + name=response['name']) + + return _poll_with_exponential_delay( + request=get_request, + max_n=9, + is_done_func=lambda resp: resp.get('done', False), + is_error_func=lambda resp: resp.get('error', None) is not None) + + def create_model(self, project_id, model): + """ + Create a Model. Blocks until finished. + """ + assert model['name'] is not None and model['name'] is not '' + project = 'projects/{}'.format(project_id) + + request = self._mlengine.projects().models().create( + parent=project, body=model) + return request.execute() + + def get_model(self, project_id, model_name): + """ + Gets a Model. Blocks until finished. + """ + assert model_name is not None and model_name is not '' + full_model_name = 'projects/{}/models/{}'.format( + project_id, model_name) + request = self._mlengine.projects().models().get(name=full_model_name) + try: + return request.execute() + except errors.HttpError as e: + if e.resp.status == 404: + self.log.error('Model was not found: %s', e) + return None + raise diff --git a/airflow/contrib/hooks/gcp_pubsub_hook.py b/airflow/contrib/hooks/gcp_pubsub_hook.py new file mode 100644 index 0000000000000..529d121f9adcf --- /dev/null +++ b/airflow/contrib/hooks/gcp_pubsub_hook.py @@ -0,0 +1,93 @@ +# -*- coding: utf-8 -*- +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from apiclient.discovery import build +from apiclient import errors + +from airflow.contrib.hooks.gcp_api_base_hook import GoogleCloudBaseHook + + +def _format_topic(project, topic): + return 'projects/%s/topics/%s' % (project, topic) + + +class PubSubHook(GoogleCloudBaseHook): + """Hook for accessing Google Pub/Sub. + + The GCP project against which actions are applied is determined by + the project embedded in the Connection referenced by gcp_conn_id. + """ + + def __init__(self, + gcp_conn_id='google_cloud_default', + delegate_to=None): + super(PubSubHook, self).__init__(gcp_conn_id, delegate_to=delegate_to) + + def get_conn(self): + """Returns a Pub/Sub service object. + + :rtype: apiclient.discovery.Resource + """ + http_authorized = self._authorize() + return build('pubsub', 'v1', http=http_authorized) + + def publish(self, project, topic, messages): + """Publishes messages to a Pub/Sub topic. + + :param project: the GCP project name or ID in which to publish + :type project: string + :param topic: the Pub/Sub topic to which to publish; do not + include the 'projects/{project}/topics/' prefix. + :type topic: string + :param messages: messages to publish; if the data field in a + message is set, it should already be base64 encoded. + :type messages: list of PubSub messages; see + http://cloud.google.com/pubsub/docs/reference/rest/v1/PubsubMessage + """ + body = {'messages': messages} + full_topic = _format_topic(project, topic) + request = self.get_conn().projects().topics().publish( + topic=full_topic, body=body) + try: + request.execute() + except errors.HttpError as e: + raise Exception('Error publishing to topic %s' % full_topic, e) + + def create_topic(self, project, topic, fail_if_exists=False): + """Creates a Pub/Sub topic, if it does not already exist. + + :param project: the GCP project name or ID in which to create + the topic + :type project: string + :param topic: the Pub/Sub topic name to create; do not + include the 'projects/{project}/topics/' prefix. + :type topic: string + :param fail_if_exists: if set, raise an exception if the topic + already exists + :type fail_if_exists: bool + """ + service = self.get_conn() + full_topic = _format_topic(project, topic) + try: + service.projects().topics().create( + name=full_topic, body={}).execute() + except errors.HttpError as e: + # Status code 409 indicates that the topic already exists. + if str(e.resp['status']) == '409': + if fail_if_exists: + raise Exception( + 'Error creating topic. Topic already exists: %s' + % full_topic) + else: + raise Exception('Error creating topic %s' % full_topic, e) diff --git a/airflow/contrib/hooks/gcs_hook.py b/airflow/contrib/hooks/gcs_hook.py new file mode 100644 index 0000000000000..24c247e3e55b7 --- /dev/null +++ b/airflow/contrib/hooks/gcs_hook.py @@ -0,0 +1,265 @@ +# -*- coding: utf-8 -*- +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +from apiclient.discovery import build +from apiclient.http import MediaFileUpload +from googleapiclient import errors + +from airflow.contrib.hooks.gcp_api_base_hook import GoogleCloudBaseHook + + +class GoogleCloudStorageHook(GoogleCloudBaseHook): + """ + Interact with Google Cloud Storage. This hook uses the Google Cloud Platform + connection. + """ + + def __init__(self, + google_cloud_storage_conn_id='google_cloud_storage_default', + delegate_to=None): + super(GoogleCloudStorageHook, self).__init__(google_cloud_storage_conn_id, + delegate_to) + + def get_conn(self): + """ + Returns a Google Cloud Storage service object. + """ + http_authorized = self._authorize() + return build('storage', 'v1', http=http_authorized) + + + # pylint:disable=redefined-builtin + def copy(self, source_bucket, source_object, destination_bucket=None, + destination_object=None): + """ + Copies an object from a bucket to another, with renaming if requested. + + destination_bucket or destination_object can be omitted, in which case + source bucket/object is used, but not both. + + :param bucket: The bucket of the object to copy from. + :type bucket: string + :param object: The object to copy. + :type object: string + :param destination_bucket: The destination of the object to copied to. + Can be omitted; then the same bucket is used. + :type destination_bucket: string + :param destination_object: The (renamed) path of the object if given. + Can be omitted; then the same name is used. + """ + destination_bucket = destination_bucket or source_bucket + destination_object = destination_object or source_object + if (source_bucket == destination_bucket and + source_object == destination_object): + raise ValueError( + 'Either source/destination bucket or source/destination object ' + 'must be different, not both the same: bucket=%s, object=%s' % + (source_bucket, source_object)) + if not source_bucket or not source_object: + raise ValueError('source_bucket and source_object cannot be empty.') + + service = self.get_conn() + try: + service \ + .objects() \ + .copy(sourceBucket=source_bucket, sourceObject=source_object, + destinationBucket=destination_bucket, + destinationObject=destination_object, body='') \ + .execute() + return True + except errors.HttpError as ex: + if ex.resp['status'] == '404': + return False + raise + + + # pylint:disable=redefined-builtin + def download(self, bucket, object, filename=False): + """ + Get a file from Google Cloud Storage. + + :param bucket: The bucket to fetch from. + :type bucket: string + :param object: The object to fetch. + :type object: string + :param filename: If set, a local file path where the file should be written to. + :type filename: string + """ + service = self.get_conn() + downloaded_file_bytes = service \ + .objects() \ + .get_media(bucket=bucket, object=object) \ + .execute() + + # Write the file to local file path, if requested. + if filename: + write_argument = 'wb' if isinstance(downloaded_file_bytes, bytes) else 'w' + with open(filename, write_argument) as file_fd: + file_fd.write(downloaded_file_bytes) + + return downloaded_file_bytes + + # pylint:disable=redefined-builtin + def upload(self, bucket, object, filename, mime_type='application/octet-stream'): + """ + Uploads a local file to Google Cloud Storage. + + :param bucket: The bucket to upload to. + :type bucket: string + :param object: The object name to set when uploading the local file. + :type object: string + :param filename: The local file path to the file to be uploaded. + :type filename: string + :param mime_type: The MIME type to set when uploading the file. + :type mime_type: string + """ + service = self.get_conn() + media = MediaFileUpload(filename, mime_type) + response = service \ + .objects() \ + .insert(bucket=bucket, name=object, media_body=media) \ + .execute() + + # pylint:disable=redefined-builtin + def exists(self, bucket, object): + """ + Checks for the existence of a file in Google Cloud Storage. + + :param bucket: The Google cloud storage bucket where the object is. + :type bucket: string + :param object: The name of the object to check in the Google cloud + storage bucket. + :type object: string + """ + service = self.get_conn() + try: + service \ + .objects() \ + .get(bucket=bucket, object=object) \ + .execute() + return True + except errors.HttpError as ex: + if ex.resp['status'] == '404': + return False + raise + + # pylint:disable=redefined-builtin + def is_updated_after(self, bucket, object, ts): + """ + Checks if an object is updated in Google Cloud Storage. + + :param bucket: The Google cloud storage bucket where the object is. + :type bucket: string + :param object: The name of the object to check in the Google cloud + storage bucket. + :type object: string + :param ts: The timestamp to check against. + :type ts: datetime + """ + service = self.get_conn() + try: + response = (service + .objects() + .get(bucket=bucket, object=object) + .execute()) + + if 'updated' in response: + import dateutil.parser + import dateutil.tz + + if not ts.tzinfo: + ts = ts.replace(tzinfo=dateutil.tz.tzutc()) + + updated = dateutil.parser.parse(response['updated']) + self.log.info("Verify object date: %s > %s", updated, ts) + + if updated > ts: + return True + + except errors.HttpError as ex: + if ex.resp['status'] != '404': + raise + + return False + + def delete(self, bucket, object, generation=None): + """ + Delete an object if versioning is not enabled for the bucket, or if generation + parameter is used. + + :param bucket: name of the bucket, where the object resides + :type bucket: string + :param object: name of the object to delete + :type object: string + :param generation: if present, permanently delete the object of this generation + :type generation: string + :return: True if succeeded + """ + service = self.get_conn() + + try: + service \ + .objects() \ + .delete(bucket=bucket, object=object, generation=generation) \ + .execute() + return True + except errors.HttpError as ex: + if ex.resp['status'] == '404': + return False + raise + + def list(self, bucket, versions=None, maxResults=None, prefix=None): + """ + List all objects from the bucket with the give string prefix in name + + :param bucket: bucket name + :type bucket: string + :param versions: if true, list all versions of the objects + :type versions: boolean + :param maxResults: max count of items to return in a single page of responses + :type maxResults: integer + :param prefix: prefix string which filters objects whose name begin with this prefix + :type prefix: string + :return: a stream of object names matching the filtering criteria + """ + service = self.get_conn() + + ids = list() + pageToken = None + while(True): + response = service.objects().list( + bucket=bucket, + versions=versions, + maxResults=maxResults, + pageToken=pageToken, + prefix=prefix + ).execute() + + if 'items' not in response: + self.log.info("No items found for prefix: %s", prefix) + break + + for item in response['items']: + if item and 'name' in item: + ids.append(item['name']) + + if 'nextPageToken' not in response: + # no further pages of results, so stop the loop + break + + pageToken = response['nextPageToken'] + if not pageToken: + # empty next page token + break + return ids diff --git a/airflow/contrib/hooks/jira_hook.py b/airflow/contrib/hooks/jira_hook.py new file mode 100644 index 0000000000000..21e669f0e153f --- /dev/null +++ b/airflow/contrib/hooks/jira_hook.py @@ -0,0 +1,79 @@ +# -*- coding: utf-8 -*- +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +from jira import JIRA +from jira.exceptions import JIRAError + +from airflow.exceptions import AirflowException +from airflow.hooks.base_hook import BaseHook +from airflow.utils.log.logging_mixin import LoggingMixin + + +class JiraHook(BaseHook, LoggingMixin): + """ + Jira interaction hook, a Wrapper around JIRA Python SDK. + + :param jira_conn_id: reference to a pre-defined Jira Connection + :type jira_conn_id: string + """ + def __init__(self, + jira_conn_id='jira_default'): + super(JiraHook, self).__init__(jira_conn_id) + self.jira_conn_id = jira_conn_id + self.client = None + self.get_conn() + + def get_conn(self): + if not self.client: + self.log.debug('Creating Jira client for conn_id: %s', self.jira_conn_id) + + get_server_info = True + validate = True + extra_options = {} + conn = None + + if self.jira_conn_id is not None: + conn = self.get_connection(self.jira_conn_id) + if conn.extra is not None: + extra_options = conn.extra_dejson + # only required attributes are taken for now, + # more can be added ex: async, logging, max_retries + + # verify + if 'verify' in extra_options \ + and extra_options['verify'].lower() == 'false': + extra_options['verify'] = False + + # validate + if 'validate' in extra_options \ + and extra_options['validate'].lower() == 'false': + validate = False + + if 'get_server_info' in extra_options \ + and extra_options['get_server_info'].lower() == 'false': + get_server_info = False + + try: + self.client = JIRA(conn.host, + options=extra_options, + basic_auth=(conn.login, conn.password), + get_server_info=get_server_info, + validate=validate) + except JIRAError as jira_error: + raise AirflowException('Failed to create jira client, jira error: %s' + % str(jira_error)) + except Exception as e: + raise AirflowException('Failed to create jira client, error: %s' + % str(e)) + + return self.client diff --git a/airflow/contrib/hooks/qubole_hook.py b/airflow/contrib/hooks/qubole_hook.py new file mode 100755 index 0000000000000..833c1c7ccf4b4 --- /dev/null +++ b/airflow/contrib/hooks/qubole_hook.py @@ -0,0 +1,210 @@ +# -*- coding: utf-8 -*- +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +import os +import time +import datetime +import six + +from airflow.exceptions import AirflowException +from airflow.hooks.base_hook import BaseHook +from airflow import configuration +from airflow.utils.log.logging_mixin import LoggingMixin +from airflow.utils.state import State + +from qds_sdk.qubole import Qubole +from qds_sdk.commands import Command, HiveCommand, PrestoCommand, HadoopCommand, \ + PigCommand, ShellCommand, SparkCommand, DbTapQueryCommand, DbExportCommand, \ + DbImportCommand + + +COMMAND_CLASSES = { + "hivecmd": HiveCommand, + "prestocmd": PrestoCommand, + "hadoopcmd": HadoopCommand, + "shellcmd": ShellCommand, + "pigcmd": PigCommand, + "sparkcmd": SparkCommand, + "dbtapquerycmd": DbTapQueryCommand, + "dbexportcmd": DbExportCommand, + "dbimportcmd": DbImportCommand +} + +HYPHEN_ARGS = ['cluster_label', 'app_id', 'note_id'] + +POSITIONAL_ARGS = ['sub_command', 'parameters'] + +COMMAND_ARGS = { + "hivecmd": ['query', 'script_location', 'macros', 'tags', 'sample_size', + 'cluster_label', 'name'], + 'prestocmd': ['query', 'script_location', 'macros', 'tags', 'cluster_label', 'name'], + 'hadoopcmd': ['sub_command', 'tags', 'cluster_label', 'name'], + 'shellcmd': ['script', 'script_location', 'files', 'archives', 'parameters', 'tags', + 'cluster_label', 'name'], + 'pigcmd': ['script', 'script_location', 'parameters', 'tags', 'cluster_label', + 'name'], + 'dbtapquerycmd': ['db_tap_id', 'query', 'macros', 'tags', 'name'], + 'sparkcmd': ['program', 'cmdline', 'sql', 'script_location', 'macros', 'tags', + 'cluster_label', 'language', 'app_id', 'name', 'arguments', 'note_id', + 'user_program_arguments'], + 'dbexportcmd': ['mode', 'hive_table', 'partition_spec', 'dbtap_id', 'db_table', + 'db_update_mode', 'db_update_keys', 'export_dir', + 'fields_terminated_by', 'tags', 'name'], + 'dbimportcmd': ['mode', 'hive_table', 'dbtap_id', 'db_table', 'where_clause', + 'parallelism', 'extract_query', 'boundary_query', 'split_column', + 'tags', 'name'] +} + + +class QuboleHook(BaseHook, LoggingMixin): + def __init__(self, *args, **kwargs): + conn = self.get_connection(kwargs['qubole_conn_id']) + Qubole.configure(api_token=conn.password, api_url=conn.host) + self.task_id = kwargs['task_id'] + self.dag_id = kwargs['dag'].dag_id + self.kwargs = kwargs + self.cls = COMMAND_CLASSES[self.kwargs['command_type']] + self.cmd = None + + @staticmethod + def handle_failure_retry(context): + ti = context['ti'] + cmd_id = ti.xcom_pull(key='qbol_cmd_id', task_ids=ti.task_id) + + if cmd_id is not None: + cmd = Command.find(cmd_id) + if cmd is not None: + log = LoggingMixin().log + if cmd.status == 'done': + log.info('Command ID: %s has been succeeded, hence marking this ' + 'TI as Success.', cmd_id) + ti.state = State.SUCCESS + elif cmd.status == 'running': + log.info('Cancelling the Qubole Command Id: %s', cmd_id) + cmd.cancel() + + def execute(self, context): + args = self.cls.parse(self.create_cmd_args(context)) + self.cmd = self.cls.create(**args) + context['task_instance'].xcom_push(key='qbol_cmd_id', value=self.cmd.id) + self.log.info( + "Qubole command created with Id: %s and Status: %s", + self.cmd.id, self.cmd.status + ) + + while not Command.is_done(self.cmd.status): + time.sleep(Qubole.poll_interval) + self.cmd = self.cls.find(self.cmd.id) + self.log.info("Command Id: %s and Status: %s", self.cmd.id, self.cmd.status) + + if 'fetch_logs' in self.kwargs and self.kwargs['fetch_logs'] is True: + self.log.info("Logs for Command Id: %s \n%s", self.cmd.id, self.cmd.get_log()) + + if self.cmd.status != 'done': + raise AirflowException('Command Id: {0} failed with Status: {1}'.format( + self.cmd.id, self.cmd.status)) + + def kill(self, ti): + """ + Kill (cancel) a Qubole commmand + :param ti: Task Instance of the dag, used to determine the Quboles command id + :return: response from Qubole + """ + if self.cmd is None: + cmd_id = ti.xcom_pull(key="qbol_cmd_id", task_ids=ti.task_id) + self.cmd = self.cls.find(cmd_id) + if self.cls and self.cmd: + self.log.info('Sending KILL signal to Qubole Command Id: %s', self.cmd.id) + self.cmd.cancel() + + def get_results(self, ti=None, fp=None, inline=True, delim=None, fetch=True): + """ + Get results (or just s3 locations) of a command from Qubole and save into a file + :param ti: Task Instance of the dag, used to determine the Quboles command id + :param fp: Optional file pointer, will create one and return if None passed + :param inline: True to download actual results, False to get s3 locations only + :param delim: Replaces the CTL-A chars with the given delim, defaults to ',' + :param fetch: when inline is True, get results directly from s3 (if large) + :return: file location containing actual results or s3 locations of results + """ + if fp is None: + iso = datetime.datetime.utcnow().isoformat() + logpath = os.path.expanduser(configuration.get('core', 'BASE_LOG_FOLDER')) + resultpath = logpath + '/' + self.dag_id + '/' + self.task_id + '/results' + configuration.mkdir_p(resultpath) + fp = open(resultpath + '/' + iso, 'wb') + + if self.cmd is None: + cmd_id = ti.xcom_pull(key="qbol_cmd_id", task_ids=self.task_id) + self.cmd = self.cls.find(cmd_id) + + self.cmd.get_results(fp, inline, delim, fetch) + fp.flush() + fp.close() + return fp.name + + def get_log(self, ti): + """ + Get Logs of a command from Qubole + :param ti: Task Instance of the dag, used to determine the Quboles command id + :return: command log as text + """ + if self.cmd is None: + cmd_id = ti.xcom_pull(key="qbol_cmd_id", task_ids=self.task_id) + Command.get_log_id(self.cls, cmd_id) + + def get_jobs_id(self, ti): + """ + Get jobs associated with a Qubole commands + :param ti: Task Instance of the dag, used to determine the Quboles command id + :return: Job informations assoiciated with command + """ + if self.cmd is None: + cmd_id = ti.xcom_pull(key="qbol_cmd_id", task_ids=self.task_id) + Command.get_jobs_id(self.cls, cmd_id) + + def create_cmd_args(self, context): + args = [] + cmd_type = self.kwargs['command_type'] + inplace_args = None + tags = set([self.dag_id, self.task_id, context['run_id']]) + + for k,v in self.kwargs.items(): + if k in COMMAND_ARGS[cmd_type]: + if k in HYPHEN_ARGS: + args.append("--{0}={1}".format(k.replace('_', '-'),v)) + elif k in POSITIONAL_ARGS: + inplace_args = v + elif k == 'tags': + if isinstance(v, six.string_types): + tags.add(v) + elif isinstance(v, (list, tuple)): + for val in v: + tags.add(val) + else: + args.append("--{0}={1}".format(k,v)) + + if k == 'notify' and v is True: + args.append("--notify") + + args.append("--tags={0}".format(','.join(filter(None,tags)))) + + if inplace_args is not None: + if cmd_type == 'hadoopcmd': + args += inplace_args.split(' ', 1) + else: + args += inplace_args.split(' ') + + return args diff --git a/airflow/contrib/hooks/redis_hook.py b/airflow/contrib/hooks/redis_hook.py new file mode 100644 index 0000000000000..278e19659a65f --- /dev/null +++ b/airflow/contrib/hooks/redis_hook.py @@ -0,0 +1,88 @@ +# -*- coding: utf-8 -*- +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +""" +RedisHook module +""" +from redis import StrictRedis + +from airflow.exceptions import AirflowException +from airflow.hooks.base_hook import BaseHook +from airflow.utils.log.logging_mixin import LoggingMixin + + +class RedisHook(BaseHook, LoggingMixin): + """ + Hook to interact with Redis database + """ + def __init__(self, redis_conn_id='redis_default'): + """ + Prepares hook to connect to a Redis database. + + :param conn_id: the name of the connection that has the parameters + we need to connect to Redis. + """ + self.redis_conn_id = redis_conn_id + self.client = None + conn = self.get_connection(self.redis_conn_id) + self.host = conn.host + self.port = int(conn.port) + self.password = conn.password + self.db = int(conn.extra_dejson.get('db', 0)) + + self.log.debug( + '''Connection "{conn}": + \thost: {host} + \tport: {port} + \textra: {extra} + '''.format( + conn=self.redis_conn_id, + host=self.host, + port=self.port, + extra=conn.extra_dejson + ) + ) + + def get_conn(self): + """ + Returns a Redis connection. + """ + if not self.client: + self.log.debug( + 'generating Redis client for conn_id "%s" on %s:%s:%s', + self.redis_conn_id, self.host, self.port, self.db + ) + try: + self.client = StrictRedis( + host=self.host, + port=self.port, + password=self.password, + db=self.db) + except Exception as general_error: + raise AirflowException( + 'Failed to create Redis client, error: {error}'.format( + error=str(general_error) + ) + ) + + return self.client + + def key_exists(self, key): + """ + Checks if a key exists in Redis database + + :param key: The key to check the existence. + :type key: string + """ + return self.get_conn().exists(key) diff --git a/airflow/contrib/hooks/salesforce_hook.py b/airflow/contrib/hooks/salesforce_hook.py new file mode 100644 index 0000000000000..b82e4cad47ae9 --- /dev/null +++ b/airflow/contrib/hooks/salesforce_hook.py @@ -0,0 +1,318 @@ +# -*- coding: utf-8 -*- +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +""" +This module contains a Salesforce Hook +which allows you to connect to your Salesforce instance, +retrieve data from it, and write that data to a file +for other uses. + +NOTE: this hook also relies on the simple_salesforce package: + https://github.com/simple-salesforce/simple-salesforce +""" +from simple_salesforce import Salesforce +from airflow.hooks.base_hook import BaseHook + +import json + +import pandas as pd +import time + +from airflow.utils.log.logging_mixin import LoggingMixin + + +class SalesforceHook(BaseHook, LoggingMixin): + def __init__( + self, + conn_id, + *args, + **kwargs + ): + """ + Create new connection to Salesforce + and allows you to pull data out of SFDC and save it to a file. + + You can then use that file with other + Airflow operators to move the data into another data source + + :param conn_id: the name of the connection that has the parameters + we need to connect to Salesforce. + The conenction shoud be type `http` and include a + user's security token in the `Extras` field. + .. note:: + For the HTTP connection type, you can include a + JSON structure in the `Extras` field. + We need a user's security token to connect to Salesforce. + So we define it in the `Extras` field as: + `{"security_token":"YOUR_SECRUITY_TOKEN"}` + """ + self.conn_id = conn_id + self._args = args + self._kwargs = kwargs + + # get the connection parameters + self.connection = self.get_connection(conn_id) + self.extras = self.connection.extra_dejson + + def sign_in(self): + """ + Sign into Salesforce. + + If we have already signed it, this will just return the original object + """ + if hasattr(self, 'sf'): + return self.sf + + # connect to Salesforce + sf = Salesforce( + username=self.connection.login, + password=self.connection.password, + security_token=self.extras['security_token'], + instance_url=self.connection.host + ) + self.sf = sf + return sf + + def make_query(self, query): + """ + Make a query to Salesforce. Returns result in dictionary + + :param query: The query to make to Salesforce + """ + self.sign_in() + + self.log.info("Querying for all objects") + query = self.sf.query_all(query) + + self.log.info( + "Received results: Total size: %s; Done: %s", + query['totalSize'], query['done'] + ) + + query = json.loads(json.dumps(query)) + return query + + def describe_object(self, obj): + """ + Get the description of an object from Salesforce. + + This description is the object's schema + and some extra metadata that Salesforce stores for each object + + :param obj: Name of the Salesforce object + that we are getting a description of. + """ + self.sign_in() + + return json.loads(json.dumps(self.sf.__getattr__(obj).describe())) + + def get_available_fields(self, obj): + """ + Get a list of all available fields for an object. + + This only returns the names of the fields. + """ + self.sign_in() + + desc = self.describe_object(obj) + + return [f['name'] for f in desc['fields']] + + def _build_field_list(self, fields): + # join all of the fields in a comma separated list + return ",".join(fields) + + def get_object_from_salesforce(self, obj, fields): + """ + Get all instances of the `object` from Salesforce. + For each model, only get the fields specified in fields. + + All we really do underneath the hood is run: + SELECT FROM ; + """ + field_string = self._build_field_list(fields) + + query = "SELECT {0} FROM {1}".format(field_string, obj) + self.log.info( + "Making query to Salesforce: %s", + query if len(query) < 30 else " ... ".join([query[:15], query[-15:]]) + ) + return self.make_query(query) + + @classmethod + def _to_timestamp(cls, col): + """ + Convert a column of a dataframe to UNIX timestamps if applicable + + :param col: A Series object representing a column of a dataframe. + """ + # try and convert the column to datetimes + # the column MUST have a four digit year somewhere in the string + # there should be a better way to do this, + # but just letting pandas try and convert every column without a format + # caused it to convert floats as well + # For example, a column of integers + # between 0 and 10 are turned into timestamps + # if the column cannot be converted, + # just return the original column untouched + try: + col = pd.to_datetime(col) + except ValueError: + log = LoggingMixin().log + log.warning( + "Could not convert field to timestamps: %s", col.name + ) + return col + + # now convert the newly created datetimes into timestamps + # we have to be careful here + # because NaT cannot be converted to a timestamp + # so we have to return NaN + converted = [] + for i in col: + try: + converted.append(i.timestamp()) + except ValueError: + converted.append(pd.np.NaN) + except AttributeError: + converted.append(pd.np.NaN) + + # return a new series that maintains the same index as the original + return pd.Series(converted, index=col.index) + + def write_object_to_file( + self, + query_results, + filename, + fmt="csv", + coerce_to_timestamp=False, + record_time_added=False + ): + """ + Write query results to file. + + Acceptable formats are: + - csv: + comma-separated-values file. This is the default format. + - json: + JSON array. Each element in the array is a different row. + - ndjson: + JSON array but each element is new-line deliminated + instead of comman deliminated like in `json` + + This requires a significant amount of cleanup. + Pandas doesn't handle output to CSV and json in a uniform way. + This is especially painful for datetime types. + Pandas wants to write them as strings in CSV, + but as milisecond Unix timestamps. + + By default, this function will try and leave all values as + they are represented in Salesforce. + You use the `coerce_to_timestamp` flag to force all datetimes + to become Unix timestamps (UTC). + This is can be greatly beneficial as it will make all of your + datetime fields look the same, + and makes it easier to work with in other database environments + + :param query_results: the results from a SQL query + :param filename: the name of the file where the data + should be dumped to + :param fmt: the format you want the output in. + *Default:* csv. + :param coerce_to_timestamp: True if you want all datetime fields to be + converted into Unix timestamps. + False if you want them to be left in the + same format as they were in Salesforce. + Leaving the value as False will result + in datetimes being strings. + *Defaults to False* + :param record_time_added: *(optional)* True if you want to add a + Unix timestamp field to the resulting data + that marks when the data + was fetched from Salesforce. + *Default: False*. + """ + fmt = fmt.lower() + if fmt not in ['csv', 'json', 'ndjson']: + raise ValueError("Format value is not recognized: {0}".format(fmt)) + + # this line right here will convert all integers to floats if there are + # any None/np.nan values in the column + # that's because None/np.nan cannot exist in an integer column + # we should write all of our timestamps as FLOATS in our final schema + df = pd.DataFrame.from_records(query_results, exclude=["attributes"]) + + df.columns = [c.lower() for c in df.columns] + + # convert columns with datetime strings to datetimes + # not all strings will be datetimes, so we ignore any errors that occur + # we get the object's definition at this point and only consider + # features that are DATE or DATETIME + if coerce_to_timestamp and df.shape[0] > 0: + # get the object name out of the query results + # it's stored in the "attributes" dictionary + # for each returned record + object_name = query_results[0]['attributes']['type'] + + self.log.info("Coercing timestamps for: %s", object_name) + + schema = self.describe_object(object_name) + + # possible columns that can be convereted to timestamps + # are the ones that are either date or datetime types + # strings are too general and we risk unintentional conversion + possible_timestamp_cols = [ + i['name'].lower() + for i in schema['fields'] + if i['type'] in ["date", "datetime"] and + i['name'].lower() in df.columns + ] + df[possible_timestamp_cols] = df[possible_timestamp_cols].apply( + lambda x: self._to_timestamp(x) + ) + + if record_time_added: + fetched_time = time.time() + df["time_fetched_from_salesforce"] = fetched_time + + # write the CSV or JSON file depending on the option + # NOTE: + # datetimes here are an issue. + # There is no good way to manage the difference + # for to_json, the options are an epoch or a ISO string + # but for to_csv, it will be a string output by datetime + # For JSON we decided to output the epoch timestamp in seconds + # (as is fairly standard for JavaScript) + # And for csv, we do a string + if fmt == "csv": + # there are also a ton of newline objects + # that mess up our ability to write to csv + # we remove these newlines so that the output is a valid CSV format + self.log.info("Cleaning data and writing to CSV") + possible_strings = df.columns[df.dtypes == "object"] + df[possible_strings] = df[possible_strings].apply( + lambda x: x.str.replace("\r\n", "") + ) + df[possible_strings] = df[possible_strings].apply( + lambda x: x.str.replace("\n", "") + ) + + # write the dataframe + df.to_csv(filename, index=False) + elif fmt == "json": + df.to_json(filename, "records", date_unit="s") + elif fmt == "ndjson": + df.to_json(filename, "records", lines=True, date_unit="s") + + return df diff --git a/airflow/contrib/hooks/spark_sql_hook.py b/airflow/contrib/hooks/spark_sql_hook.py new file mode 100644 index 0000000000000..ea3ee8bf33017 --- /dev/null +++ b/airflow/contrib/hooks/spark_sql_hook.py @@ -0,0 +1,156 @@ +# -*- coding: utf-8 -*- +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +import subprocess + +from airflow.hooks.base_hook import BaseHook +from airflow.exceptions import AirflowException +from airflow.utils.log.logging_mixin import LoggingMixin + + +class SparkSqlHook(BaseHook): + """ + This hook is a wrapper around the spark-sql binary. It requires that the + "spark-sql" binary is in the PATH. + :param sql: The SQL query to execute + :type sql: str + :param conf: arbitrary Spark configuration property + :type conf: str (format: PROP=VALUE) + :param conn_id: connection_id string + :type conn_id: str + :param total_executor_cores: (Standalone & Mesos only) Total cores for all executors (Default: all the available cores on the worker) + :type total_executor_cores: int + :param executor_cores: (Standalone & YARN only) Number of cores per executor (Default: 2) + :type executor_cores: int + :param executor_memory: Memory per executor (e.g. 1000M, 2G) (Default: 1G) + :type executor_memory: str + :param keytab: Full path to the file that contains the keytab + :type keytab: str + :param master: spark://host:port, mesos://host:port, yarn, or local + :type master: str + :param name: Name of the job. + :type name: str + :param num_executors: Number of executors to launch + :type num_executors: int + :param verbose: Whether to pass the verbose flag to spark-sql + :type verbose: bool + :param yarn_queue: The YARN queue to submit to (Default: "default") + :type yarn_queue: str + """ + def __init__(self, + sql, + conf=None, + conn_id='spark_sql_default', + total_executor_cores=None, + executor_cores=None, + executor_memory=None, + keytab=None, + principal=None, + master='yarn', + name='default-name', + num_executors=None, + verbose=True, + yarn_queue='default' + ): + self._sql = sql + self._conf = conf + self._conn = self.get_connection(conn_id) + self._total_executor_cores = total_executor_cores + self._executor_cores = executor_cores + self._executor_memory = executor_memory + self._keytab = keytab + self._principal = principal + self._master = master + self._name = name + self._num_executors = num_executors + self._verbose = verbose + self._yarn_queue = yarn_queue + self._sp = None + + def get_conn(self): + pass + + def _prepare_command(self, cmd): + """ + Construct the spark-sql command to execute. Verbose output is enabled + as default. + :param cmd: command to append to the spark-sql command + :type cmd: str + :return: full command to be executed + """ + connection_cmd = ["spark-sql"] + if self._conf: + for conf_el in self._conf.split(","): + connection_cmd += ["--conf", conf_el] + if self._total_executor_cores: + connection_cmd += ["--total-executor-cores", str(self._total_executor_cores)] + if self._executor_cores: + connection_cmd += ["--executor-cores", str(self._executor_cores)] + if self._executor_memory: + connection_cmd += ["--executor-memory", self._executor_memory] + if self._keytab: + connection_cmd += ["--keytab", self._keytab] + if self._principal: + connection_cmd += ["--principal", self._principal] + if self._num_executors: + connection_cmd += ["--num-executors", str(self._num_executors)] + if self._sql: + sql = self._sql.strip() + if sql.endswith(".sql") or sql.endswith(".hql"): + connection_cmd += ["-f", sql] + else: + connection_cmd += ["-e", sql] + if self._master: + connection_cmd += ["--master", self._master] + if self._name: + connection_cmd += ["--name", self._name] + if self._verbose: + connection_cmd += ["--verbose"] + if self._yarn_queue: + connection_cmd += ["--queue", self._yarn_queue] + + connection_cmd += cmd + self.log.debug("Spark-Sql cmd: %s", connection_cmd) + + return connection_cmd + + def run_query(self, cmd="", **kwargs): + """ + Remote Popen (actually execute the Spark-sql query) + + :param cmd: command to remotely execute + :param kwargs: extra arguments to Popen (see subprocess.Popen) + """ + spark_sql_cmd = self._prepare_command(cmd) + self._sp = subprocess.Popen(spark_sql_cmd, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + **kwargs) + + for line in iter(self._sp.stdout.readline, ''): + self.log.info(line) + + returncode = self._sp.wait() + + if returncode: + raise AirflowException( + "Cannot execute {} on {}. Process exit code: {}.".format( + cmd, self._conn.host, returncode + ) + ) + + def kill(self): + if self._sp and self._sp.poll() is None: + self.log.info("Killing the Spark-Sql job") + self._sp.kill() diff --git a/airflow/contrib/hooks/spark_submit_hook.py b/airflow/contrib/hooks/spark_submit_hook.py new file mode 100644 index 0000000000000..7f8e35ecaf6d6 --- /dev/null +++ b/airflow/contrib/hooks/spark_submit_hook.py @@ -0,0 +1,257 @@ +# -*- coding: utf-8 -*- +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +import os +import subprocess +import re + +from airflow.hooks.base_hook import BaseHook +from airflow.exceptions import AirflowException +from airflow.utils.log.logging_mixin import LoggingMixin + + +class SparkSubmitHook(BaseHook, LoggingMixin): + """ + This hook is a wrapper around the spark-submit binary to kick off a spark-submit job. + It requires that the "spark-submit" binary is in the PATH or the spark_home to be + supplied. + :param conf: Arbitrary Spark configuration properties + :type conf: dict + :param conn_id: The connection id as configured in Airflow administration. When an + invalid connection_id is supplied, it will default to yarn. + :type conn_id: str + :param files: Upload additional files to the container running the job, separated by a + comma. For example hive-site.xml. + :type files: str + :param py_files: Additional python files used by the job, can be .zip, .egg or .py. + :type py_files: str + :param jars: Submit additional jars to upload and place them in executor classpath. + :type jars: str + :param java_class: the main class of the Java application + :type java_class: str + :param total_executor_cores: (Standalone & Mesos only) Total cores for all executors (Default: all the available cores on the worker) + :type total_executor_cores: int + :param executor_cores: (Standalone & YARN only) Number of cores per executor (Default: 2) + :type executor_cores: int + :param executor_memory: Memory per executor (e.g. 1000M, 2G) (Default: 1G) + :type executor_memory: str + :param driver_memory: Memory allocated to the driver (e.g. 1000M, 2G) (Default: 1G) + :type driver_memory: str + :param keytab: Full path to the file that contains the keytab + :type keytab: str + :param principal: The name of the kerberos principal used for keytab + :type principal: str + :param name: Name of the job (default airflow-spark) + :type name: str + :param num_executors: Number of executors to launch + :type num_executors: int + :param application_args: Arguments for the application being submitted + :type application_args: list + :param verbose: Whether to pass the verbose flag to spark-submit process for debugging + :type verbose: bool + """ + def __init__(self, + conf=None, + conn_id='spark_default', + files=None, + py_files=None, + jars=None, + java_class=None, + packages=None, + total_executor_cores=None, + executor_cores=None, + executor_memory=None, + driver_memory=None, + keytab=None, + principal=None, + name='default-name', + num_executors=None, + application_args=None, + verbose=False): + self._conf = conf + self._conn_id = conn_id + self._files = files + self._py_files = py_files + self._jars = jars + self._java_class = java_class + self._packages = packages + self._total_executor_cores = total_executor_cores + self._executor_cores = executor_cores + self._executor_memory = executor_memory + self._driver_memory = driver_memory + self._keytab = keytab + self._principal = principal + self._name = name + self._num_executors = num_executors + self._application_args = application_args + self._verbose = verbose + self._sp = None + self._yarn_application_id = None + + self._connection = self._resolve_connection() + self._is_yarn = 'yarn' in self._connection['master'] + + def _resolve_connection(self): + # Build from connection master or default to yarn if not available + conn_data = {'master': 'yarn', + 'queue': None, + 'deploy_mode': None, + 'spark_home': None, + 'spark_binary': 'spark-submit'} + + try: + # Master can be local, yarn, spark://HOST:PORT or mesos://HOST:PORT + conn = self.get_connection(self._conn_id) + if conn.port: + conn_data['master'] = "{}:{}".format(conn.host, conn.port) + else: + conn_data['master'] = conn.host + + # Determine optional yarn queue from the extra field + extra = conn.extra_dejson + conn_data['queue'] = extra.get('queue', None) + conn_data['deploy_mode'] = extra.get('deploy-mode', None) + conn_data['spark_home'] = extra.get('spark-home', None) + conn_data['spark_binary'] = extra.get('spark-binary', 'spark-submit') + except AirflowException: + self.log.debug( + "Could not load connection string %s, defaulting to %s", + self._conn_id, conn_data['master'] + ) + + return conn_data + + def get_conn(self): + pass + + def _build_command(self, application): + """ + Construct the spark-submit command to execute. + :param application: command to append to the spark-submit command + :type application: str + :return: full command to be executed + """ + # If the spark_home is passed then build the spark-submit executable path using + # the spark_home; otherwise assume that spark-submit is present in the path to + # the executing user + if self._connection['spark_home']: + connection_cmd = [os.path.join(self._connection['spark_home'], 'bin', self._connection['spark_binary'])] + else: + connection_cmd = [self._connection['spark_binary']] + + # The url ot the spark master + connection_cmd += ["--master", self._connection['master']] + + if self._conf: + for key in self._conf: + connection_cmd += ["--conf", "{}={}".format(key, str(self._conf[key]))] + if self._files: + connection_cmd += ["--files", self._files] + if self._py_files: + connection_cmd += ["--py-files", self._py_files] + if self._jars: + connection_cmd += ["--jars", self._jars] + if self._packages: + connection_cmd += ["--packages", self._packages] + if self._num_executors: + connection_cmd += ["--num-executors", str(self._num_executors)] + if self._total_executor_cores: + connection_cmd += ["--total-executor-cores", str(self._total_executor_cores)] + if self._executor_cores: + connection_cmd += ["--executor-cores", str(self._executor_cores)] + if self._executor_memory: + connection_cmd += ["--executor-memory", self._executor_memory] + if self._driver_memory: + connection_cmd += ["--driver-memory", self._driver_memory] + if self._keytab: + connection_cmd += ["--keytab", self._keytab] + if self._principal: + connection_cmd += ["--principal", self._principal] + if self._name: + connection_cmd += ["--name", self._name] + if self._java_class: + connection_cmd += ["--class", self._java_class] + if self._verbose: + connection_cmd += ["--verbose"] + if self._connection['queue']: + connection_cmd += ["--queue", self._connection['queue']] + if self._connection['deploy_mode']: + connection_cmd += ["--deploy-mode", self._connection['deploy_mode']] + + # The actual script to execute + connection_cmd += [application] + + # Append any application arguments + if self._application_args: + connection_cmd += self._application_args + + self.log.debug("Spark-Submit cmd: %s", connection_cmd) + + return connection_cmd + + def submit(self, application="", **kwargs): + """ + Remote Popen to execute the spark-submit job + + :param application: Submitted application, jar or py file + :type application: str + :param kwargs: extra arguments to Popen (see subprocess.Popen) + """ + spark_submit_cmd = self._build_command(application) + self._sp = subprocess.Popen(spark_submit_cmd, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + bufsize=-1, + universal_newlines=True, + **kwargs) + + self._process_log(iter(self._sp.stdout.readline, '')) + returncode = self._sp.wait() + + if returncode: + raise AirflowException( + "Cannot execute: {}. Error code is: {}.".format( + spark_submit_cmd, returncode + ) + ) + + def _process_log(self, itr): + """ + Processes the log files and extracts useful information out of it + + :param itr: An iterator which iterates over the input of the subprocess + """ + # Consume the iterator + for line in itr: + line = line.strip() + # If we run yarn cluster mode, we want to extract the application id from + # the logs so we can kill the application when we stop it unexpectedly + if self._is_yarn and self._connection['deploy_mode'] == 'cluster': + match = re.search('(application[0-9_]+)', line) + if match: + self._yarn_application_id = match.groups()[0] + + # Pass to logging + self.log.info(line) + + def on_kill(self): + if self._sp and self._sp.poll() is None: + self.log.info('Sending kill signal to %s', self._connection['spark_binary']) + self._sp.kill() + + if self._yarn_application_id: + self.log.info('Killing application on YARN') + kill_cmd = "yarn application -kill {0}".format(self._yarn_application_id).split() + yarn_kill = subprocess.Popen(kill_cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE) + self.log.info("YARN killed with return code: %s", yarn_kill.wait()) diff --git a/airflow/contrib/hooks/sqoop_hook.py b/airflow/contrib/hooks/sqoop_hook.py new file mode 100644 index 0000000000000..5b00b156def05 --- /dev/null +++ b/airflow/contrib/hooks/sqoop_hook.py @@ -0,0 +1,320 @@ +# -*- coding: utf-8 -*- +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +""" +This module contains a sqoop 1.x hook +""" +import subprocess + +from airflow.exceptions import AirflowException +from airflow.hooks.base_hook import BaseHook +from airflow.utils.log.logging_mixin import LoggingMixin + + +class SqoopHook(BaseHook, LoggingMixin): + """ + This hook is a wrapper around the sqoop 1 binary. To be able to use the hook + it is required that "sqoop" is in the PATH. + + Additional arguments that can be passed via the 'extra' JSON field of the + sqoop connection: + * job_tracker: Job tracker local|jobtracker:port. + * namenode: Namenode. + * lib_jars: Comma separated jar files to include in the classpath. + * files: Comma separated files to be copied to the map reduce cluster. + * archives: Comma separated archives to be unarchived on the compute + machines. + * password_file: Path to file containing the password. + + :param conn_id: Reference to the sqoop connection. + :type conn_id: str + :param verbose: Set sqoop to verbose. + :type verbose: bool + :param num_mappers: Number of map tasks to import in parallel. + :type num_mappers: str + :param properties: Properties to set via the -D argument + :type properties: dict + """ + + def __init__(self, conn_id='sqoop_default', verbose=False, + num_mappers=None, hcatalog_database=None, + hcatalog_table=None, properties=None): + # No mutable types in the default parameters + if properties is None: + properties = {} + self.conn = self.get_connection(conn_id) + connection_parameters = self.conn.extra_dejson + self.job_tracker = connection_parameters.get('job_tracker', None) + self.namenode = connection_parameters.get('namenode', None) + self.libjars = connection_parameters.get('libjars', None) + self.files = connection_parameters.get('files', None) + self.archives = connection_parameters.get('archives', None) + self.password_file = connection_parameters.get('password_file', None) + self.hcatalog_database = hcatalog_database + self.hcatalog_table = hcatalog_table + self.verbose = verbose + self.num_mappers = num_mappers + self.properties = properties + + def get_conn(self): + pass + + def cmd_mask_password(self, cmd): + try: + password_index = cmd.index('--password') + cmd[password_index + 1] = 'MASKED' + except ValueError: + self.log.debug("No password in sqoop cmd") + return cmd + + def Popen(self, cmd, **kwargs): + """ + Remote Popen + + :param cmd: command to remotely execute + :param kwargs: extra arguments to Popen (see subprocess.Popen) + :return: handle to subprocess + """ + self.log.info("Executing command: %s", ' '.join(cmd)) + sp = subprocess.Popen(cmd, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + **kwargs) + + for line in iter(sp.stdout): + self.log.info(line.strip()) + + sp.wait() + + self.log.info("Command exited with return code %s", sp.returncode) + + if sp.returncode: + raise AirflowException("Sqoop command failed: %s", ' '.join(cmd)) + + def _prepare_command(self, export=False): + if export: + connection_cmd = ["sqoop", "export"] + else: + connection_cmd = ["sqoop", "import"] + + if self.verbose: + connection_cmd += ["--verbose"] + if self.job_tracker: + connection_cmd += ["-jt", self.job_tracker] + if self.conn.login: + connection_cmd += ["--username", self.conn.login] + if self.conn.password: + connection_cmd += ["--password", self.conn.password] + if self.password_file: + connection_cmd += ["--password-file", self.password_file] + if self.libjars: + connection_cmd += ["-libjars", self.libjars] + if self.files: + connection_cmd += ["-files", self.files] + if self.namenode: + connection_cmd += ["-fs", self.namenode] + if self.archives: + connection_cmd += ["-archives", self.archives] + if self.num_mappers: + connection_cmd += ["--num-mappers", str(self.num_mappers)] + if self.hcatalog_database: + connection_cmd += ["--hcatalog-database", self.hcatalog_database] + if self.hcatalog_table: + connection_cmd += ["--hcatalog-table", self.hcatalog_table] + + for key, value in self.properties.items(): + connection_cmd += ["-D", "{}={}".format(key, value)] + + connection_cmd += ["--connect", "{}:{}/{}".format( + self.conn.host, + self.conn.port, + self.conn.schema + )] + + return connection_cmd + + def _get_export_format_argument(self, file_type='text'): + if file_type == "avro": + return ["--as-avrodatafile"] + elif file_type == "sequence": + return ["--as-sequencefile"] + elif file_type == "parquet": + return ["--as-parquetfile"] + elif file_type == "text": + return ["--as-textfile"] + else: + raise AirflowException("Argument file_type should be 'avro', " + "'sequence', 'parquet' or 'text'.") + + def _import_cmd(self, target_dir, append, file_type, split_by, direct, + driver): + + cmd = self._prepare_command(export=False) + + cmd += ["--target-dir", target_dir] + + if append: + cmd += ["--append"] + + cmd += self._get_export_format_argument(file_type) + + if split_by: + cmd += ["--split-by", split_by] + + if direct: + cmd += ["--direct"] + + if driver: + cmd += ["--driver", driver] + + return cmd + + def import_table(self, table, target_dir, append=False, file_type="text", + columns=None, split_by=None, where=None, direct=False, + driver=None): + """ + Imports table from remote location to target dir. Arguments are + copies of direct sqoop command line arguments + :param table: Table to read + :param target_dir: HDFS destination dir + :param append: Append data to an existing dataset in HDFS + :param file_type: "avro", "sequence", "text" or "parquet". + Imports data to into the specified format. Defaults to text. + :param columns: Columns to import from table + :param split_by: Column of the table used to split work units + :param where: WHERE clause to use during import + :param direct: Use direct connector if exists for the database + :param driver: Manually specify JDBC driver class to use + """ + cmd = self._import_cmd(target_dir, append, file_type, split_by, direct, + driver) + + cmd += ["--table", table] + + if columns: + cmd += ["--columns", columns] + if where: + cmd += ["--where", where] + + self.Popen(cmd) + + def import_query(self, query, target_dir, + append=False, file_type="text", + split_by=None, direct=None, driver=None): + """ + Imports a specific query from the rdbms to hdfs + :param query: Free format query to run + :param target_dir: HDFS destination dir + :param append: Append data to an existing dataset in HDFS + :param file_type: "avro", "sequence", "text" or "parquet" + Imports data to hdfs into the specified format. Defaults to text. + :param split_by: Column of the table used to split work units + :param direct: Use direct import fast path + :param driver: Manually specify JDBC driver class to use + """ + cmd = self._import_cmd(target_dir, append, file_type, split_by, direct, + driver) + cmd += ["--query", query] + + self.Popen(cmd) + + def _export_cmd(self, table, export_dir, input_null_string, + input_null_non_string, staging_table, clear_staging_table, + enclosed_by, escaped_by, input_fields_terminated_by, + input_lines_terminated_by, input_optionally_enclosed_by, + batch, relaxed_isolation): + + cmd = self._prepare_command(export=True) + + if input_null_string: + cmd += ["--input-null-string", input_null_string] + + if input_null_non_string: + cmd += ["--input-null-non-string", input_null_non_string] + + if staging_table: + cmd += ["--staging-table", staging_table] + + if clear_staging_table: + cmd += ["--clear-staging-table"] + + if enclosed_by: + cmd += ["--enclosed-by", enclosed_by] + + if escaped_by: + cmd += ["--escaped-by", escaped_by] + + if input_fields_terminated_by: + cmd += ["--input-fields-terminated-by", input_fields_terminated_by] + + if input_lines_terminated_by: + cmd += ["--input-lines-terminated-by", input_lines_terminated_by] + + if input_optionally_enclosed_by: + cmd += ["--input-optionally-enclosed-by", + input_optionally_enclosed_by] + + if batch: + cmd += ["--batch"] + + if relaxed_isolation: + cmd += ["--relaxed-isolation"] + + if export_dir: + cmd += ["--export-dir", export_dir] + + # The required option + cmd += ["--table", table] + + return cmd + + def export_table(self, table, export_dir, input_null_string, + input_null_non_string, staging_table, + clear_staging_table, enclosed_by, + escaped_by, input_fields_terminated_by, + input_lines_terminated_by, + input_optionally_enclosed_by, batch, + relaxed_isolation): + """ + Exports Hive table to remote location. Arguments are copies of direct + sqoop command line Arguments + :param table: Table remote destination + :param export_dir: Hive table to export + :param input_null_string: The string to be interpreted as null for + string columns + :param input_null_non_string: The string to be interpreted as null + for non-string columns + :param staging_table: The table in which data will be staged before + being inserted into the destination table + :param clear_staging_table: Indicate that any data present in the + staging table can be deleted + :param enclosed_by: Sets a required field enclosing character + :param escaped_by: Sets the escape character + :param input_fields_terminated_by: Sets the field separator character + :param input_lines_terminated_by: Sets the end-of-line character + :param input_optionally_enclosed_by: Sets a field enclosing character + :param batch: Use batch mode for underlying statement execution + :param relaxed_isolation: Transaction isolation to read uncommitted + for the mappers + """ + cmd = self._export_cmd(table, export_dir, input_null_string, + input_null_non_string, staging_table, + clear_staging_table, enclosed_by, escaped_by, + input_fields_terminated_by, + input_lines_terminated_by, + input_optionally_enclosed_by, batch, + relaxed_isolation) + + self.Popen(cmd) diff --git a/airflow/contrib/hooks/ssh_hook.py b/airflow/contrib/hooks/ssh_hook.py new file mode 100755 index 0000000000000..b061fd73705d3 --- /dev/null +++ b/airflow/contrib/hooks/ssh_hook.py @@ -0,0 +1,212 @@ +# -*- coding: utf-8 -*- +# +# Copyright 2012-2015 Spotify AB +# Ported to Airflow by Bolke de Bruin +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import getpass +import os + +import paramiko + +from contextlib import contextmanager +from airflow.exceptions import AirflowException +from airflow.hooks.base_hook import BaseHook +from airflow.utils.log.logging_mixin import LoggingMixin + + +class SSHHook(BaseHook, LoggingMixin): + """ + Hook for ssh remote execution using Paramiko. + ref: https://github.com/paramiko/paramiko + This hook also lets you create ssh tunnel and serve as basis for SFTP file transfer + + :param ssh_conn_id: connection id from airflow Connections from where all the required + parameters can be fetched like username, password or key_file. + Thought the priority is given to the param passed during init + :type ssh_conn_id: str + :param remote_host: remote host to connect + :type remote_host: str + :param username: username to connect to the remote_host + :type username: str + :param password: password of the username to connect to the remote_host + :type password: str + :param key_file: key file to use to connect to the remote_host. + :type key_file: str + :param timeout: timeout for the attempt to connect to the remote_host. + :type timeout: int + """ + + def __init__(self, + ssh_conn_id=None, + remote_host=None, + username=None, + password=None, + key_file=None, + timeout=10 + ): + super(SSHHook, self).__init__(ssh_conn_id) + self.ssh_conn_id = ssh_conn_id + self.remote_host = remote_host + self.username = username + self.password = password + self.key_file = key_file + self.timeout = timeout + # Default values, overridable from Connection + self.compress = True + self.no_host_key_check = True + self.client = None + + def get_conn(self): + if not self.client: + self.log.debug('Creating SSH client for conn_id: %s', self.ssh_conn_id) + if self.ssh_conn_id is not None: + conn = self.get_connection(self.ssh_conn_id) + if self.username is None: + self.username = conn.login + if self.password is None: + self.password = conn.password + if self.remote_host is None: + self.remote_host = conn.host + if conn.extra is not None: + extra_options = conn.extra_dejson + self.key_file = extra_options.get("key_file") + + if "timeout" in extra_options: + self.timeout = int(extra_options["timeout"], 10) + + if "compress" in extra_options \ + and extra_options["compress"].lower() == 'false': + self.compress = False + if "no_host_key_check" in extra_options \ + and extra_options["no_host_key_check"].lower() == 'false': + self.no_host_key_check = False + + if not self.remote_host: + raise AirflowException("Missing required param: remote_host") + + # Auto detecting username values from system + if not self.username: + self.log.debug( + "username to ssh to host: %s is not specified for connection id" + " %s. Using system's default provided by getpass.getuser()", + self.remote_host, self.ssh_conn_id + ) + self.username = getpass.getuser() + + host_proxy = None + user_ssh_config_filename = os.path.expanduser('~/.ssh/config') + if os.path.isfile(user_ssh_config_filename): + ssh_conf = paramiko.SSHConfig() + ssh_conf.parse(open(user_ssh_config_filename)) + host_info = ssh_conf.lookup(self.remote_host) + if host_info and host_info.get('proxycommand'): + host_proxy = paramiko.ProxyCommand(host_info.get('proxycommand')) + + if not (self.password or self.key_file): + if host_info and host_info.get('identityfile'): + self.key_file = host_info.get('identityfile')[0] + + try: + client = paramiko.SSHClient() + client.load_system_host_keys() + if self.no_host_key_check: + # Default is RejectPolicy + client.set_missing_host_key_policy(paramiko.AutoAddPolicy()) + + if self.password and self.password.strip(): + client.connect(hostname=self.remote_host, + username=self.username, + password=self.password, + timeout=self.timeout, + compress=self.compress, + sock=host_proxy) + else: + client.connect(hostname=self.remote_host, + username=self.username, + key_filename=self.key_file, + timeout=self.timeout, + compress=self.compress, + sock=host_proxy) + + self.client = client + except paramiko.AuthenticationException as auth_error: + self.log.error( + "Auth failed while connecting to host: %s, error: %s", + self.remote_host, auth_error + ) + except paramiko.SSHException as ssh_error: + self.log.error( + "Failed connecting to host: %s, error: %s", + self.remote_host, ssh_error + ) + except Exception as error: + self.log.error( + "Error connecting to host: %s, error: %s", + self.remote_host, error + ) + return self.client + + @contextmanager + def create_tunnel(self, local_port, remote_port=None, remote_host="localhost"): + """ + Creates a tunnel between two hosts. Like ssh -L :host:. + Remember to close() the returned "tunnel" object in order to clean up + after yourself when you are done with the tunnel. + + :param local_port: + :type local_port: int + :param remote_port: + :type remote_port: int + :param remote_host: + :type remote_host: str + :return: + """ + + import subprocess + # this will ensure the connection to the ssh.remote_host from where the tunnel + # is getting created + self.get_conn() + + tunnel_host = "{0}:{1}:{2}".format(local_port, remote_host, remote_port) + + ssh_cmd = ["ssh", "{0}@{1}".format(self.username, self.remote_host), + "-o", "ControlMaster=no", + "-o", "UserKnownHostsFile=/dev/null", + "-o", "StrictHostKeyChecking=no"] + + ssh_tunnel_cmd = ["-L", tunnel_host, + "echo -n ready && cat" + ] + + ssh_cmd += ssh_tunnel_cmd + self.log.debug("Creating tunnel with cmd: %s", ssh_cmd) + + proc = subprocess.Popen(ssh_cmd, + stdin=subprocess.PIPE, + stdout=subprocess.PIPE) + ready = proc.stdout.read(5) + assert ready == b"ready", \ + "Did not get 'ready' from remote, got '{0}' instead".format(ready) + yield + proc.communicate() + assert proc.returncode == 0, \ + "Tunnel process did unclean exit (returncode {}".format(proc.returncode) + + def __enter__(self): + return self + + def __exit__(self, exc_type, exc_val, exc_tb): + if self.client is not None: + self.client.close() diff --git a/airflow/contrib/hooks/vertica_hook.py b/airflow/contrib/hooks/vertica_hook.py new file mode 100644 index 0000000000000..dc81ee10a6c63 --- /dev/null +++ b/airflow/contrib/hooks/vertica_hook.py @@ -0,0 +1,48 @@ +# -*- coding: utf-8 -*- +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +from vertica_python import connect + +from airflow.hooks.dbapi_hook import DbApiHook + + +class VerticaHook(DbApiHook): + ''' + Interact with Vertica. + ''' + + conn_name_attr = 'vertica_conn_id' + default_conn_name = 'vertica_default' + supports_autocommit = True + + def get_conn(self): + """ + Returns verticaql connection object + """ + conn = self.get_connection(self.vertica_conn_id) + conn_config = { + "user": conn.login, + "password": conn.password or '', + "database": conn.schema, + } + + conn_config["host"] = conn.host or 'localhost' + if not conn.port: + conn_config["port"] = 5433 + else: + conn_config["port"] = int(conn.port) + + conn = connect(**conn_config) + return conn diff --git a/airflow/contrib/hooks/wasb_hook.py b/airflow/contrib/hooks/wasb_hook.py new file mode 100644 index 0000000000000..cc9922b097154 --- /dev/null +++ b/airflow/contrib/hooks/wasb_hook.py @@ -0,0 +1,112 @@ +# -*- coding: utf-8 -*- +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +from airflow.hooks.base_hook import BaseHook + +from azure.storage.blob import BlockBlobService + + +class WasbHook(BaseHook): + """ + Interacts with Azure Blob Storage through the wasb:// protocol. + + Additional options passed in the 'extra' field of the connection will be + passed to the `BlockBlockService()` constructor. For example, authenticate + using a SAS token by adding {"sas_token": "YOUR_TOKEN"}. + + :param wasb_conn_id: Reference to the wasb connection. + :type wasb_conn_id: str + """ + + def __init__(self, wasb_conn_id='wasb_default'): + self.conn_id = wasb_conn_id + self.connection = self.get_conn() + + def get_conn(self): + """Return the BlockBlobService object.""" + conn = self.get_connection(self.conn_id) + service_options = conn.extra_dejson + return BlockBlobService(account_name=conn.login, + account_key=conn.password, **service_options) + + def check_for_blob(self, container_name, blob_name, **kwargs): + """ + Check if a blob exists on Azure Blob Storage. + + :param container_name: Name of the container. + :type container_name: str + :param blob_name: Name of the blob. + :type blob_name: str + :param kwargs: Optional keyword arguments that + `BlockBlobService.exists()` takes. + :type kwargs: object + :return: True if the blob exists, False otherwise. + :rtype bool + """ + return self.connection.exists(container_name, blob_name, **kwargs) + + def check_for_prefix(self, container_name, prefix, **kwargs): + """ + Check if a prefix exists on Azure Blob storage. + + :param container_name: Name of the container. + :type container_name: str + :param prefix: Prefix of the blob. + :type prefix: str + :param kwargs: Optional keyword arguments that + `BlockBlobService.list_blobs()` takes. + :type kwargs: object + :return: True if blobs matching the prefix exist, False otherwise. + :rtype bool + """ + matches = self.connection.list_blobs(container_name, prefix, + num_results=1, **kwargs) + return len(list(matches)) > 0 + + def load_file(self, file_path, container_name, blob_name, **kwargs): + """ + Upload a file to Azure Blob Storage. + + :param file_path: Path to the file to load. + :type file_path: str + :param container_name: Name of the container. + :type container_name: str + :param blob_name: Name of the blob. + :type blob_name: str + :param kwargs: Optional keyword arguments that + `BlockBlobService.create_blob_from_path()` takes. + :type kwargs: object + """ + # Reorder the argument order from airflow.hooks.S3_hook.load_file. + self.connection.create_blob_from_path(container_name, blob_name, + file_path, **kwargs) + + def load_string(self, string_data, container_name, blob_name, **kwargs): + """ + Upload a string to Azure Blob Storage. + + :param string_data: String to load. + :type string_data: str + :param container_name: Name of the container. + :type container_name: str + :param blob_name: Name of the blob. + :type blob_name: str + :param kwargs: Optional keyword arguments that + `BlockBlobService.create_blob_from_text()` takes. + :type kwargs: object + """ + # Reorder the argument order from airflow.hooks.S3_hook.load_string. + self.connection.create_blob_from_text(container_name, blob_name, + string_data, **kwargs) diff --git a/airflow/contrib/operators/__init__.py b/airflow/contrib/operators/__init__.py new file mode 100644 index 0000000000000..a761f4f87cd6b --- /dev/null +++ b/airflow/contrib/operators/__init__.py @@ -0,0 +1,48 @@ +# -*- coding: utf-8 -*- +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + + +# Contrib operators are not imported by default. They should be accessed +# directly: from airflow.contrib.operators.operator_module import Operator + + +import sys + + +# ------------------------------------------------------------------------ +# +# #TODO #FIXME Airflow 2.0 +# +# Old import machinary below. +# +# This is deprecated but should be kept until Airflow 2.0 +# for compatibility. +# +# ------------------------------------------------------------------------ +_operators = { + 'ssh_operator': ['SSHOperator'], + 'vertica_operator': ['VerticaOperator'], + 'vertica_to_hive': ['VerticaToHiveTransfer'], + 'qubole_operator': ['QuboleOperator'], + 'spark_submit_operator': ['SparkSubmitOperator'], + 'file_to_wasb': ['FileToWasbOperator'], + 'fs_operator': ['FileSensor'], + 'hive_to_dynamodb': ['HiveToDynamoDBTransferOperator'] +} + +import os as _os +if not _os.environ.get('AIRFLOW_USE_NEW_IMPORTS', False): + from airflow.utils.helpers import AirflowImporter + airflow_importer = AirflowImporter(sys.modules[__name__], _operators) diff --git a/airflow/contrib/operators/bigquery_check_operator.py b/airflow/contrib/operators/bigquery_check_operator.py new file mode 100644 index 0000000000000..e5a8f0f6086cc --- /dev/null +++ b/airflow/contrib/operators/bigquery_check_operator.py @@ -0,0 +1,125 @@ +# -*- coding: utf-8 -*- +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from airflow.contrib.hooks.bigquery_hook import BigQueryHook +from airflow.operators.check_operator import CheckOperator, ValueCheckOperator, IntervalCheckOperator +from airflow.utils.decorators import apply_defaults + + +class BigQueryCheckOperator(CheckOperator): + """ + Performs checks against BigQuery. The ``BigQueryCheckOperator`` expects + a sql query that will return a single row. Each value on that + first row is evaluated using python ``bool`` casting. If any of the + values return ``False`` the check is failed and errors out. + + Note that Python bool casting evals the following as ``False``: + + * ``False`` + * ``0`` + * Empty string (``""``) + * Empty list (``[]``) + * Empty dictionary or set (``{}``) + + Given a query like ``SELECT COUNT(*) FROM foo``, it will fail only if + the count ``== 0``. You can craft much more complex query that could, + for instance, check that the table has the same number of rows as + the source table upstream, or that the count of today's partition is + greater than yesterday's partition, or that a set of metrics are less + than 3 standard deviation for the 7 day average. + + This operator can be used as a data quality check in your pipeline, and + depending on where you put it in your DAG, you have the choice to + stop the critical path, preventing from + publishing dubious data, or on the side and receive email alterts + without stopping the progress of the DAG. + + :param sql: the sql to be executed + :type sql: string + :param bigquery_conn_id: reference to the BigQuery database + :type bigquery_conn_id: string + """ + + @apply_defaults + def __init__( + self, + sql, + bigquery_conn_id='bigquery_default', + *args, + **kwargs): + super(BigQueryCheckOperator, self).__init__(sql=sql, *args, **kwargs) + self.bigquery_conn_id = bigquery_conn_id + self.sql = sql + + def get_db_hook(self): + return BigQueryHook(bigquery_conn_id=self.bigquery_conn_id) + + +class BigQueryValueCheckOperator(ValueCheckOperator): + """ + Performs a simple value check using sql code. + + :param sql: the sql to be executed + :type sql: string + """ + + @apply_defaults + def __init__( + self, sql, pass_value, tolerance=None, + bigquery_conn_id='bigquery_default', + *args, **kwargs): + super(BigQueryValueCheckOperator, self).__init__( + sql=sql, pass_value=pass_value, tolerance=tolerance, + *args, **kwargs) + self.bigquery_conn_id = bigquery_conn_id + + def get_db_hook(self): + return BigQueryHook(bigquery_conn_id=self.bigquery_conn_id) + + +class BigQueryIntervalCheckOperator(IntervalCheckOperator): + """ + Checks that the values of metrics given as SQL expressions are within + a certain tolerance of the ones from days_back before. + + This method constructs a query like so: + + SELECT {metrics_threshold_dict_key} FROM {table} + WHERE {date_filter_column}= + + :param table: the table name + :type table: str + :param days_back: number of days between ds and the ds we want to check + against. Defaults to 7 days + :type days_back: int + :param metrics_threshold: a dictionary of ratios indexed by metrics, for + example 'COUNT(*)': 1.5 would require a 50 percent or less difference + between the current day, and the prior days_back. + :type metrics_threshold: dict + """ + + @apply_defaults + def __init__( + self, table, metrics_thresholds, + date_filter_column='ds', days_back=-7, + bigquery_conn_id='bigquery_default', + *args, **kwargs): + super(BigQueryIntervalCheckOperator, self).__init__( + table=table, metrics_thresholds=metrics_thresholds, + date_filter_column=date_filter_column, days_back=days_back, + *args, **kwargs) + self.bigquery_conn_id = bigquery_conn_id + + def get_db_hook(self): + return BigQueryHook(bigquery_conn_id=self.bigquery_conn_id) diff --git a/airflow/contrib/operators/bigquery_operator.py b/airflow/contrib/operators/bigquery_operator.py new file mode 100644 index 0000000000000..19efa550f6b02 --- /dev/null +++ b/airflow/contrib/operators/bigquery_operator.py @@ -0,0 +1,107 @@ +# -*- coding: utf-8 -*- +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from airflow.contrib.hooks.bigquery_hook import BigQueryHook +from airflow.models import BaseOperator +from airflow.utils.decorators import apply_defaults + + +class BigQueryOperator(BaseOperator): + """ + Executes BigQuery SQL queries in a specific BigQuery database + + :param bql: the sql code to be executed + :type bql: Can receive a str representing a sql statement, + a list of str (sql statements), or reference to a template file. + Template reference are recognized by str ending in '.sql' + :param destination_dataset_table: A dotted + (.|:).
that, if set, will store the results + of the query. + :type destination_dataset_table: string + :param write_disposition: Specifies the action that occurs if the destination table + already exists. (default: 'WRITE_EMPTY') + :type write_disposition: string + :param create_disposition: Specifies whether the job is allowed to create new tables. + (default: 'CREATE_IF_NEEDED') + :type create_disposition: string + :param bigquery_conn_id: reference to a specific BigQuery hook. + :type bigquery_conn_id: string + :param delegate_to: The account to impersonate, if any. + For this to work, the service account making the request must have domain-wide + delegation enabled. + :type delegate_to: string + :param udf_config: The User Defined Function configuration for the query. + See https://cloud.google.com/bigquery/user-defined-functions for details. + :type udf_config: list + :param use_legacy_sql: Whether to use legacy SQL (true) or standard SQL (false). + :type use_legacy_sql: boolean + :param maximum_billing_tier: Positive integer that serves as a multiplier of the basic price. + Defaults to None, in which case it uses the value set in the project. + :type maximum_billing_tier: integer + :param query_params: a dictionary containing query parameter types and values, passed to + BigQuery. + :type query_params: dict + + """ + template_fields = ('bql', 'destination_dataset_table') + template_ext = ('.sql',) + ui_color = '#e4f0e8' + + @apply_defaults + def __init__(self, + bql, + destination_dataset_table=False, + write_disposition='WRITE_EMPTY', + allow_large_results=False, + bigquery_conn_id='bigquery_default', + delegate_to=None, + udf_config=False, + use_legacy_sql=True, + maximum_billing_tier=None, + create_disposition='CREATE_IF_NEEDED', + query_params=None, + *args, + **kwargs): + super(BigQueryOperator, self).__init__(*args, **kwargs) + self.bql = bql + self.destination_dataset_table = destination_dataset_table + self.write_disposition = write_disposition + self.create_disposition = create_disposition + self.allow_large_results = allow_large_results + self.bigquery_conn_id = bigquery_conn_id + self.delegate_to = delegate_to + self.udf_config = udf_config + self.use_legacy_sql = use_legacy_sql + self.maximum_billing_tier = maximum_billing_tier + self.query_params = query_params + self.bq_cursor = None + + def execute(self, context): + if(self.bq_cursor == None): + self.log.info('Executing: %s', self.bql) + hook = BigQueryHook(bigquery_conn_id=self.bigquery_conn_id, + delegate_to=self.delegate_to) + conn = hook.get_conn() + self.bq_cursor = conn.cursor() + self.bq_cursor.run_query(self.bql, self.destination_dataset_table, self.write_disposition, + self.allow_large_results, self.udf_config, + self.use_legacy_sql, self.maximum_billing_tier, + self.create_disposition, self.query_params) + + + def on_kill(self): + super(BigQueryOperator, self).on_kill() + if(self.bq_cursor!=None): + self.log.info('Canceling running query due to execution timeout') + self.bq_cursor.cancel_query() diff --git a/airflow/contrib/operators/bigquery_table_delete_operator.py b/airflow/contrib/operators/bigquery_table_delete_operator.py new file mode 100644 index 0000000000000..0f4ef509acecb --- /dev/null +++ b/airflow/contrib/operators/bigquery_table_delete_operator.py @@ -0,0 +1,61 @@ +# -*- coding: utf-8 -*- +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from airflow.contrib.hooks.bigquery_hook import BigQueryHook +from airflow.models import BaseOperator +from airflow.utils.decorators import apply_defaults + + +class BigQueryTableDeleteOperator(BaseOperator): + """ + Deletes BigQuery tables + + :param deletion_dataset_table: A dotted + (.|:).
that indicates which table + will be deleted. + :type deletion_dataset_table: string + :param bigquery_conn_id: reference to a specific BigQuery hook. + :type bigquery_conn_id: string + :param delegate_to: The account to impersonate, if any. + For this to work, the service account making the request must have domain-wide + delegation enabled. + :type delegate_to: string + :param ignore_if_missing: if True, then return success even if the + requested table does not exist. + :type ignore_if_missing: boolean + """ + template_fields = ('deletion_dataset_table',) + ui_color = '#ffd1dc' + + @apply_defaults + def __init__(self, + deletion_dataset_table, + bigquery_conn_id='bigquery_default', + delegate_to=None, + ignore_if_missing=False, + *args, + **kwargs): + super(BigQueryTableDeleteOperator, self).__init__(*args, **kwargs) + self.deletion_dataset_table = deletion_dataset_table + self.bigquery_conn_id = bigquery_conn_id + self.delegate_to = delegate_to + self.ignore_if_missing = ignore_if_missing + + def execute(self, context): + self.log.info('Deleting: %s', self.deletion_dataset_table) + hook = BigQueryHook(bigquery_conn_id=self.bigquery_conn_id, + delegate_to=self.delegate_to) + conn = hook.get_conn() + cursor = conn.cursor() + cursor.run_table_delete(self.deletion_dataset_table, self.ignore_if_missing) diff --git a/airflow/contrib/operators/bigquery_to_bigquery.py b/airflow/contrib/operators/bigquery_to_bigquery.py new file mode 100644 index 0000000000000..2bc4a8b8fea5a --- /dev/null +++ b/airflow/contrib/operators/bigquery_to_bigquery.py @@ -0,0 +1,83 @@ +# -*- coding: utf-8 -*- +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from airflow.contrib.hooks.bigquery_hook import BigQueryHook +from airflow.models import BaseOperator +from airflow.utils.decorators import apply_defaults + + +class BigQueryToBigQueryOperator(BaseOperator): + """ + Copies data from one BigQuery table to another. See here: + + https://cloud.google.com/bigquery/docs/reference/v2/jobs#configuration.copy + + For more details about these parameters. + + :param source_project_dataset_tables: One or more + dotted (project:|project.).
BigQuery tables to use as the + source data. If is not included, project will be the project defined + in the connection json. Use a list if there are multiple source tables. + :type source_project_dataset_tables: list|string + :param destination_project_dataset_table: The destination BigQuery + table. Format is: (project:|project.).
+ :type destination_project_dataset_table: string + :param write_disposition: The write disposition if the table already exists. + :type write_disposition: string + :param create_disposition: The create disposition if the table doesn't exist. + :type create_disposition: string + :param bigquery_conn_id: reference to a specific BigQuery hook. + :type bigquery_conn_id: string + :param delegate_to: The account to impersonate, if any. + For this to work, the service account making the request must have domain-wide + delegation enabled. + :type delegate_to: string + """ + template_fields = ('source_project_dataset_tables', + 'destination_project_dataset_table') + template_ext = ('.sql',) + ui_color = '#e6f0e4' + + @apply_defaults + def __init__(self, + source_project_dataset_tables, + destination_project_dataset_table, + write_disposition='WRITE_EMPTY', + create_disposition='CREATE_IF_NEEDED', + bigquery_conn_id='bigquery_default', + delegate_to=None, + *args, + **kwargs): + super(BigQueryToBigQueryOperator, self).__init__(*args, **kwargs) + self.source_project_dataset_tables = source_project_dataset_tables + self.destination_project_dataset_table = destination_project_dataset_table + self.write_disposition = write_disposition + self.create_disposition = create_disposition + self.bigquery_conn_id = bigquery_conn_id + self.delegate_to = delegate_to + + def execute(self, context): + self.log.info( + 'Executing copy of %s into: %s', + self.source_project_dataset_tables, self.destination_project_dataset_table + ) + hook = BigQueryHook(bigquery_conn_id=self.bigquery_conn_id, + delegate_to=self.delegate_to) + conn = hook.get_conn() + cursor = conn.cursor() + cursor.run_copy( + self.source_project_dataset_tables, + self.destination_project_dataset_table, + self.write_disposition, + self.create_disposition) diff --git a/airflow/contrib/operators/bigquery_to_gcs.py b/airflow/contrib/operators/bigquery_to_gcs.py new file mode 100644 index 0000000000000..800e7bdf3223c --- /dev/null +++ b/airflow/contrib/operators/bigquery_to_gcs.py @@ -0,0 +1,95 @@ +# -*- coding: utf-8 -*- +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from airflow.contrib.hooks.bigquery_hook import BigQueryHook +from airflow.models import BaseOperator +from airflow.utils.decorators import apply_defaults + + +class BigQueryToCloudStorageOperator(BaseOperator): + """ + Transfers a BigQuery table to a Google Cloud Storage bucket. + + See here: + + https://cloud.google.com/bigquery/docs/reference/v2/jobs + + For more details about these parameters. + + :param source_project_dataset_table: The dotted + (.|:).
BigQuery table to use as the source + data. If is not included, project will be the project defined in + the connection json. + :type source_project_dataset_table: string + :param destination_cloud_storage_uris: The destination Google Cloud + Storage URI (e.g. gs://some-bucket/some-file.txt). Follows + convention defined here: + https://cloud.google.com/bigquery/exporting-data-from-bigquery#exportingmultiple + :type destination_cloud_storage_uris: list + :param compression: Type of compression to use. + :type compression: string + :param export_format: File format to export. + :type field_delimiter: string + :param field_delimiter: The delimiter to use when extracting to a CSV. + :type field_delimiter: string + :param print_header: Whether to print a header for a CSV file extract. + :type print_header: boolean + :param bigquery_conn_id: reference to a specific BigQuery hook. + :type bigquery_conn_id: string + :param delegate_to: The account to impersonate, if any. + For this to work, the service account making the request must have domain-wide + delegation enabled. + :type delegate_to: string + """ + template_fields = ('source_project_dataset_table', 'destination_cloud_storage_uris') + template_ext = ('.sql',) + ui_color = '#e4e6f0' + + @apply_defaults + def __init__(self, + source_project_dataset_table, + destination_cloud_storage_uris, + compression='NONE', + export_format='CSV', + field_delimiter=',', + print_header=True, + bigquery_conn_id='bigquery_default', + delegate_to=None, + *args, + **kwargs): + super(BigQueryToCloudStorageOperator, self).__init__(*args, **kwargs) + self.source_project_dataset_table = source_project_dataset_table + self.destination_cloud_storage_uris = destination_cloud_storage_uris + self.compression = compression + self.export_format = export_format + self.field_delimiter = field_delimiter + self.print_header = print_header + self.bigquery_conn_id = bigquery_conn_id + self.delegate_to = delegate_to + + def execute(self, context): + self.log.info('Executing extract of %s into: %s', + self.source_project_dataset_table, + self.destination_cloud_storage_uris) + hook = BigQueryHook(bigquery_conn_id=self.bigquery_conn_id, + delegate_to=self.delegate_to) + conn = hook.get_conn() + cursor = conn.cursor() + cursor.run_extract( + self.source_project_dataset_table, + self.destination_cloud_storage_uris, + self.compression, + self.export_format, + self.field_delimiter, + self.print_header) diff --git a/airflow/contrib/operators/databricks_operator.py b/airflow/contrib/operators/databricks_operator.py new file mode 100644 index 0000000000000..cffc4ff75b232 --- /dev/null +++ b/airflow/contrib/operators/databricks_operator.py @@ -0,0 +1,254 @@ +# -*- coding: utf-8 -*- +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +import six +import time + +from airflow.exceptions import AirflowException +from airflow.contrib.hooks.databricks_hook import DatabricksHook +from airflow.models import BaseOperator + + +class DatabricksSubmitRunOperator(BaseOperator): + """ + Submits an Spark job run to Databricks using the + `api/2.0/jobs/runs/submit + `_ + API endpoint. + + There are two ways to instantiate this operator. + + In the first way, you can take the JSON payload that you typically use + to call the ``api/2.0/jobs/runs/submit`` endpoint and pass it directly + to our ``DatabricksSubmitRunOperator`` through the ``json`` parameter. + For example :: + json = { + 'new_cluster': { + 'spark_version': '2.1.0-db3-scala2.11', + 'num_workers': 2 + }, + 'notebook_task': { + 'notebook_path': '/Users/airflow@example.com/PrepareData', + }, + } + notebook_run = DatabricksSubmitRunOperator(task_id='notebook_run', json=json) + + Another way to accomplish the same thing is to use the named parameters + of the ``DatabricksSubmitRunOperator`` directly. Note that there is exactly + one named parameter for each top level parameter in the ``runs/submit`` + endpoint. In this method, your code would look like this: :: + new_cluster = { + 'spark_version': '2.1.0-db3-scala2.11', + 'num_workers': 2 + } + notebook_task = { + 'notebook_path': '/Users/airflow@example.com/PrepareData', + } + notebook_run = DatabricksSubmitRunOperator( + task_id='notebook_run', + new_cluster=new_cluster, + notebook_task=notebook_task) + + In the case where both the json parameter **AND** the named parameters + are provided, they will be merged together. If there are conflicts during the merge, + the named parameters will take precedence and override the top level ``json`` keys. + + Currently the named parameters that ``DatabricksSubmitRunOperator`` supports are + - ``spark_jar_task`` + - ``notebook_task`` + - ``new_cluster`` + - ``existing_cluster_id`` + - ``libraries`` + - ``run_name`` + - ``timeout_seconds`` + + :param json: A JSON object containing API parameters which will be passed + directly to the ``api/2.0/jobs/runs/submit`` endpoint. The other named parameters + (i.e. ``spark_jar_task``, ``notebook_task``..) to this operator will + be merged with this json dictionary if they are provided. + If there are conflicts during the merge, the named parameters will + take precedence and override the top level json keys. This field will be + templated. + + .. seealso:: + For more information about templating see :ref:`jinja-templating`. + https://docs.databricks.com/api/latest/jobs.html#runs-submit + :type json: dict + :param spark_jar_task: The main class and parameters for the JAR task. Note that + the actual JAR is specified in the ``libraries``. + *EITHER* ``spark_jar_task`` *OR* ``notebook_task`` should be specified. + This field will be templated. + + .. seealso:: + https://docs.databricks.com/api/latest/jobs.html#jobssparkjartask + :type spark_jar_task: dict + :param notebook_task: The notebook path and parameters for the notebook task. + *EITHER* ``spark_jar_task`` *OR* ``notebook_task`` should be specified. + This field will be templated. + + .. seealso:: + https://docs.databricks.com/api/latest/jobs.html#jobsnotebooktask + :type notebook_task: dict + :param new_cluster: Specs for a new cluster on which this task will be run. + *EITHER* ``new_cluster`` *OR* ``existing_cluster_id`` should be specified. + This field will be templated. + + .. seealso:: + https://docs.databricks.com/api/latest/jobs.html#jobsclusterspecnewcluster + :type new_cluster: dict + :param existing_cluster_id: ID for existing cluster on which to run this task. + *EITHER* ``new_cluster`` *OR* ``existing_cluster_id`` should be specified. + This field will be templated. + :type existing_cluster_id: string + :param libraries: Libraries which this run will use. + This field will be templated. + + .. seealso:: + https://docs.databricks.com/api/latest/libraries.html#managedlibrarieslibrary + :type libraries: list of dicts + :param run_name: The run name used for this task. + By default this will be set to the Airflow ``task_id``. This ``task_id`` is a + required parameter of the superclass ``BaseOperator``. + This field will be templated. + :type run_name: string + :param timeout_seconds: The timeout for this run. By default a value of 0 is used + which means to have no timeout. + This field will be templated. + :type timeout_seconds: int32 + :param databricks_conn_id: The name of the Airflow connection to use. + By default and in the common case this will be ``databricks_default``. To use + token based authentication, provide the key ``token`` in the extra field for the + connection. + :type databricks_conn_id: string + :param polling_period_seconds: Controls the rate which we poll for the result of + this run. By default the operator will poll every 30 seconds. + :type polling_period_seconds: int + :param databricks_retry_limit: Amount of times retry if the Databricks backend is + unreachable. Its value must be greater than or equal to 1. + :type databricks_retry_limit: int + """ + # Used in airflow.models.BaseOperator + template_fields = ('json',) + # Databricks brand color (blue) under white text + ui_color = '#1CB1C2' + ui_fgcolor = '#fff' + + def __init__( + self, + json=None, + spark_jar_task=None, + notebook_task=None, + new_cluster=None, + existing_cluster_id=None, + libraries=None, + run_name=None, + timeout_seconds=None, + databricks_conn_id='databricks_default', + polling_period_seconds=30, + databricks_retry_limit=3, + **kwargs): + """ + Creates a new ``DatabricksSubmitRunOperator``. + """ + super(DatabricksSubmitRunOperator, self).__init__(**kwargs) + self.json = json or {} + self.databricks_conn_id = databricks_conn_id + self.polling_period_seconds = polling_period_seconds + self.databricks_retry_limit = databricks_retry_limit + if spark_jar_task is not None: + self.json['spark_jar_task'] = spark_jar_task + if notebook_task is not None: + self.json['notebook_task'] = notebook_task + if new_cluster is not None: + self.json['new_cluster'] = new_cluster + if existing_cluster_id is not None: + self.json['existing_cluster_id'] = existing_cluster_id + if libraries is not None: + self.json['libraries'] = libraries + if run_name is not None: + self.json['run_name'] = run_name + if timeout_seconds is not None: + self.json['timeout_seconds'] = timeout_seconds + if 'run_name' not in self.json: + self.json['run_name'] = run_name or kwargs['task_id'] + + self.json = self._deep_string_coerce(self.json) + # This variable will be used in case our task gets killed. + self.run_id = None + + def _deep_string_coerce(self, content, json_path='json'): + """ + Coerces content or all values of content if it is a dict to a string. The + function will throw if content contains non-string or non-numeric types. + + The reason why we have this function is because the ``self.json`` field must be a dict + with only string values. This is because ``render_template`` will fail for numerical values. + """ + c = self._deep_string_coerce + if isinstance(content, six.string_types): + return content + elif isinstance(content, six.integer_types+(float,)): + # Databricks can tolerate either numeric or string types in the API backend. + return str(content) + elif isinstance(content, (list, tuple)): + return [c(e, '{0}[{1}]'.format(json_path, i)) for i, e in enumerate(content)] + elif isinstance(content, dict): + return {k: c(v, '{0}[{1}]'.format(json_path, k)) + for k, v in list(content.items())} + else: + param_type = type(content) + msg = 'Type {0} used for parameter {1} is not a number or a string' \ + .format(param_type, json_path) + raise AirflowException(msg) + + def _log_run_page_url(self, url): + self.log.info('View run status, Spark UI, and logs at %s', url) + + def get_hook(self): + return DatabricksHook( + self.databricks_conn_id, + retry_limit=self.databricks_retry_limit) + + def execute(self, context): + hook = self.get_hook() + self.run_id = hook.submit_run(self.json) + run_page_url = hook.get_run_page_url(self.run_id) + self.log.info('Run submitted with run_id: %s', self.run_id) + self._log_run_page_url(run_page_url) + while True: + run_state = hook.get_run_state(self.run_id) + if run_state.is_terminal: + if run_state.is_successful: + self.log.info('%s completed successfully.', self.task_id) + self._log_run_page_url(run_page_url) + return + else: + error_message = '{t} failed with terminal state: {s}'.format( + t=self.task_id, + s=run_state) + raise AirflowException(error_message) + else: + self.log.info('%s in run state: %s', self.task_id, run_state) + self._log_run_page_url(run_page_url) + self.log.info('Sleeping for %s seconds.', self.polling_period_seconds) + time.sleep(self.polling_period_seconds) + + def on_kill(self): + hook = self.get_hook() + hook.cancel_run(self.run_id) + self.log.info( + 'Task: %s with run_id: %s was requested to be cancelled.', + self.task_id, self.run_id + ) diff --git a/airflow/contrib/operators/dataflow_operator.py b/airflow/contrib/operators/dataflow_operator.py new file mode 100644 index 0000000000000..b7103eb43ddbb --- /dev/null +++ b/airflow/contrib/operators/dataflow_operator.py @@ -0,0 +1,251 @@ +# -*- coding: utf-8 -*- +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import copy +import re +import uuid + +from airflow.contrib.hooks.gcs_hook import GoogleCloudStorageHook +from airflow.contrib.hooks.gcp_dataflow_hook import DataFlowHook +from airflow.models import BaseOperator +from airflow.utils.decorators import apply_defaults + + +class DataFlowJavaOperator(BaseOperator): + """ + Start a Java Cloud DataFlow batch job. The parameters of the operation + will be passed to the job. + + It's a good practice to define dataflow_* parameters in the default_args of the dag + like the project, zone and staging location. + + ``` + default_args = { + 'dataflow_default_options': { + 'project': 'my-gcp-project', + 'zone': 'europe-west1-d', + 'stagingLocation': 'gs://my-staging-bucket/staging/' + } + } + ``` + + You need to pass the path to your dataflow as a file reference with the ``jar`` + parameter, the jar needs to be a self executing jar. Use ``options`` to pass on + options to your job. + + ``` + t1 = DataFlowOperation( + task_id='datapflow_example', + jar='{{var.value.gcp_dataflow_base}}pipeline/build/libs/pipeline-example-1.0.jar', + options={ + 'autoscalingAlgorithm': 'BASIC', + 'maxNumWorkers': '50', + 'start': '{{ds}}', + 'partitionType': 'DAY' + }, + dag=my-dag) + ``` + + Both ``jar`` and ``options`` are templated so you can use variables in them. + """ + template_fields = ['options', 'jar'] + ui_color = '#0273d4' + + @apply_defaults + def __init__( + self, + jar, + dataflow_default_options=None, + options=None, + gcp_conn_id='google_cloud_default', + delegate_to=None, + poll_sleep=10, + *args, + **kwargs): + """ + Create a new DataFlowJavaOperator. Note that both + dataflow_default_options and options will be merged to specify pipeline + execution parameter, and dataflow_default_options is expected to save + high-level options, for instances, project and zone information, which + apply to all dataflow operators in the DAG. + + For more detail on job submission have a look at the reference: + + https://cloud.google.com/dataflow/pipelines/specifying-exec-params + + :param jar: The reference to a self executing DataFlow jar. + :type jar: string + :param dataflow_default_options: Map of default job options. + :type dataflow_default_options: dict + :param options: Map of job specific options. + :type options: dict + :param gcp_conn_id: The connection ID to use connecting to Google Cloud + Platform. + :type gcp_conn_id: string + :param delegate_to: The account to impersonate, if any. + For this to work, the service account making the request must have + domain-wide delegation enabled. + :type delegate_to: string + :param poll_sleep: The time in seconds to sleep between polling Google + Cloud Platform for the dataflow job status while the job is in the + JOB_STATE_RUNNING state. + :type poll_sleep: int + """ + super(DataFlowJavaOperator, self).__init__(*args, **kwargs) + + dataflow_default_options = dataflow_default_options or {} + options = options or {} + + self.gcp_conn_id = gcp_conn_id + self.delegate_to = delegate_to + self.jar = jar + self.dataflow_default_options = dataflow_default_options + self.options = options + self.poll_sleep = poll_sleep + + def execute(self, context): + bucket_helper = GoogleCloudBucketHelper( + self.gcp_conn_id, self.delegate_to) + self.jar = bucket_helper.google_cloud_to_local(self.jar) + hook = DataFlowHook(gcp_conn_id=self.gcp_conn_id, + delegate_to=self.delegate_to, + poll_sleep=self.poll_sleep) + + dataflow_options = copy.copy(self.dataflow_default_options) + dataflow_options.update(self.options) + + hook.start_java_dataflow(self.task_id, dataflow_options, self.jar) + + +class DataFlowPythonOperator(BaseOperator): + + template_fields = ['options', 'dataflow_default_options'] + + @apply_defaults + def __init__( + self, + py_file, + py_options=None, + dataflow_default_options=None, + options=None, + gcp_conn_id='google_cloud_default', + delegate_to=None, + poll_sleep=10, + *args, + **kwargs): + """ + Create a new DataFlowPythonOperator. Note that both + dataflow_default_options and options will be merged to specify pipeline + execution parameter, and dataflow_default_options is expected to save + high-level options, for instances, project and zone information, which + apply to all dataflow operators in the DAG. + + For more detail on job submission have a look at the reference: + + https://cloud.google.com/dataflow/pipelines/specifying-exec-params + + :param py_file: Reference to the python dataflow pipleline file, e.g., + /some/local/file/path/to/your/python/pipeline/file.py. + :type py_file: string + :param py_options: Additional python options. + :type pyt_options: list of strings, e.g., ["-m", "-v"]. + :param dataflow_default_options: Map of default job options. + :type dataflow_default_options: dict + :param options: Map of job specific options. + :type options: dict + :param gcp_conn_id: The connection ID to use connecting to Google Cloud + Platform. + :type gcp_conn_id: string + :param delegate_to: The account to impersonate, if any. + For this to work, the service account making the request must have + domain-wide delegation enabled. + :type delegate_to: string + :param poll_sleep: The time in seconds to sleep between polling Google + Cloud Platform for the dataflow job status while the job is in the + JOB_STATE_RUNNING state. + :type poll_sleep: int + """ + super(DataFlowPythonOperator, self).__init__(*args, **kwargs) + + self.py_file = py_file + self.py_options = py_options or [] + self.dataflow_default_options = dataflow_default_options or {} + self.options = options or {} + self.gcp_conn_id = gcp_conn_id + self.delegate_to = delegate_to + self.poll_sleep = poll_sleep + + def execute(self, context): + """Execute the python dataflow job.""" + bucket_helper = GoogleCloudBucketHelper( + self.gcp_conn_id, self.delegate_to) + self.py_file = bucket_helper.google_cloud_to_local(self.py_file) + hook = DataFlowHook(gcp_conn_id=self.gcp_conn_id, + delegate_to=self.delegate_to, + poll_sleep=self.poll_sleep) + dataflow_options = self.dataflow_default_options.copy() + dataflow_options.update(self.options) + # Convert argument names from lowerCamelCase to snake case. + camel_to_snake = lambda name: re.sub( + r'[A-Z]', lambda x: '_' + x.group(0).lower(), name) + formatted_options = {camel_to_snake(key): dataflow_options[key] + for key in dataflow_options} + hook.start_python_dataflow( + self.task_id, formatted_options, + self.py_file, self.py_options) + + +class GoogleCloudBucketHelper(): + """GoogleCloudStorageHook helper class to download GCS object.""" + GCS_PREFIX_LENGTH = 5 + + def __init__(self, + gcp_conn_id='google_cloud_default', + delegate_to=None): + self._gcs_hook = GoogleCloudStorageHook(gcp_conn_id, delegate_to) + + def google_cloud_to_local(self, file_name): + """ + Checks whether the file specified by file_name is stored in Google Cloud + Storage (GCS), if so, downloads the file and saves it locally. The full + path of the saved file will be returned. Otherwise the local file_name + will be returned immediately. + + :param file_name: The full path of input file. + :type file_name: string + :return: The full path of local file. + :type: string + """ + if not file_name.startswith('gs://'): + return file_name + + # Extracts bucket_id and object_id by first removing 'gs://' prefix and + # then split the remaining by path delimiter '/'. + path_components = file_name[self.GCS_PREFIX_LENGTH:].split('/') + if path_components < 2: + raise Exception( + 'Invalid Google Cloud Storage (GCS) object path: {}.' + .format(file_name)) + + bucket_id = path_components[0] + object_id = '/'.join(path_components[1:]) + local_file = '/tmp/dataflow{}-{}'.format(str(uuid.uuid1())[:8], + path_components[-1]) + file_size = self._gcs_hook.download(bucket_id, object_id, local_file) + + if file_size > 0: + return local_file + raise Exception( + 'Failed to download Google Cloud Storage GCS object: {}' + .format(file_name)) diff --git a/airflow/contrib/operators/dataproc_operator.py b/airflow/contrib/operators/dataproc_operator.py new file mode 100644 index 0000000000000..ba2c601b70b82 --- /dev/null +++ b/airflow/contrib/operators/dataproc_operator.py @@ -0,0 +1,942 @@ +# -*- coding: utf-8 -*- +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +import time + +from airflow.contrib.hooks.gcp_dataproc_hook import DataProcHook +from airflow.models import BaseOperator +from airflow.utils.decorators import apply_defaults +from airflow.version import version +from googleapiclient.errors import HttpError + + +class DataprocClusterCreateOperator(BaseOperator): + """ + Create a new cluster on Google Cloud Dataproc. The operator will wait until the + creation is successful or an error occurs in the creation process. + + The parameters allow to configure the cluster. Please refer to + + https://cloud.google.com/dataproc/docs/reference/rest/v1/projects.regions.clusters + + for a detailed explanation on the different parameters. Most of the configuration + parameters detailed in the link are available as a parameter to this operator. + """ + + template_fields = ['cluster_name',] + + @apply_defaults + def __init__(self, + cluster_name, + project_id, + num_workers, + zone, + network_uri=None, + subnetwork_uri=None, + tags=None, + storage_bucket=None, + init_actions_uris=None, + metadata=None, + image_version=None, + properties=None, + master_machine_type='n1-standard-4', + master_disk_size=500, + worker_machine_type='n1-standard-4', + worker_disk_size=500, + num_preemptible_workers=0, + labels=None, + region='global', + gcp_conn_id='google_cloud_default', + delegate_to=None, + service_account=None, + service_account_scopes=None, + *args, + **kwargs): + """ + Create a new DataprocClusterCreateOperator. + + For more info on the creation of a cluster through the API, have a look at: + + https://cloud.google.com/dataproc/docs/reference/rest/v1/projects.regions.clusters + + :param cluster_name: The name of the DataProc cluster to create. + :type cluster_name: string + :param project_id: The ID of the google cloud project in which + to create the cluster + :type project_id: string + :param num_workers: The # of workers to spin up + :type num_workers: int + :param storage_bucket: The storage bucket to use, setting to None lets dataproc + generate a custom one for you + :type storage_bucket: string + :param init_actions_uris: List of GCS uri's containing + dataproc initialization scripts + :type init_actions_uris: list[string] + :param metadata: dict of key-value google compute engine metadata entries + to add to all instances + :type metadata: dict + :param image_version: the version of software inside the Dataproc cluster + :type image_version: string + :param properties: dict of properties to set on + config files (e.g. spark-defaults.conf), see + https://cloud.google.com/dataproc/docs/reference/rest/v1/ \ + projects.regions.clusters#SoftwareConfig + :type properties: dict + :param master_machine_type: Compute engine machine type to use for the master node + :type master_machine_type: string + :param master_disk_size: Disk size for the master node + :type int + :param worker_machine_type:Compute engine machine type to use for the worker nodes + :type worker_machine_type: string + :param worker_disk_size: Disk size for the worker nodes + :type worker_disk_size: int + :param num_preemptible_workers: The # of preemptible worker nodes to spin up + :type num_preemptible_workers: int + :param labels: dict of labels to add to the cluster + :type labels: dict + :param zone: The zone where the cluster will be located + :type zone: string + :param network_uri: The network uri to be used for machine communication, cannot be + specified with subnetwork_uri + :type network_uri: string + :param subnetwork_uri: The subnetwork uri to be used for machine communication, cannot be + specified with network_uri + :type subnetwork_uri: string + :param tags: The GCE tags to add to all instances + :type tags: list[string] + :param region: leave as 'global', might become relevant in the future + :param gcp_conn_id: The connection ID to use connecting to Google Cloud Platform. + :type gcp_conn_id: string + :param delegate_to: The account to impersonate, if any. + For this to work, the service account making the request must have domain-wide + delegation enabled. + :type delegate_to: string + :param service_account: The service account of the dataproc instances. + :type service_account: string + :param service_account_scopes: The URIs of service account scopes to be included. + :type service_account_scopes: list[string] + """ + super(DataprocClusterCreateOperator, self).__init__(*args, **kwargs) + self.gcp_conn_id = gcp_conn_id + self.delegate_to = delegate_to + self.cluster_name = cluster_name + self.project_id = project_id + self.num_workers = num_workers + self.num_preemptible_workers = num_preemptible_workers + self.storage_bucket = storage_bucket + self.init_actions_uris = init_actions_uris + self.metadata = metadata + self.image_version = image_version + self.properties = properties + self.master_machine_type = master_machine_type + self.master_disk_size = master_disk_size + self.worker_machine_type = worker_machine_type + self.worker_disk_size = worker_disk_size + self.labels = labels + self.zone = zone + self.network_uri = network_uri + self.subnetwork_uri = subnetwork_uri + self.tags = tags + self.region = region + self.service_account = service_account + self.service_account_scopes = service_account_scopes + + def _get_cluster_list_for_project(self, service): + result = service.projects().regions().clusters().list( + projectId=self.project_id, + region=self.region + ).execute() + return result.get('clusters', []) + + def _get_cluster(self, service): + cluster_list = self._get_cluster_list_for_project(service) + cluster = [c for c in cluster_list if c['clusterName'] == self.cluster_name] + if cluster: + return cluster[0] + return None + + def _get_cluster_state(self, service): + cluster = self._get_cluster(service) + if 'status' in cluster: + return cluster['status']['state'] + else: + return None + + def _cluster_ready(self, state, service): + if state == 'RUNNING': + return True + if state == 'ERROR': + cluster = self._get_cluster(service) + try: + error_details = cluster['status']['details'] + except KeyError: + error_details = 'Unknown error in cluster creation, ' \ + 'check Google Cloud console for details.' + raise Exception(error_details) + return False + + def _wait_for_done(self, service): + while True: + state = self._get_cluster_state(service) + if state is None: + self.log.info("No state for cluster '%s'", self.cluster_name) + time.sleep(15) + else: + self.log.info("State for cluster '%s' is %s", self.cluster_name, state) + if self._cluster_ready(state, service): + self.log.info( + "Cluster '%s' successfully created", self.cluster_name + ) + return + time.sleep(15) + + def _build_cluster_data(self): + zone_uri = \ + 'https://www.googleapis.com/compute/v1/projects/{}/zones/{}'.format( + self.project_id, self.zone + ) + master_type_uri = \ + "https://www.googleapis.com/compute/v1/projects/{}/zones/{}/machineTypes/{}".format( + self.project_id, self.zone, self.master_machine_type + ) + worker_type_uri = \ + "https://www.googleapis.com/compute/v1/projects/{}/zones/{}/machineTypes/{}".format( + self.project_id, self.zone, self.worker_machine_type + ) + cluster_data = { + 'projectId': self.project_id, + 'clusterName': self.cluster_name, + 'config': { + 'gceClusterConfig': { + 'zoneUri': zone_uri + }, + 'masterConfig': { + 'numInstances': 1, + 'machineTypeUri': master_type_uri, + 'diskConfig': { + 'bootDiskSizeGb': self.master_disk_size + } + }, + 'workerConfig': { + 'numInstances': self.num_workers, + 'machineTypeUri': worker_type_uri, + 'diskConfig': { + 'bootDiskSizeGb': self.worker_disk_size + } + }, + 'secondaryWorkerConfig': {}, + 'softwareConfig': {} + } + } + if self.num_preemptible_workers > 0: + cluster_data['config']['secondaryWorkerConfig'] = { + 'numInstances': self.num_preemptible_workers, + 'machineTypeUri': worker_type_uri, + 'diskConfig': { + 'bootDiskSizeGb': self.worker_disk_size + }, + 'isPreemptible': True + } + + cluster_data['labels'] = self.labels if self.labels else {} + # Dataproc labels must conform to the following regex: + # [a-z]([-a-z0-9]*[a-z0-9])? (current airflow version string follows + # semantic versioning spec: x.y.z). + cluster_data['labels'].update({'airflow-version': + 'v' + version.replace('.', '-').replace('+','-')}) + if self.storage_bucket: + cluster_data['config']['configBucket'] = self.storage_bucket + if self.metadata: + cluster_data['config']['gceClusterConfig']['metadata'] = self.metadata + if self.network_uri: + cluster_data['config']['gceClusterConfig']['networkUri'] = self.network_uri + if self.subnetwork_uri: + cluster_data['config']['gceClusterConfig']['subnetworkUri'] = self.subnetwork_uri + if self.tags: + cluster_data['config']['gceClusterConfig']['tags'] = self.tags + if self.image_version: + cluster_data['config']['softwareConfig']['imageVersion'] = self.image_version + if self.properties: + cluster_data['config']['softwareConfig']['properties'] = self.properties + if self.init_actions_uris: + init_actions_dict = [ + {'executableFile': uri} for uri in self.init_actions_uris + ] + cluster_data['config']['initializationActions'] = init_actions_dict + if self.service_account: + cluster_data['config']['gceClusterConfig']['serviceAccount'] =\ + self.service_account + if self.service_account_scopes: + cluster_data['config']['gceClusterConfig']['serviceAccountScopes'] =\ + self.service_account_scopes + return cluster_data + + def execute(self, context): + self.log.info('Creating cluster: %s', self.cluster_name) + hook = DataProcHook( + gcp_conn_id=self.gcp_conn_id, + delegate_to=self.delegate_to + ) + service = hook.get_conn() + + if self._get_cluster(service): + self.log.info( + 'Cluster %s already exists... Checking status...', + self.cluster_name + ) + self._wait_for_done(service) + return True + + cluster_data = self._build_cluster_data() + try: + service.projects().regions().clusters().create( + projectId=self.project_id, + region=self.region, + body=cluster_data + ).execute() + except HttpError as e: + # probably two cluster start commands at the same time + time.sleep(10) + if self._get_cluster(service): + self.log.info( + 'Cluster {} already exists... Checking status...', + self.cluster_name + ) + self._wait_for_done(service) + return True + else: + raise e + + self._wait_for_done(service) + + +class DataprocClusterDeleteOperator(BaseOperator): + """ + Delete a cluster on Google Cloud Dataproc. The operator will wait until the + cluster is destroyed. + """ + + template_fields = ['cluster_name'] + + @apply_defaults + def __init__(self, + cluster_name, + project_id, + region='global', + gcp_conn_id='google_cloud_default', + delegate_to=None, + *args, + **kwargs): + """ + Delete a cluster on Google Cloud Dataproc. + + :param cluster_name: The name of the cluster to create. + :type cluster_name: string + :param project_id: The ID of the google cloud project in which + the cluster runs + :type project_id: string + :param region: leave as 'global', might become relevant in the future + :type region: string + :param gcp_conn_id: The connection ID to use connecting to Google Cloud Platform. + :type gcp_conn_id: string + :param delegate_to: The account to impersonate, if any. + For this to work, the service account making the request must have domain-wide + delegation enabled. + :type delegate_to: string + """ + super(DataprocClusterDeleteOperator, self).__init__(*args, **kwargs) + self.gcp_conn_id = gcp_conn_id + self.delegate_to = delegate_to + self.cluster_name = cluster_name + self.project_id = project_id + self.region = region + + def _wait_for_done(self, service, operation_name): + time.sleep(15) + while True: + response = service.projects().regions().operations().get( + name=operation_name + ).execute() + + if 'done' in response and response['done']: + if 'error' in response: + raise Exception(str(response['error'])) + else: + return + time.sleep(15) + + def execute(self, context): + self.log.info('Deleting cluster: %s', self.cluster_name) + hook = DataProcHook( + gcp_conn_id=self.gcp_conn_id, + delegate_to=self.delegate_to + ) + service = hook.get_conn() + + response = service.projects().regions().clusters().delete( + projectId=self.project_id, + region=self.region, + clusterName=self.cluster_name + ).execute() + operation_name = response['name'] + self.log.info("Cluster delete operation name: %s", operation_name) + self._wait_for_done(service, operation_name) + + +class DataProcPigOperator(BaseOperator): + """ + Start a Pig query Job on a Cloud DataProc cluster. The parameters of the operation + will be passed to the cluster. + + It's a good practice to define dataproc_* parameters in the default_args of the dag + like the cluster name and UDFs. + + ``` + default_args = { + 'cluster_name': 'cluster-1', + 'dataproc_pig_jars': [ + 'gs://example/udf/jar/datafu/1.2.0/datafu.jar', + 'gs://example/udf/jar/gpig/1.2/gpig.jar' + ] + } + ``` + + You can pass a pig script as string or file reference. Use variables to pass on + variables for the pig script to be resolved on the cluster or use the parameters to + be resolved in the script as template parameters. + + ``` + t1 = DataProcPigOperator( + task_id='dataproc_pig', + query='a_pig_script.pig', + variables={'out': 'gs://example/output/{{ds}}'}, + dag=dag) + ``` + """ + template_fields = ['query', 'variables', 'job_name', 'cluster_name'] + template_ext = ('.pg', '.pig',) + ui_color = '#0273d4' + + @apply_defaults + def __init__( + self, + query=None, + query_uri=None, + variables=None, + job_name='{{task.task_id}}_{{ds_nodash}}', + cluster_name='cluster-1', + dataproc_pig_properties=None, + dataproc_pig_jars=None, + gcp_conn_id='google_cloud_default', + delegate_to=None, + *args, + **kwargs): + """ + Create a new DataProcPigOperator. + + For more detail on about job submission have a look at the reference: + + https://cloud.google.com/dataproc/reference/rest/v1/projects.regions.jobs + + :param query: The query or reference to the query file (pg or pig extension). + :type query: string + :param query_uri: The uri of a pig script on Cloud Storage. + :type query_uri: string + :param variables: Map of named parameters for the query. + :type variables: dict + :param job_name: The job name used in the DataProc cluster. This name by default + is the task_id appended with the execution data, but can be templated. The + name will always be appended with a random number to avoid name clashes. + :type job_name: string + :param cluster_name: The name of the DataProc cluster. + :type cluster_name: string + :param dataproc_pig_properties: Map for the Pig properties. Ideal to put in + default arguments + :type dataproc_pig_properties: dict + :param dataproc_pig_jars: URIs to jars provisioned in Cloud Storage (example: for + UDFs and libs) and are ideal to put in default arguments. + :type dataproc_pig_jars: list + :param gcp_conn_id: The connection ID to use connecting to Google Cloud Platform. + :type gcp_conn_id: string + :param delegate_to: The account to impersonate, if any. + For this to work, the service account making the request must have domain-wide + delegation enabled. + :type delegate_to: string + """ + super(DataProcPigOperator, self).__init__(*args, **kwargs) + self.gcp_conn_id = gcp_conn_id + self.delegate_to = delegate_to + self.query = query + self.query_uri = query_uri + self.variables = variables + self.job_name = job_name + self.cluster_name = cluster_name + self.dataproc_properties = dataproc_pig_properties + self.dataproc_jars = dataproc_pig_jars + + def execute(self, context): + hook = DataProcHook(gcp_conn_id=self.gcp_conn_id, + delegate_to=self.delegate_to) + job = hook.create_job_template(self.task_id, self.cluster_name, "pigJob", + self.dataproc_properties) + + if self.query is None: + job.add_query_uri(self.query_uri) + else: + job.add_query(self.query) + job.add_variables(self.variables) + job.add_jar_file_uris(self.dataproc_jars) + job.set_job_name(self.job_name) + + hook.submit(hook.project_id, job.build()) + + +class DataProcHiveOperator(BaseOperator): + """ + Start a Hive query Job on a Cloud DataProc cluster. + """ + template_fields = ['query', 'variables', 'job_name', 'cluster_name'] + template_ext = ('.q',) + ui_color = '#0273d4' + + @apply_defaults + def __init__( + self, + query=None, + query_uri=None, + variables=None, + job_name='{{task.task_id}}_{{ds_nodash}}', + cluster_name='cluster-1', + dataproc_hive_properties=None, + dataproc_hive_jars=None, + gcp_conn_id='google_cloud_default', + delegate_to=None, + region='global', + *args, + **kwargs): + """ + Create a new DataProcHiveOperator. + + :param query: The query or reference to the query file (q extension). + :type query: string + :param query_uri: The uri of a hive script on Cloud Storage. + :type query_uri: string + :param variables: Map of named parameters for the query. + :type variables: dict + :param job_name: The job name used in the DataProc cluster. This name by default + is the task_id appended with the execution data, but can be templated. The + name will always be appended with a random number to avoid name clashes. + :type job_name: string + :param cluster_name: The name of the DataProc cluster. + :type cluster_name: string + :param dataproc_hive_properties: Map for the Pig properties. Ideal to put in + default arguments + :type dataproc_hive_properties: dict + :param dataproc_hive_jars: URIs to jars provisioned in Cloud Storage (example: for + UDFs and libs) and are ideal to put in default arguments. + :type dataproc_hive_jars: list + :param gcp_conn_id: The connection ID to use connecting to Google Cloud Platform. + :type gcp_conn_id: string + :param delegate_to: The account to impersonate, if any. + For this to work, the service account making the request must have domain-wide + delegation enabled. + :type delegate_to: string + :param region: The specified region where the dataproc cluster is created. + :type region: string + """ + super(DataProcHiveOperator, self).__init__(*args, **kwargs) + self.gcp_conn_id = gcp_conn_id + self.delegate_to = delegate_to + self.query = query + self.query_uri = query_uri + self.variables = variables + self.job_name = job_name + self.cluster_name = cluster_name + self.dataproc_properties = dataproc_hive_properties + self.dataproc_jars = dataproc_hive_jars + self.region = region + + def execute(self, context): + hook = DataProcHook(gcp_conn_id=self.gcp_conn_id, + delegate_to=self.delegate_to) + + job = hook.create_job_template(self.task_id, self.cluster_name, "hiveJob", + self.dataproc_properties) + + if self.query is None: + job.add_query_uri(self.query_uri) + else: + job.add_query(self.query) + job.add_variables(self.variables) + job.add_jar_file_uris(self.dataproc_jars) + job.set_job_name(self.job_name) + + hook.submit(hook.project_id, job.build(), self.region) + + +class DataProcSparkSqlOperator(BaseOperator): + """ + Start a Spark SQL query Job on a Cloud DataProc cluster. + """ + template_fields = ['query', 'variables', 'job_name', 'cluster_name'] + template_ext = ('.q',) + ui_color = '#0273d4' + + @apply_defaults + def __init__( + self, + query=None, + query_uri=None, + variables=None, + job_name='{{task.task_id}}_{{ds_nodash}}', + cluster_name='cluster-1', + dataproc_spark_properties=None, + dataproc_spark_jars=None, + gcp_conn_id='google_cloud_default', + delegate_to=None, + *args, + **kwargs): + """ + Create a new DataProcSparkSqlOperator. + + :param query: The query or reference to the query file (q extension). + :type query: string + :param query_uri: The uri of a spark sql script on Cloud Storage. + :type query_uri: string + :param variables: Map of named parameters for the query. + :type variables: dict + :param job_name: The job name used in the DataProc cluster. This name by default + is the task_id appended with the execution data, but can be templated. The + name will always be appended with a random number to avoid name clashes. + :type job_name: string + :param cluster_name: The name of the DataProc cluster. + :type cluster_name: string + :param dataproc_spark_properties: Map for the Pig properties. Ideal to put in + default arguments + :type dataproc_spark_properties: dict + :param dataproc_spark_jars: URIs to jars provisioned in Cloud Storage (example: + for UDFs and libs) and are ideal to put in default arguments. + :type dataproc_spark_jars: list + :param gcp_conn_id: The connection ID to use connecting to Google Cloud Platform. + :type gcp_conn_id: string + :param delegate_to: The account to impersonate, if any. + For this to work, the service account making the request must have domain-wide + delegation enabled. + :type delegate_to: string + """ + super(DataProcSparkSqlOperator, self).__init__(*args, **kwargs) + self.gcp_conn_id = gcp_conn_id + self.delegate_to = delegate_to + self.query = query + self.query_uri = query_uri + self.variables = variables + self.job_name = job_name + self.cluster_name = cluster_name + self.dataproc_properties = dataproc_spark_properties + self.dataproc_jars = dataproc_spark_jars + + def execute(self, context): + hook = DataProcHook(gcp_conn_id=self.gcp_conn_id, + delegate_to=self.delegate_to) + + job = hook.create_job_template(self.task_id, self.cluster_name, "sparkSqlJob", + self.dataproc_properties) + + if self.query is None: + job.add_query_uri(self.query_uri) + else: + job.add_query(self.query) + job.add_variables(self.variables) + job.add_jar_file_uris(self.dataproc_jars) + job.set_job_name(self.job_name) + + hook.submit(hook.project_id, job.build()) + + +class DataProcSparkOperator(BaseOperator): + """ + Start a Spark Job on a Cloud DataProc cluster. + """ + + template_fields = ['arguments', 'job_name', 'cluster_name'] + ui_color = '#0273d4' + + @apply_defaults + def __init__( + self, + main_jar=None, + main_class=None, + arguments=None, + archives=None, + files=None, + job_name='{{task.task_id}}_{{ds_nodash}}', + cluster_name='cluster-1', + dataproc_spark_properties=None, + dataproc_spark_jars=None, + gcp_conn_id='google_cloud_default', + delegate_to=None, + region='global', + *args, + **kwargs): + """ + Create a new DataProcSparkOperator. + + :param main_jar: URI of the job jar provisioned on Cloud Storage. (use this or + the main_class, not both together). + :type main_jar: string + :param main_class: Name of the job class. (use this or the main_jar, not both + together). + :type main_class: string + :param arguments: Arguments for the job. + :type arguments: list + :param archives: List of archived files that will be unpacked in the work + directory. Should be stored in Cloud Storage. + :type archives: list + :param files: List of files to be copied to the working directory + :type files: list + :param job_name: The job name used in the DataProc cluster. This name by default + is the task_id appended with the execution data, but can be templated. The + name will always be appended with a random number to avoid name clashes. + :type job_name: string + :param cluster_name: The name of the DataProc cluster. + :type cluster_name: string + :param dataproc_spark_properties: Map for the Pig properties. Ideal to put in + default arguments + :type dataproc_spark_properties: dict + :param dataproc_spark_jars: URIs to jars provisioned in Cloud Storage (example: + for UDFs and libs) and are ideal to put in default arguments. + :type dataproc_spark_jars: list + :param gcp_conn_id: The connection ID to use connecting to Google Cloud Platform. + :type gcp_conn_id: string + :param delegate_to: The account to impersonate, if any. + For this to work, the service account making the request must have domain-wide + delegation enabled. + :type delegate_to: string + :param region: The specified region where the dataproc cluster is created. + :type region: string + """ + super(DataProcSparkOperator, self).__init__(*args, **kwargs) + self.gcp_conn_id = gcp_conn_id + self.delegate_to = delegate_to + self.main_jar = main_jar + self.main_class = main_class + self.arguments = arguments + self.archives = archives + self.files = files + self.job_name = job_name + self.cluster_name = cluster_name + self.dataproc_properties = dataproc_spark_properties + self.dataproc_jars = dataproc_spark_jars + self.region = region + + def execute(self, context): + hook = DataProcHook(gcp_conn_id=self.gcp_conn_id, + delegate_to=self.delegate_to) + job = hook.create_job_template(self.task_id, self.cluster_name, "sparkJob", + self.dataproc_properties) + + job.set_main(self.main_jar, self.main_class) + job.add_args(self.arguments) + job.add_jar_file_uris(self.dataproc_jars) + job.add_archive_uris(self.archives) + job.add_file_uris(self.files) + job.set_job_name(self.job_name) + + hook.submit(hook.project_id, job.build(), self.region) + + +class DataProcHadoopOperator(BaseOperator): + """ + Start a Hadoop Job on a Cloud DataProc cluster. + """ + + template_fields = ['arguments', 'job_name', 'cluster_name'] + ui_color = '#0273d4' + + @apply_defaults + def __init__( + self, + main_jar=None, + main_class=None, + arguments=None, + archives=None, + files=None, + job_name='{{task.task_id}}_{{ds_nodash}}', + cluster_name='cluster-1', + dataproc_hadoop_properties=None, + dataproc_hadoop_jars=None, + gcp_conn_id='google_cloud_default', + delegate_to=None, + region='global', + *args, + **kwargs): + """ + Create a new DataProcHadoopOperator. + + :param main_jar: URI of the job jar provisioned on Cloud Storage. (use this or + the main_class, not both together). + :type main_jar: string + :param main_class: Name of the job class. (use this or the main_jar, not both + together). + :type main_class: string + :param arguments: Arguments for the job. + :type arguments: list + :param archives: List of archived files that will be unpacked in the work + directory. Should be stored in Cloud Storage. + :type archives: list + :param files: List of files to be copied to the working directory + :type files: list + :param job_name: The job name used in the DataProc cluster. This name by default + is the task_id appended with the execution data, but can be templated. The + name will always be appended with a random number to avoid name clashes. + :type job_name: string + :param cluster_name: The name of the DataProc cluster. + :type cluster_name: string + :param dataproc_hadoop_properties: Map for the Pig properties. Ideal to put in + default arguments + :type dataproc_hadoop_properties: dict + :param dataproc_hadoop_jars: URIs to jars provisioned in Cloud Storage (example: + for UDFs and libs) and are ideal to put in default arguments. + :type dataproc_hadoop_jars: list + :param gcp_conn_id: The connection ID to use connecting to Google Cloud Platform. + :type gcp_conn_id: string + :param delegate_to: The account to impersonate, if any. + For this to work, the service account making the request must have domain-wide + delegation enabled. + :type delegate_to: string + :param region: The specified region where the dataproc cluster is created. + :type region: string + """ + super(DataProcHadoopOperator, self).__init__(*args, **kwargs) + self.gcp_conn_id = gcp_conn_id + self.delegate_to = delegate_to + self.main_jar = main_jar + self.main_class = main_class + self.arguments = arguments + self.archives = archives + self.files = files + self.job_name = job_name + self.cluster_name = cluster_name + self.dataproc_properties = dataproc_hadoop_properties + self.dataproc_jars = dataproc_hadoop_jars + self.region = region + + def execute(self, context): + hook = DataProcHook(gcp_conn_id=self.gcp_conn_id, + delegate_to=self.delegate_to) + job = hook.create_job_template(self.task_id, self.cluster_name, "hadoopJob", + self.dataproc_properties) + + job.set_main(self.main_jar, self.main_class) + job.add_args(self.arguments) + job.add_jar_file_uris(self.dataproc_jars) + job.add_archive_uris(self.archives) + job.add_file_uris(self.files) + job.set_job_name(self.job_name) + + hook.submit(hook.project_id, job.build(), self.region) + + +class DataProcPySparkOperator(BaseOperator): + """ + Start a PySpark Job on a Cloud DataProc cluster. + """ + + template_fields = ['arguments', 'job_name', 'cluster_name'] + ui_color = '#0273d4' + + @apply_defaults + def __init__( + self, + main, + arguments=None, + archives=None, + pyfiles=None, + files=None, + job_name='{{task.task_id}}_{{ds_nodash}}', + cluster_name='cluster-1', + dataproc_pyspark_properties=None, + dataproc_pyspark_jars=None, + gcp_conn_id='google_cloud_default', + delegate_to=None, + region='global', + *args, + **kwargs): + """ + Create a new DataProcPySparkOperator. + + :param main: [Required] The Hadoop Compatible Filesystem (HCFS) URI of the main + Python file to use as the driver. Must be a .py file. + :type main: string + :param arguments: Arguments for the job. + :type arguments: list + :param archives: List of archived files that will be unpacked in the work + directory. Should be stored in Cloud Storage. + :type archives: list + :param files: List of files to be copied to the working directory + :type files: list + :param pyfiles: List of Python files to pass to the PySpark framework. + Supported file types: .py, .egg, and .zip + :type pyfiles: list + :param job_name: The job name used in the DataProc cluster. This name by default + is the task_id appended with the execution data, but can be templated. The + name will always be appended with a random number to avoid name clashes. + :type job_name: string + :param cluster_name: The name of the DataProc cluster. + :type cluster_name: string + :param dataproc_pyspark_properties: Map for the Pig properties. Ideal to put in + default arguments + :type dataproc_pyspark_properties: dict + :param dataproc_pyspark_jars: URIs to jars provisioned in Cloud Storage (example: + for UDFs and libs) and are ideal to put in default arguments. + :type dataproc_pyspark_jars: list + :param gcp_conn_id: The connection ID to use connecting to Google Cloud Platform. + :type gcp_conn_id: string + :param delegate_to: The account to impersonate, if any. + For this to work, the service account making the request must have + domain-wide delegation enabled. + :type delegate_to: string + :param region: The specified region where the dataproc cluster is created. + :type region: string + """ + super(DataProcPySparkOperator, self).__init__(*args, **kwargs) + self.gcp_conn_id = gcp_conn_id + self.delegate_to = delegate_to + self.main = main + self.arguments = arguments + self.archives = archives + self.files = files + self.pyfiles = pyfiles + self.job_name = job_name + self.cluster_name = cluster_name + self.dataproc_properties = dataproc_pyspark_properties + self.dataproc_jars = dataproc_pyspark_jars + self.region = region + + def execute(self, context): + hook = DataProcHook(gcp_conn_id=self.gcp_conn_id, + delegate_to=self.delegate_to) + job = hook.create_job_template(self.task_id, self.cluster_name, "pysparkJob", + self.dataproc_properties) + + job.set_python_main(self.main) + job.add_args(self.arguments) + job.add_jar_file_uris(self.dataproc_jars) + job.add_archive_uris(self.archives) + job.add_file_uris(self.files) + job.add_python_file_uris(self.pyfiles) + job.set_job_name(self.job_name) + + hook.submit(hook.project_id, job.build(), self.region) diff --git a/airflow/contrib/operators/datastore_export_operator.py b/airflow/contrib/operators/datastore_export_operator.py new file mode 100644 index 0000000000000..51e1d06267c79 --- /dev/null +++ b/airflow/contrib/operators/datastore_export_operator.py @@ -0,0 +1,103 @@ +# -*- coding: utf-8 -*- +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +from airflow.contrib.hooks.datastore_hook import DatastoreHook +from airflow.contrib.hooks.gcs_hook import GoogleCloudStorageHook +from airflow.models import BaseOperator +from airflow.utils.decorators import apply_defaults + + +class DatastoreExportOperator(BaseOperator): + """ + Export entities from Google Cloud Datastore to Cloud Storage + + :param bucket: name of the cloud storage bucket to backup data + :type bucket: string + :param namespace: optional namespace path in the specified Cloud Storage bucket + to backup data. If this namespace does not exist in GCS, it will be created. + :type namespace: str + :param datastore_conn_id: the name of the Datastore connection id to use + :type datastore_conn_id: string + :param cloud_storage_conn_id: the name of the cloud storage connection id to force-write + backup + :type cloud_storage_conn_id: string + :param delegate_to: The account to impersonate, if any. + For this to work, the service account making the request must have domain-wide + delegation enabled. + :type delegate_to: string + :param entity_filter: description of what data from the project is included in the export, + refer to https://cloud.google.com/datastore/docs/reference/rest/Shared.Types/EntityFilter + :type entity_filter: dict + :param labels: client-assigned labels for cloud storage + :type labels: dict + :param polling_interval_in_seconds: number of seconds to wait before polling for + execution status again + :type polling_interval_in_seconds: int + :param overwrite_existing: if the storage bucket + namespace is not empty, it will be + emptied prior to exports. This enables overwriting existing backups. + :type overwrite_existing: bool + :param xcom_push: push operation name to xcom for reference + :type xcom_push: bool + """ + + @apply_defaults + def __init__(self, + bucket, + namespace=None, + datastore_conn_id='google_cloud_default', + cloud_storage_conn_id='google_cloud_default', + delegate_to=None, + entity_filter=None, + labels=None, + polling_interval_in_seconds=10, + overwrite_existing=False, + xcom_push=False, + *args, + **kwargs): + super(DatastoreExportOperator, self).__init__(*args, **kwargs) + self.datastore_conn_id = datastore_conn_id + self.cloud_storage_conn_id = cloud_storage_conn_id + self.delegate_to = delegate_to + self.bucket = bucket + self.namespace = namespace + self.entity_filter = entity_filter + self.labels = labels + self.polling_interval_in_seconds = polling_interval_in_seconds + self.overwrite_existing = overwrite_existing + self.xcom_push = xcom_push + + def execute(self, context): + self.log.info('Exporting data to Cloud Storage bucket ' + self.bucket) + + if self.overwrite_existing and self.namespace: + gcs_hook = GoogleCloudStorageHook(self.cloud_storage_conn_id) + objects = gcs_hook.list(self.bucket, prefix=self.namespace) + for o in objects: + gcs_hook.delete(self.bucket, o) + + ds_hook = DatastoreHook(self.datastore_conn_id,self.delegate_to) + result = ds_hook.export_to_storage_bucket(bucket=self.bucket, + namespace=self.namespace, + entity_filter=self.entity_filter, + labels=self.labels) + operation_name = result['name'] + result = ds_hook.poll_operation_until_done(operation_name, + self.polling_interval_in_seconds) + + state = result['metadata']['common']['state'] + if state != 'SUCCESSFUL': + raise AirflowException('Operation failed: result={}'.format(result)) + + if self.xcom_push: + return result diff --git a/airflow/contrib/operators/datastore_import_operator.py b/airflow/contrib/operators/datastore_import_operator.py new file mode 100644 index 0000000000000..d8c42e7dfbcd7 --- /dev/null +++ b/airflow/contrib/operators/datastore_import_operator.py @@ -0,0 +1,93 @@ +# -*- coding: utf-8 -*- +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +from airflow.contrib.hooks.datastore_hook import DatastoreHook +from airflow.models import BaseOperator +from airflow.utils.decorators import apply_defaults + + +class DatastoreImportOperator(BaseOperator): + """ + Import entities from Cloud Storage to Google Cloud Datastore + + :param bucket: container in Cloud Storage to store data + :type bucket: string + :param file: path of the backup metadata file in the specified Cloud Storage bucket. + It should have the extension .overall_export_metadata + :type file: string + :param namespace: optional namespace of the backup metadata file in + the specified Cloud Storage bucket. + :type namespace: str + :param entity_filter: description of what data from the project is included in the export, + refer to https://cloud.google.com/datastore/docs/reference/rest/Shared.Types/EntityFilter + :type entity_filter: dict + :param labels: client-assigned labels for cloud storage + :type labels: dict + :param datastore_conn_id: the name of the connection id to use + :type datastore_conn_id: string + :param delegate_to: The account to impersonate, if any. + For this to work, the service account making the request must have domain-wide + delegation enabled. + :type delegate_to: string + :param polling_interval_in_seconds: number of seconds to wait before polling for + execution status again + :type polling_interval_in_seconds: int + :param xcom_push: push operation name to xcom for reference + :type xcom_push: bool + """ + + @apply_defaults + def __init__(self, + bucket, + file, + namespace=None, + entity_filter=None, + labels=None, + datastore_conn_id='google_cloud_default', + delegate_to=None, + polling_interval_in_seconds=10, + xcom_push=False, + *args, + **kwargs): + super(DatastoreImportOperator, self).__init__(*args, **kwargs) + self.datastore_conn_id = datastore_conn_id + self.delegate_to = delegate_to + self.bucket = bucket + self.file = file + self.namespace = namespace + self.entity_filter = entity_filter + self.labels = labels + self.polling_interval_in_seconds = polling_interval_in_seconds + self.xcom_push = xcom_push + + def execute(self, context): + self.log.info('Importing data from Cloud Storage bucket %s', self.bucket) + ds_hook = DatastoreHook(self.datastore_conn_id, self.delegate_to) + result = ds_hook.import_from_storage_bucket(bucket=self.bucket, + file=self.file, + namespace=self.namespace, + entity_filter=self.entity_filter, + labels=self.labels) + operation_name = result['name'] + result = ds_hook.poll_operation_until_done(operation_name, + self.polling_interval_in_seconds) + + + state = result['metadata']['common']['state'] + if state != 'SUCCESSFUL': + raise AirflowException('Operation failed: result={}'.format(result)) + + if self.xcom_push: + return result + diff --git a/airflow/contrib/operators/druid_operator.py b/airflow/contrib/operators/druid_operator.py new file mode 100644 index 0000000000000..fa8b1e37c6ab8 --- /dev/null +++ b/airflow/contrib/operators/druid_operator.py @@ -0,0 +1,47 @@ +# -*- coding: utf-8 -*- +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import json + +from airflow.hooks.druid_hook import DruidHook +from airflow.models import BaseOperator + + +class DruidOperator(BaseOperator): + """ + Allows to submit a task directly to druid + + :param json_index_file: The filepath to the druid index specification + :type json_index_file: str + :param druid_ingest_conn_id: The connection id of the Druid overlord which accepts index jobs + :type druid_ingest_conn_id: str + """ + template_fields = ('intervals',) + template_ext = ('.json',) + + def __init__( + self, + json_index_file, + druid_ingest_conn_id='druid_ingest_default', + *args, **kwargs): + + super(DruidOperator, self).__init__(*args, **kwargs) + self.conn_id = druid_ingest_conn_id + + with open(json_index_file) as data_file: + self.index_spec = json.load(data_file) + + def execute(self, context): + hook = DruidHook(druid_ingest_conn_id=self.conn_id) + hook.submit_indexing_job(json.dumps(self.index_spec)) diff --git a/airflow/contrib/operators/ecs_operator.py b/airflow/contrib/operators/ecs_operator.py new file mode 100644 index 0000000000000..898a77ab32e2f --- /dev/null +++ b/airflow/contrib/operators/ecs_operator.py @@ -0,0 +1,127 @@ +# -*- coding: utf-8 -*- +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +import sys + +from airflow.exceptions import AirflowException +from airflow.models import BaseOperator +from airflow.utils import apply_defaults + +from airflow.contrib.hooks.aws_hook import AwsHook + + +class ECSOperator(BaseOperator): + """ + Execute a task on AWS EC2 Container Service + + :param task_definition: the task definition name on EC2 Container Service + :type task_definition: str + :param cluster: the cluster name on EC2 Container Service + :type cluster: str + :param: overrides: the same parameter that boto3 will receive: + http://boto3.readthedocs.org/en/latest/reference/services/ecs.html#ECS.Client.run_task + :type: overrides: dict + :param aws_conn_id: connection id of AWS credentials / region name. If None, + credential boto3 strategy will be used (http://boto3.readthedocs.io/en/latest/guide/configuration.html). + :type aws_conn_id: str + :param region_name: region name to use in AWS Hook. Override the region_name in connection (if provided) + """ + + ui_color = '#f0ede4' + client = None + arn = None + template_fields = ('overrides',) + + @apply_defaults + def __init__(self, task_definition, cluster, overrides, + aws_conn_id=None, region_name=None, **kwargs): + super(ECSOperator, self).__init__(**kwargs) + + self.aws_conn_id = aws_conn_id + self.region_name = region_name + self.task_definition = task_definition + self.cluster = cluster + self.overrides = overrides + + self.hook = self.get_hook() + + def execute(self, context): + self.log.info( + 'Running ECS Task - Task definition: %s - on cluster %s', + self.task_definition,self.cluster + ) + self.log.info('ECSOperator overrides: %s', self.overrides) + + self.client = self.hook.get_client_type( + 'ecs', + region_name=self.region_name + ) + + response = self.client.run_task( + cluster=self.cluster, + taskDefinition=self.task_definition, + overrides=self.overrides, + startedBy=self.owner + ) + + failures = response['failures'] + if len(failures) > 0: + raise AirflowException(response) + self.log.info('ECS Task started: %s', response) + + self.arn = response['tasks'][0]['taskArn'] + self._wait_for_task_ended() + + self._check_success_task() + self.log.info('ECS Task has been successfully executed: %s', response) + + def _wait_for_task_ended(self): + waiter = self.client.get_waiter('tasks_stopped') + waiter.config.max_attempts = sys.maxsize # timeout is managed by airflow + waiter.wait( + cluster=self.cluster, + tasks=[self.arn] + ) + + def _check_success_task(self): + response = self.client.describe_tasks( + cluster=self.cluster, + tasks=[self.arn] + ) + self.log.info('ECS Task stopped, check status: %s', response) + + if len(response.get('failures', [])) > 0: + raise AirflowException(response) + + for task in response['tasks']: + containers = task['containers'] + for container in containers: + if container.get('lastStatus') == 'STOPPED' and container['exitCode'] != 0: + raise AirflowException('This task is not in success state {}'.format(task)) + elif container.get('lastStatus') == 'PENDING': + raise AirflowException('This task is still pending {}'.format(task)) + elif 'error' in container.get('reason', '').lower(): + raise AirflowException('This containers encounter an error during launching : {}'. + format(container.get('reason', '').lower())) + + def get_hook(self): + return AwsHook( + aws_conn_id=self.aws_conn_id + ) + + def on_kill(self): + response = self.client.stop_task( + cluster=self.cluster, + task=self.arn, + reason='Task killed by the user') + self.log.info(response) diff --git a/airflow/contrib/operators/emr_add_steps_operator.py b/airflow/contrib/operators/emr_add_steps_operator.py new file mode 100644 index 0000000000000..227474e297dee --- /dev/null +++ b/airflow/contrib/operators/emr_add_steps_operator.py @@ -0,0 +1,58 @@ +# -*- coding: utf-8 -*- +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +from airflow.models import BaseOperator +from airflow.utils import apply_defaults +from airflow.exceptions import AirflowException +from airflow.contrib.hooks.emr_hook import EmrHook + + +class EmrAddStepsOperator(BaseOperator): + """ + An operator that adds steps to an existing EMR job_flow. + + :param job_flow_id: id of the JobFlow to add steps to + :type job_flow_name: str + :param aws_conn_id: aws connection to uses + :type aws_conn_id: str + :param steps: boto3 style steps to be added to the jobflow + :type steps: list + """ + template_fields = ['job_flow_id'] + template_ext = () + ui_color = '#f9c915' + + @apply_defaults + def __init__( + self, + job_flow_id, + aws_conn_id='s3_default', + steps=None, + *args, **kwargs): + super(EmrAddStepsOperator, self).__init__(*args, **kwargs) + steps = steps or [] + self.job_flow_id = job_flow_id + self.aws_conn_id = aws_conn_id + self.steps = steps + + def execute(self, context): + emr = EmrHook(aws_conn_id=self.aws_conn_id).get_conn() + + self.log.info('Adding steps to %s', self.job_flow_id) + response = emr.add_job_flow_steps(JobFlowId=self.job_flow_id, Steps=self.steps) + + if not response['ResponseMetadata']['HTTPStatusCode'] == 200: + raise AirflowException('Adding steps failed: %s' % response) + else: + self.log.info('Steps %s added to JobFlow', response['StepIds']) + return response['StepIds'] diff --git a/airflow/contrib/operators/emr_create_job_flow_operator.py b/airflow/contrib/operators/emr_create_job_flow_operator.py new file mode 100644 index 0000000000000..2544adf51669a --- /dev/null +++ b/airflow/contrib/operators/emr_create_job_flow_operator.py @@ -0,0 +1,63 @@ +# -*- coding: utf-8 -*- +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +from airflow.contrib.hooks.emr_hook import EmrHook +from airflow.models import BaseOperator +from airflow.utils import apply_defaults +from airflow.exceptions import AirflowException + + +class EmrCreateJobFlowOperator(BaseOperator): + """ + Creates an EMR JobFlow, reading the config from the EMR connection. + A dictionary of JobFlow overrides can be passed that override the config from the connection. + + :param aws_conn_id: aws connection to uses + :type aws_conn_id: str + :param emr_conn_id: emr connection to use + :type emr_conn_id: str + :param job_flow_overrides: boto3 style arguments to override emr_connection extra + :type steps: dict + """ + template_fields = [] + template_ext = () + ui_color = '#f9c915' + + @apply_defaults + def __init__( + self, + aws_conn_id='s3_default', + emr_conn_id='emr_default', + job_flow_overrides=None, + *args, **kwargs): + super(EmrCreateJobFlowOperator, self).__init__(*args, **kwargs) + self.aws_conn_id = aws_conn_id + self.emr_conn_id = emr_conn_id + if job_flow_overrides is None: + job_flow_overrides = {} + self.job_flow_overrides = job_flow_overrides + + def execute(self, context): + emr = EmrHook(aws_conn_id=self.aws_conn_id, emr_conn_id=self.emr_conn_id) + + self.log.info( + 'Creating JobFlow using aws-conn-id: %s, emr-conn-id: %s', + self.aws_conn_id, self.emr_conn_id + ) + response = emr.create_job_flow(self.job_flow_overrides) + + if not response['ResponseMetadata']['HTTPStatusCode'] == 200: + raise AirflowException('JobFlow creation failed: %s' % response) + else: + self.log.info('JobFlow with id %s created', response['JobFlowId']) + return response['JobFlowId'] diff --git a/airflow/contrib/operators/emr_terminate_job_flow_operator.py b/airflow/contrib/operators/emr_terminate_job_flow_operator.py new file mode 100644 index 0000000000000..ec29897226364 --- /dev/null +++ b/airflow/contrib/operators/emr_terminate_job_flow_operator.py @@ -0,0 +1,52 @@ +# -*- coding: utf-8 -*- +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +from airflow.models import BaseOperator +from airflow.utils import apply_defaults +from airflow.exceptions import AirflowException +from airflow.contrib.hooks.emr_hook import EmrHook + + +class EmrTerminateJobFlowOperator(BaseOperator): + """ + Operator to terminate EMR JobFlows. + + :param job_flow_id: id of the JobFlow to terminate + :type job_flow_name: str + :param aws_conn_id: aws connection to uses + :type aws_conn_id: str + """ + template_fields = ['job_flow_id'] + template_ext = () + ui_color = '#f9c915' + + @apply_defaults + def __init__( + self, + job_flow_id, + aws_conn_id='s3_default', + *args, **kwargs): + super(EmrTerminateJobFlowOperator, self).__init__(*args, **kwargs) + self.job_flow_id = job_flow_id + self.aws_conn_id = aws_conn_id + + def execute(self, context): + emr = EmrHook(aws_conn_id=self.aws_conn_id).get_conn() + + self.log.info('Terminating JobFlow %s', self.job_flow_id) + response = emr.terminate_job_flows(JobFlowIds=[self.job_flow_id]) + + if not response['ResponseMetadata']['HTTPStatusCode'] == 200: + raise AirflowException('JobFlow termination failed: %s' % response) + else: + self.log.info('JobFlow with id %s terminated', self.job_flow_id) diff --git a/airflow/contrib/operators/file_to_gcs.py b/airflow/contrib/operators/file_to_gcs.py new file mode 100644 index 0000000000000..b9f68f98d3728 --- /dev/null +++ b/airflow/contrib/operators/file_to_gcs.py @@ -0,0 +1,70 @@ +# -*- coding: utf-8 -*- +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +from airflow.contrib.hooks.gcs_hook import GoogleCloudStorageHook +from airflow.models import BaseOperator +from airflow.utils.decorators import apply_defaults + + +class FileToGoogleCloudStorageOperator(BaseOperator): + """ + Uploads a file to Google Cloud Storage + + :param src: Path to the local file + :type src: string + :param dst: Destination path within the specified bucket + :type dst: string + :param bucket: The bucket to upload to + :type bucket: string + :param google_cloud_storage_conn_id: The Airflow connection ID to upload with + :type google_cloud_storage_conn_id: string + :param mime_type: The mime-type string + :type mime_type: string + :param delegate_to: The account to impersonate, if any + :type delegate_to: string + """ + template_fields = ('src', 'dst', 'bucket') + + @apply_defaults + def __init__(self, + src, + dst, + bucket, + google_cloud_storage_conn_id='google_cloud_storage_default', + mime_type='application/octet-stream', + delegate_to=None, + *args, + **kwargs): + super(FileToGoogleCloudStorageOperator, self).__init__(*args, **kwargs) + self.src = src + self.dst = dst + self.bucket = bucket + self.google_cloud_storage_conn_id = google_cloud_storage_conn_id + self.mime_type = mime_type + self.delegate_to = delegate_to + + def execute(self, context): + """ + Uploads the file to Google cloud storage + """ + hook = GoogleCloudStorageHook( + google_cloud_storage_conn_id=self.google_cloud_storage_conn_id, + delegate_to=self.delegate_to) + + hook.upload( + bucket=self.bucket, + object=self.dst, + mime_type=self.mime_type, + filename=self.src) diff --git a/airflow/contrib/operators/file_to_wasb.py b/airflow/contrib/operators/file_to_wasb.py new file mode 100644 index 0000000000000..3478dd3a65202 --- /dev/null +++ b/airflow/contrib/operators/file_to_wasb.py @@ -0,0 +1,57 @@ +# -*- coding: utf-8 -*- +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +from airflow.contrib.hooks.wasb_hook import WasbHook +from airflow.models import BaseOperator +from airflow.utils.decorators import apply_defaults + + +class FileToWasbOperator(BaseOperator): + """ + Uploads a file to Azure Blob Storage. + + :param file_path: Path to the file to load. + :type file_path: str + :param container_name: Name of the container. + :type container_name: str + :param blob_name: Name of the blob. + :type blob_name: str + :param wasb_conn_id: Reference to the wasb connection. + :type wasb_conn_id: str + :param load_options: Optional keyword arguments that + `WasbHook.load_file()` takes. + :type load_options: dict + """ + template_fields = ('file_path', 'container_name', 'blob_name') + + @apply_defaults + def __init__(self, file_path, container_name, blob_name, + wasb_conn_id='wasb_default', load_options=None, *args, + **kwargs): + super(FileToWasbOperator, self).__init__(*args, **kwargs) + if load_options is None: + load_options = {} + self.file_path = file_path + self.container_name = container_name + self.blob_name = blob_name + self.wasb_conn_id = wasb_conn_id + self.load_options = load_options + + def execute(self, context): + """Upload a file to Azure Blob Storage.""" + hook = WasbHook(wasb_conn_id=self.wasb_conn_id) + self.log.info( + 'Uploading {self.file_path} to wasb://{self.container_name} as {self.blob_name}'.format(**locals()) + ) + hook.load_file(self.file_path, self.container_name, self.blob_name, **self.load_options) diff --git a/airflow/contrib/operators/fs_operator.py b/airflow/contrib/operators/fs_operator.py new file mode 100644 index 0000000000000..e7640c893c9ba --- /dev/null +++ b/airflow/contrib/operators/fs_operator.py @@ -0,0 +1,56 @@ +# -*- coding: utf-8 -*- +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +from os import walk + +from airflow.operators.sensors import BaseSensorOperator +from airflow.contrib.hooks.fs_hook import FSHook +from airflow.utils.decorators import apply_defaults + + +class FileSensor(BaseSensorOperator): + """ + Waits for a file or folder to land in a filesystem + + :param fs_conn_id: reference to the File (path) + connection id + :type fs_conn_id: string + :param filepath: File or folder name (relative to + the base path set within the connection) + :type fs_conn_id: string + """ + template_fields = ('filepath',) + ui_color = '#91818a' + + @apply_defaults + def __init__( + self, + filepath, + fs_conn_id='fs_default2', + *args, **kwargs): + super(FileSensor, self).__init__(*args, **kwargs) + self.filepath = filepath + self.fs_conn_id = fs_conn_id + + def poke(self, context): + hook = FSHook(self.fs_conn_id) + basepath = hook.get_path() + full_path = "/".join([basepath, self.filepath]) + self.log.info('Poking for file {full_path}'.format(**locals())) + try: + files = [f for f in walk(full_path)] + except: + return False + return True diff --git a/airflow/contrib/operators/gcs_download_operator.py b/airflow/contrib/operators/gcs_download_operator.py new file mode 100644 index 0000000000000..6f4783306c0ba --- /dev/null +++ b/airflow/contrib/operators/gcs_download_operator.py @@ -0,0 +1,78 @@ +# -*- coding: utf-8 -*- +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import sys + +from airflow.contrib.hooks.gcs_hook import GoogleCloudStorageHook +from airflow.models import BaseOperator +from airflow.utils.decorators import apply_defaults + + +class GoogleCloudStorageDownloadOperator(BaseOperator): + """ + Downloads a file from Google Cloud Storage. + + :param bucket: The Google cloud storage bucket where the object is. + :type bucket: string + :param object: The name of the object to download in the Google cloud + storage bucket. + :type object: string + :param filename: The file path on the local file system (where the + operator is being executed) that the file should be downloaded to. + If false, the downloaded data will not be stored on the local file + system. + :type filename: string + :param store_to_xcom_key: If this param is set, the operator will push + the contents of the downloaded file to XCom with the key set in this + parameter. If false, the downloaded data will not be pushed to XCom. + :type store_to_xcom_key: string + :param google_cloud_storage_conn_id: The connection ID to use when + connecting to Google cloud storage. + :type google_cloud_storage_conn_id: string + :param delegate_to: The account to impersonate, if any. + For this to work, the service account making the request must have domain-wide delegation enabled. + :type delegate_to: string + """ + template_fields = ('bucket', 'object', 'filename', 'store_to_xcom_key',) + ui_color = '#f0eee4' + + @apply_defaults + def __init__(self, + bucket, + object, + filename=False, + store_to_xcom_key=False, + google_cloud_storage_conn_id='google_cloud_storage_default', + delegate_to=None, + *args, + **kwargs): + super(GoogleCloudStorageDownloadOperator, self).__init__(*args, **kwargs) + self.bucket = bucket + self.object = object + self.filename = filename + self.store_to_xcom_key = store_to_xcom_key + self.google_cloud_storage_conn_id = google_cloud_storage_conn_id + self.delegate_to = delegate_to + + def execute(self, context): + self.log.info('Executing download: %s, %s, %s', self.bucket, self.object, self.filename) + hook = GoogleCloudStorageHook(google_cloud_storage_conn_id=self.google_cloud_storage_conn_id, + delegate_to=self.delegate_to) + file_bytes = hook.download(self.bucket, self.object, self.filename) + if self.store_to_xcom_key: + if sys.getsizeof(file_bytes) < 48000: + context['ti'].xcom_push(key=self.store_to_xcom_key, value=file_bytes) + else: + raise RuntimeError('The size of the downloaded file is too large to push to XCom!') + self.log.info(file_bytes) diff --git a/airflow/contrib/operators/gcs_to_bq.py b/airflow/contrib/operators/gcs_to_bq.py new file mode 100644 index 0000000000000..730a3bc631c66 --- /dev/null +++ b/airflow/contrib/operators/gcs_to_bq.py @@ -0,0 +1,196 @@ +# -*- coding: utf-8 -*- +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import json + +from airflow.contrib.hooks.gcs_hook import GoogleCloudStorageHook +from airflow.contrib.hooks.bigquery_hook import BigQueryHook +from airflow.models import BaseOperator +from airflow.utils.decorators import apply_defaults + + +class GoogleCloudStorageToBigQueryOperator(BaseOperator): + """ + Loads files from Google cloud storage into BigQuery. + """ + template_fields = ('bucket', 'source_objects', + 'schema_object', 'destination_project_dataset_table') + template_ext = ('.sql',) + ui_color = '#f0eee4' + + @apply_defaults + def __init__( + self, + bucket, + source_objects, + destination_project_dataset_table, + schema_fields=None, + schema_object=None, + source_format='CSV', + create_disposition='CREATE_IF_NEEDED', + skip_leading_rows=0, + write_disposition='WRITE_EMPTY', + field_delimiter=',', + max_bad_records=0, + quote_character=None, + allow_quoted_newlines=False, + allow_jagged_rows=False, + max_id_key=None, + bigquery_conn_id='bigquery_default', + google_cloud_storage_conn_id='google_cloud_storage_default', + delegate_to=None, + schema_update_options=(), + src_fmt_configs={}, + *args, + **kwargs): + """ + The schema to be used for the BigQuery table may be specified in one of + two ways. You may either directly pass the schema fields in, or you may + point the operator to a Google cloud storage object name. The object in + Google cloud storage must be a JSON file with the schema fields in it. + + :param bucket: The bucket to load from. + :type bucket: string + :param source_objects: List of Google cloud storage URIs to load from. + If source_format is 'DATASTORE_BACKUP', the list must only contain a single URI. + :type object: list + :param destination_project_dataset_table: The dotted (.).
+ BigQuery table to load data into. If is not included, project will + be the project defined in the connection json. + :type destination_project_dataset_table: string + :param schema_fields: If set, the schema field list as defined here: + https://cloud.google.com/bigquery/docs/reference/v2/jobs#configuration.load + Should not be set when source_format is 'DATASTORE_BACKUP'. + :type schema_fields: list + :param schema_object: If set, a GCS object path pointing to a .json file that + contains the schema for the table. + :param schema_object: string + :param source_format: File format to export. + :type source_format: string + :param create_disposition: The create disposition if the table doesn't exist. + :type create_disposition: string + :param skip_leading_rows: Number of rows to skip when loading from a CSV. + :type skip_leading_rows: int + :param write_disposition: The write disposition if the table already exists. + :type write_disposition: string + :param field_delimiter: The delimiter to use when loading from a CSV. + :type field_delimiter: string + :param max_bad_records: The maximum number of bad records that BigQuery can + ignore when running the job. + :type max_bad_records: int + :param quote_character: The value that is used to quote data sections in a CSV file. + :type quote_character: string + :param allow_quoted_newlines: Whether to allow quoted newlines (true) or not (false). + :type allow_quoted_newlines: boolean + :param allow_jagged_rows: Accept rows that are missing trailing optional columns. + The missing values are treated as nulls. If false, records with missing trailing columns + are treated as bad records, and if there are too many bad records, an invalid error is + returned in the job result. Only applicable to CSV, ignored for other formats. + :type allow_jagged_rows: bool + :param max_id_key: If set, the name of a column in the BigQuery table + that's to be loaded. Thsi will be used to select the MAX value from + BigQuery after the load occurs. The results will be returned by the + execute() command, which in turn gets stored in XCom for future + operators to use. This can be helpful with incremental loads--during + future executions, you can pick up from the max ID. + :type max_id_key: string + :param bigquery_conn_id: Reference to a specific BigQuery hook. + :type bigquery_conn_id: string + :param google_cloud_storage_conn_id: Reference to a specific Google + cloud storage hook. + :type google_cloud_storage_conn_id: string + :param delegate_to: The account to impersonate, if any. For this to + work, the service account making the request must have domain-wide + delegation enabled. + :type delegate_to: string + :param schema_update_options: Allows the schema of the desitination + table to be updated as a side effect of the load job. + :type schema_update_options: list + :param src_fmt_configs: configure optional fields specific to the source format + :type src_fmt_configs: dict + """ + super(GoogleCloudStorageToBigQueryOperator, self).__init__(*args, **kwargs) + + # GCS config + self.bucket = bucket + self.source_objects = source_objects + self.schema_object = schema_object + + # BQ config + self.destination_project_dataset_table = destination_project_dataset_table + self.schema_fields = schema_fields + self.source_format = source_format + self.create_disposition = create_disposition + self.skip_leading_rows = skip_leading_rows + self.write_disposition = write_disposition + self.field_delimiter = field_delimiter + self.max_bad_records = max_bad_records + self.quote_character = quote_character + self.allow_quoted_newlines = allow_quoted_newlines + self.allow_jagged_rows = allow_jagged_rows + + self.max_id_key = max_id_key + self.bigquery_conn_id = bigquery_conn_id + self.google_cloud_storage_conn_id = google_cloud_storage_conn_id + self.delegate_to = delegate_to + + self.schema_update_options = schema_update_options + self.src_fmt_configs = src_fmt_configs + + def execute(self, context): + bq_hook = BigQueryHook(bigquery_conn_id=self.bigquery_conn_id, + delegate_to=self.delegate_to) + + if not self.schema_fields and self.schema_object \ + and self.source_format != 'DATASTORE_BACKUP': + gcs_hook = GoogleCloudStorageHook( + google_cloud_storage_conn_id=self.google_cloud_storage_conn_id, + delegate_to=self.delegate_to) + schema_fields = json.loads(gcs_hook.download( + self.bucket, + self.schema_object).decode("utf-8")) + else: + schema_fields = self.schema_fields + + source_uris = ['gs://{}/{}'.format(self.bucket, source_object) + for source_object in self.source_objects] + conn = bq_hook.get_conn() + cursor = conn.cursor() + cursor.run_load( + destination_project_dataset_table=self.destination_project_dataset_table, + schema_fields=schema_fields, + source_uris=source_uris, + source_format=self.source_format, + create_disposition=self.create_disposition, + skip_leading_rows=self.skip_leading_rows, + write_disposition=self.write_disposition, + field_delimiter=self.field_delimiter, + max_bad_records=self.max_bad_records, + quote_character=self.quote_character, + allow_quoted_newlines=self.allow_quoted_newlines, + allow_jagged_rows=self.allow_jagged_rows, + schema_update_options=self.schema_update_options, + src_fmt_configs=self.src_fmt_configs) + + if self.max_id_key: + cursor.execute('SELECT MAX({}) FROM {}'.format( + self.max_id_key, + self.destination_project_dataset_table)) + row = cursor.fetchone() + max_id = row[0] if row[0] else 0 + self.log.info( + 'Loaded BQ data with max %s.%s=%s', + self.destination_project_dataset_table, self.max_id_key, max_id + ) + return max_id diff --git a/airflow/contrib/operators/hipchat_operator.py b/airflow/contrib/operators/hipchat_operator.py new file mode 100644 index 0000000000000..d82ad6108a55b --- /dev/null +++ b/airflow/contrib/operators/hipchat_operator.py @@ -0,0 +1,130 @@ +# -*- coding: utf-8 -*- +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from builtins import str + +from airflow.utils.decorators import apply_defaults +from airflow.models import BaseOperator +from airflow.exceptions import AirflowException +import requests +import json + + +class HipChatAPIOperator(BaseOperator): + """ + Base HipChat Operator. + All derived HipChat operators reference from HipChat's official REST API documentation + at https://www.hipchat.com/docs/apiv2. Before using any HipChat API operators you need + to get an authentication token at https://www.hipchat.com/docs/apiv2/auth. + In the future additional HipChat operators will be derived from this class as well. + + :param token: HipChat REST API authentication token + :type token: str + :param base_url: HipChat REST API base url. + :type base_url: str + """ + @apply_defaults + def __init__(self, + token, + base_url='https://api.hipchat.com/v2', + *args, + **kwargs): + super(HipChatAPIOperator, self).__init__(*args, **kwargs) + self.token = token + self.base_url = base_url + self.method = None + self.url = None + self.body = None + + def prepare_request(self): + """ + Used by the execute function. Set the request method, url, and body of HipChat's + REST API call. + Override in child class. Each HipChatAPI child operator is responsible for having + a prepare_request method call which sets self.method, self.url, and self.body. + """ + pass + + def execute(self, context): + self.prepare_request() + + response = requests.request(self.method, + self.url, + headers={ + 'Content-Type': 'application/json', + 'Authorization': 'Bearer %s' % self.token}, + data=self.body) + if response.status_code >= 400: + self.log.error('HipChat API call failed: %s %s', + response.status_code, response.reason) + raise AirflowException('HipChat API call failed: %s %s' % + (response.status_code, response.reason)) + + +class HipChatAPISendRoomNotificationOperator(HipChatAPIOperator): + """ + Send notification to a specific HipChat room. + More info: https://www.hipchat.com/docs/apiv2/method/send_room_notification + + :param room_id: Room in which to send notification on HipChat + :type room_id: str + :param message: The message body + :type message: str + :param frm: Label to be shown in addition to sender's name + :type frm: str + :param message_format: How the notification is rendered: html or text + :type message_format: str + :param color: Background color of the msg: yellow, green, red, purple, gray, or random + :type color: str + :param attach_to: The message id to attach this notification to + :type attach_to: str + :param notify: Whether this message should trigger a user notification + :type notify: bool + :param card: HipChat-defined card object + :type card: dict + """ + template_fields = ('token', 'room_id', 'message') + ui_color = '#2980b9' + + @apply_defaults + def __init__(self, room_id, message, *args, **kwargs): + super(HipChatAPISendRoomNotificationOperator, self).__init__(*args, **kwargs) + self.room_id = room_id + self.message = message + default_options = { + 'message_format': 'html', + 'color': 'yellow', + 'frm': 'airflow', + 'attach_to': None, + 'notify': False, + 'card': None + } + for (prop, default) in default_options.items(): + setattr(self, prop, kwargs.get(prop, default)) + + def prepare_request(self): + params = { + 'message': self.message, + 'message_format': self.message_format, + 'color': self.color, + 'from': self.frm, + 'attach_to': self.attach_to, + 'notify': self.notify, + 'card': self.card + } + + self.method = 'POST' + self.url = '%s/room/%s/notification' % (self.base_url, self.room_id) + self.body = json.dumps(dict( + (str(k), str(v)) for k, v in params.items() if v)) diff --git a/airflow/contrib/operators/hive_to_dynamodb.py b/airflow/contrib/operators/hive_to_dynamodb.py new file mode 100644 index 0000000000000..55eca4597d14b --- /dev/null +++ b/airflow/contrib/operators/hive_to_dynamodb.py @@ -0,0 +1,101 @@ +# -*- coding: utf-8 -*- +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import json +import logging + +from airflow.contrib.hooks.aws_dynamodb_hook import AwsDynamoDBHook +from airflow.hooks.hive_hooks import HiveServer2Hook +from airflow.models import BaseOperator +from airflow.utils.decorators import apply_defaults + + +class HiveToDynamoDBTransferOperator(BaseOperator): + """ + Moves data from Hive to DynamoDB, note that for now the data is loaded + into memory before being pushed to DynamoDB, so this operator should + be used for smallish amount of data. + + :param sql: SQL query to execute against the hive database + :type sql: str + :param table_name: target DynamoDB table + :type table_name: str + :param table_keys: partition key and sort key + :type table_keys: list + :param pre_process: implement pre-processing of source data + :type pre_process: function + :param pre_process_args: list of pre_process function arguments + :type pre_process_args: list + :param pre_process_kwargs: dict of pre_process function arguments + :type pre_process_kwargs: dict + :param region_name: aws region name (example: us-east-1) + :type region_name: str + :param schema: hive database schema + :type schema: str + :param hiveserver2_conn_id: source hive connection + :type hiveserver2_conn_id: str + :param aws_conn_id: aws connection + :type aws_conn_id: str + """ + + template_fields = ('sql',) + template_ext = ('.sql',) + ui_color = '#a0e08c' + + @apply_defaults + def __init__( + self, + sql, + table_name, + table_keys, + pre_process=None, + pre_process_args=None, + pre_process_kwargs=None, + region_name=None, + schema='default', + hiveserver2_conn_id='hiveserver2_default', + aws_conn_id='aws_default', + *args, **kwargs): + super(HiveToDynamoDBTransferOperator, self).__init__(*args, **kwargs) + self.sql = sql + self.table_name = table_name + self.table_keys = table_keys + self.pre_process = pre_process + self.pre_process_args = pre_process_args + self.pre_process_kwargs = pre_process_kwargs + self.region_name = region_name + self.schema = schema + self.hiveserver2_conn_id = hiveserver2_conn_id + self.aws_conn_id = aws_conn_id + + def execute(self, context): + hive = HiveServer2Hook(hiveserver2_conn_id=self.hiveserver2_conn_id) + + logging.info('Extracting data from Hive') + logging.info(self.sql) + + data = hive.get_pandas_df(self.sql, schema=self.schema) + dynamodb = AwsDynamoDBHook(aws_conn_id=self.aws_conn_id, + table_name=self.table_name, table_keys=self.table_keys, region_name=self.region_name) + + logging.info('Inserting rows into dynamodb') + + if self.pre_process is None: + dynamodb.write_batch_data( + json.loads(data.to_json(orient='records'))) + else: + dynamodb.write_batch_data( + self.pre_process(data=data, args=self.pre_process_args, kwargs=self.pre_process_kwargs)) + + logging.info('Done.') diff --git a/airflow/contrib/operators/jira_operator.py b/airflow/contrib/operators/jira_operator.py new file mode 100644 index 0000000000000..6623b1ca0ea83 --- /dev/null +++ b/airflow/contrib/operators/jira_operator.py @@ -0,0 +1,89 @@ +# -*- coding: utf-8 -*- +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + + +from airflow.contrib.hooks.jira_hook import JIRAError +from airflow.contrib.hooks.jira_hook import JiraHook +from airflow.exceptions import AirflowException +from airflow.models import BaseOperator +from airflow.utils.decorators import apply_defaults + + +class JiraOperator(BaseOperator): + """ + JiraOperator to interact and perform action on Jira issue tracking system. + This operator is designed to use Jira Python SDK: http://jira.readthedocs.io + + :param jira_conn_id: reference to a pre-defined Jira Connection + :type jira_conn_id: str + :param jira_method: method name from Jira Python SDK to be called + :type jira_method: str + :param jira_method_args: required method parameters for the jira_method + :type jira_method_args: dict + :param result_processor: function to further process the response from Jira + :type result_processor: function + :param get_jira_resource_method: function or operator to get jira resource + on which the provided jira_method will be executed + :type get_jira_resource_method: function + """ + + template_fields = ("jira_method_args",) + + @apply_defaults + def __init__(self, + jira_conn_id='jira_default', + jira_method=None, + jira_method_args=None, + result_processor=None, + get_jira_resource_method=None, + *args, + **kwargs): + super(JiraOperator, self).__init__(*args, **kwargs) + self.jira_conn_id = jira_conn_id + self.method_name = jira_method + self.jira_method_args = jira_method_args + self.result_processor = result_processor + self.get_jira_resource_method = get_jira_resource_method + + def execute(self, context): + try: + if self.get_jira_resource_method is not None: + # if get_jira_resource_method is provided, jira_method will be executed on + # resource returned by executing the get_jira_resource_method. + # This makes all the provided methods of JIRA sdk accessible and usable + # directly at the JiraOperator without additional wrappers. + # ref: http://jira.readthedocs.io/en/latest/api.html + if isinstance(self.get_jira_resource_method, JiraOperator): + resource = self.get_jira_resource_method.execute(**context) + else: + resource = self.get_jira_resource_method(**context) + else: + # Default method execution is on the top level jira client resource + hook = JiraHook(jira_conn_id=self.jira_conn_id) + resource = hook.client + + # Current Jira-Python SDK (1.0.7) has issue with pickling the jira response. + # ex: self.xcom_push(context, key='operator_response', value=jira_response) + # This could potentially throw error if jira_result is not picklable + jira_result = getattr(resource, self.method_name)(**self.jira_method_args) + if self.result_processor: + return self.result_processor(context, jira_result) + + return jira_result + + except JIRAError as jira_error: + raise AirflowException("Failed to execute jiraOperator, error: %s" + % str(jira_error)) + except Exception as e: + raise AirflowException("Jira operator error: %s" % str(e)) diff --git a/airflow/contrib/operators/mlengine_operator.py b/airflow/contrib/operators/mlengine_operator.py new file mode 100644 index 0000000000000..4d8943b184d04 --- /dev/null +++ b/airflow/contrib/operators/mlengine_operator.py @@ -0,0 +1,562 @@ +# +# Licensed to the Apache Software Foundation (ASF) under one or more +# contributor license agreements. See the NOTICE file distributed with +# this work for additional information regarding copyright ownership. +# The ASF licenses this file to You under the Apache License, Version 2.0 +# (the 'License'); you may not use this file except in compliance with +# the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an 'AS IS' BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +import re + +from airflow import settings +from airflow.contrib.hooks.gcp_mlengine_hook import MLEngineHook +from airflow.exceptions import AirflowException +from airflow.operators import BaseOperator +from airflow.utils.decorators import apply_defaults +from apiclient import errors + +from airflow.utils.log.logging_mixin import LoggingMixin + +log = LoggingMixin().log + + +def _create_prediction_input(project_id, + region, + data_format, + input_paths, + output_path, + model_name=None, + version_name=None, + uri=None, + max_worker_count=None, + runtime_version=None): + """ + Create the batch prediction input from the given parameters. + + Args: + A subset of arguments documented in __init__ method of class + MLEngineBatchPredictionOperator + + Returns: + A dictionary representing the predictionInput object as documented + in https://cloud.google.com/ml-engine/reference/rest/v1/projects.jobs. + + Raises: + ValueError: if a unique model/version origin cannot be determined. + """ + prediction_input = { + 'dataFormat': data_format, + 'inputPaths': input_paths, + 'outputPath': output_path, + 'region': region + } + + if uri: + if model_name or version_name: + log.error( + 'Ambiguous model origin: Both uri and model/version name are provided.' + ) + raise ValueError('Ambiguous model origin.') + prediction_input['uri'] = uri + elif model_name: + origin_name = 'projects/{}/models/{}'.format(project_id, model_name) + if not version_name: + prediction_input['modelName'] = origin_name + else: + prediction_input['versionName'] = \ + origin_name + '/versions/{}'.format(version_name) + else: + log.error( + 'Missing model origin: Batch prediction expects a model, ' + 'a model & version combination, or a URI to savedModel.') + raise ValueError('Missing model origin.') + + if max_worker_count: + prediction_input['maxWorkerCount'] = max_worker_count + if runtime_version: + prediction_input['runtimeVersion'] = runtime_version + + return prediction_input + + +def _normalize_mlengine_job_id(job_id): + """ + Replaces invalid MLEngine job_id characters with '_'. + + This also adds a leading 'z' in case job_id starts with an invalid + character. + + Args: + job_id: A job_id str that may have invalid characters. + + Returns: + A valid job_id representation. + """ + match = re.search(r'\d', job_id) + if match and match.start() is 0: + job_id = 'z_{}'.format(job_id) + return re.sub('[^0-9a-zA-Z]+', '_', job_id) + + +class MLEngineBatchPredictionOperator(BaseOperator): + """ + Start a Google Cloud ML Engine prediction job. + + NOTE: For model origin, users should consider exactly one from the + three options below: + 1. Populate 'uri' field only, which should be a GCS location that + points to a tensorflow savedModel directory. + 2. Populate 'model_name' field only, which refers to an existing + model, and the default version of the model will be used. + 3. Populate both 'model_name' and 'version_name' fields, which + refers to a specific version of a specific model. + + In options 2 and 3, both model and version name should contain the + minimal identifier. For instance, call + MLEngineBatchPredictionOperator( + ..., + model_name='my_model', + version_name='my_version', + ...) + if the desired model version is + "projects/my_project/models/my_model/versions/my_version". + + + :param project_id: The Google Cloud project name where the + prediction job is submitted. + :type project_id: string + + :param job_id: A unique id for the prediction job on Google Cloud + ML Engine. + :type job_id: string + + :param data_format: The format of the input data. + It will default to 'DATA_FORMAT_UNSPECIFIED' if is not provided + or is not one of ["TEXT", "TF_RECORD", "TF_RECORD_GZIP"]. + :type data_format: string + + :param input_paths: A list of GCS paths of input data for batch + prediction. Accepting wildcard operator *, but only at the end. + :type input_paths: list of string + + :param output_path: The GCS path where the prediction results are + written to. + :type output_path: string + + :param region: The Google Compute Engine region to run the + prediction job in.: + :type region: string + + :param model_name: The Google Cloud ML Engine model to use for prediction. + If version_name is not provided, the default version of this + model will be used. + Should not be None if version_name is provided. + Should be None if uri is provided. + :type model_name: string + + :param version_name: The Google Cloud ML Engine model version to use for + prediction. + Should be None if uri is provided. + :type version_name: string + + :param uri: The GCS path of the saved model to use for prediction. + Should be None if model_name is provided. + It should be a GCS path pointing to a tensorflow SavedModel. + :type uri: string + + :param max_worker_count: The maximum number of workers to be used + for parallel processing. Defaults to 10 if not specified. + :type max_worker_count: int + + :param runtime_version: The Google Cloud ML Engine runtime version to use + for batch prediction. + :type runtime_version: string + + :param gcp_conn_id: The connection ID used for connection to Google + Cloud Platform. + :type gcp_conn_id: string + + :param delegate_to: The account to impersonate, if any. + For this to work, the service account making the request must + have doamin-wide delegation enabled. + :type delegate_to: string + + Raises: + ValueError: if a unique model/version origin cannot be determined. + """ + + template_fields = [ + "prediction_job_request", + ] + + @apply_defaults + def __init__(self, + project_id, + job_id, + region, + data_format, + input_paths, + output_path, + model_name=None, + version_name=None, + uri=None, + max_worker_count=None, + runtime_version=None, + gcp_conn_id='google_cloud_default', + delegate_to=None, + *args, + **kwargs): + super(MLEngineBatchPredictionOperator, self).__init__(*args, **kwargs) + + self.project_id = project_id + self.gcp_conn_id = gcp_conn_id + self.delegate_to = delegate_to + + try: + prediction_input = _create_prediction_input( + project_id, region, data_format, input_paths, output_path, + model_name, version_name, uri, max_worker_count, + runtime_version) + except ValueError as e: + self.log.error( + 'Cannot create batch prediction job request due to: %s', + e + ) + raise + + self.prediction_job_request = { + 'jobId': _normalize_mlengine_job_id(job_id), + 'predictionInput': prediction_input + } + + def execute(self, context): + hook = MLEngineHook(self.gcp_conn_id, self.delegate_to) + + def check_existing_job(existing_job): + return existing_job.get('predictionInput', None) == \ + self.prediction_job_request['predictionInput'] + try: + finished_prediction_job = hook.create_job( + self.project_id, + self.prediction_job_request, + check_existing_job) + except errors.HttpError: + raise + + if finished_prediction_job['state'] != 'SUCCEEDED': + self.log.error( + 'Batch prediction job failed: %s', + str(finished_prediction_job)) + raise RuntimeError(finished_prediction_job['errorMessage']) + + return finished_prediction_job['predictionOutput'] + + +class MLEngineModelOperator(BaseOperator): + """ + Operator for managing a Google Cloud ML Engine model. + + :param project_id: The Google Cloud project name to which MLEngine + model belongs. + :type project_id: string + + :param model: A dictionary containing the information about the model. + If the `operation` is `create`, then the `model` parameter should + contain all the information about this model such as `name`. + + If the `operation` is `get`, the `model` parameter + should contain the `name` of the model. + :type model: dict + + :param operation: The operation to perform. Available operations are: + 'create': Creates a new model as provided by the `model` parameter. + 'get': Gets a particular model where the name is specified in `model`. + + :param gcp_conn_id: The connection ID to use when fetching connection info. + :type gcp_conn_id: string + + :param delegate_to: The account to impersonate, if any. + For this to work, the service account making the request must have + domain-wide delegation enabled. + :type delegate_to: string + """ + + template_fields = [ + '_model', + ] + + @apply_defaults + def __init__(self, + project_id, + model, + operation='create', + gcp_conn_id='google_cloud_default', + delegate_to=None, + *args, + **kwargs): + super(MLEngineModelOperator, self).__init__(*args, **kwargs) + self._project_id = project_id + self._model = model + self._operation = operation + self._gcp_conn_id = gcp_conn_id + self._delegate_to = delegate_to + + def execute(self, context): + hook = MLEngineHook( + gcp_conn_id=self._gcp_conn_id, delegate_to=self._delegate_to) + if self._operation == 'create': + return hook.create_model(self._project_id, self._model) + elif self._operation == 'get': + return hook.get_model(self._project_id, self._model['name']) + else: + raise ValueError('Unknown operation: {}'.format(self._operation)) + + +class MLEngineVersionOperator(BaseOperator): + """ + Operator for managing a Google Cloud ML Engine version. + + :param project_id: The Google Cloud project name to which MLEngine + model belongs. + :type project_id: string + + :param model_name: The name of the Google Cloud ML Engine model that the version + belongs to. + :type model_name: string + + :param version_name: A name to use for the version being operated upon. If + not None and the `version` argument is None or does not have a value for + the `name` key, then this will be populated in the payload for the + `name` key. + :type version_name: string + + :param version: A dictionary containing the information about the version. + If the `operation` is `create`, `version` should contain all the + information about this version such as name, and deploymentUrl. + If the `operation` is `get` or `delete`, the `version` parameter + should contain the `name` of the version. + If it is None, the only `operation` possible would be `list`. + :type version: dict + + :param operation: The operation to perform. Available operations are: + 'create': Creates a new version in the model specified by `model_name`, + in which case the `version` parameter should contain all the + information to create that version + (e.g. `name`, `deploymentUrl`). + 'get': Gets full information of a particular version in the model + specified by `model_name`. + The name of the version should be specified in the `version` + parameter. + + 'list': Lists all available versions of the model specified + by `model_name`. + + 'delete': Deletes the version specified in `version` parameter from the + model specified by `model_name`). + The name of the version should be specified in the `version` + parameter. + :type operation: string + + :param gcp_conn_id: The connection ID to use when fetching connection info. + :type gcp_conn_id: string + + :param delegate_to: The account to impersonate, if any. + For this to work, the service account making the request must have + domain-wide delegation enabled. + :type delegate_to: string + """ + + template_fields = [ + '_model_name', + '_version_name', + '_version', + ] + + @apply_defaults + def __init__(self, + project_id, + model_name, + version_name=None, + version=None, + operation='create', + gcp_conn_id='google_cloud_default', + delegate_to=None, + *args, + **kwargs): + + super(MLEngineVersionOperator, self).__init__(*args, **kwargs) + self._project_id = project_id + self._model_name = model_name + self._version_name = version_name + self._version = version or {} + self._operation = operation + self._gcp_conn_id = gcp_conn_id + self._delegate_to = delegate_to + + def execute(self, context): + if 'name' not in self._version: + self._version['name'] = self._version_name + + hook = MLEngineHook( + gcp_conn_id=self._gcp_conn_id, delegate_to=self._delegate_to) + + if self._operation == 'create': + assert self._version is not None + return hook.create_version(self._project_id, self._model_name, + self._version) + elif self._operation == 'set_default': + return hook.set_default_version( + self._project_id, self._model_name, + self._version['name']) + elif self._operation == 'list': + return hook.list_versions(self._project_id, self._model_name) + elif self._operation == 'delete': + return hook.delete_version(self._project_id, self._model_name, + self._version['name']) + else: + raise ValueError('Unknown operation: {}'.format(self._operation)) + + +class MLEngineTrainingOperator(BaseOperator): + """ + Operator for launching a MLEngine training job. + + :param project_id: The Google Cloud project name within which MLEngine + training job should run. This field could be templated. + :type project_id: string + + :param job_id: A unique templated id for the submitted Google MLEngine + training job. + :type job_id: string + + :param package_uris: A list of package locations for MLEngine training job, + which should include the main training program + any additional + dependencies. + :type package_uris: string + + :param training_python_module: The Python module name to run within MLEngine + training job after installing 'package_uris' packages. + :type training_python_module: string + + :param training_args: A list of templated command line arguments to pass to + the MLEngine training program. + :type training_args: string + + :param region: The Google Compute Engine region to run the MLEngine training + job in. This field could be templated. + :type region: string + + :param scale_tier: Resource tier for MLEngine training job. + :type scale_tier: string + + :param gcp_conn_id: The connection ID to use when fetching connection info. + :type gcp_conn_id: string + + :param delegate_to: The account to impersonate, if any. + For this to work, the service account making the request must have + domain-wide delegation enabled. + :type delegate_to: string + + :param mode: Can be one of 'DRY_RUN'/'CLOUD'. In 'DRY_RUN' mode, no real + training job will be launched, but the MLEngine training job request + will be printed out. In 'CLOUD' mode, a real MLEngine training job + creation request will be issued. + :type mode: string + """ + + template_fields = [ + '_project_id', + '_job_id', + '_package_uris', + '_training_python_module', + '_training_args', + '_region', + '_scale_tier', + ] + + @apply_defaults + def __init__(self, + project_id, + job_id, + package_uris, + training_python_module, + training_args, + region, + scale_tier=None, + gcp_conn_id='google_cloud_default', + delegate_to=None, + mode='PRODUCTION', + *args, + **kwargs): + super(MLEngineTrainingOperator, self).__init__(*args, **kwargs) + self._project_id = project_id + self._job_id = job_id + self._package_uris = package_uris + self._training_python_module = training_python_module + self._training_args = training_args + self._region = region + self._scale_tier = scale_tier + self._gcp_conn_id = gcp_conn_id + self._delegate_to = delegate_to + self._mode = mode + + if not self._project_id: + raise AirflowException('Google Cloud project id is required.') + if not self._job_id: + raise AirflowException( + 'An unique job id is required for Google MLEngine training ' + 'job.') + if not package_uris: + raise AirflowException( + 'At least one python package is required for MLEngine ' + 'Training job.') + if not training_python_module: + raise AirflowException( + 'Python module name to run after installing required ' + 'packages is required.') + if not self._region: + raise AirflowException('Google Compute Engine region is required.') + + def execute(self, context): + job_id = _normalize_mlengine_job_id(self._job_id) + training_request = { + 'jobId': job_id, + 'trainingInput': { + 'scaleTier': self._scale_tier, + 'packageUris': self._package_uris, + 'pythonModule': self._training_python_module, + 'region': self._region, + 'args': self._training_args, + } + } + + if self._mode == 'DRY_RUN': + self.log.info('In dry_run mode.') + self.log.info('MLEngine Training job request is: {}'.format(training_request)) + return + + hook = MLEngineHook( + gcp_conn_id=self._gcp_conn_id, delegate_to=self._delegate_to) + + # Helper method to check if the existing job's training input is the + # same as the request we get here. + def check_existing_job(existing_job): + return existing_job.get('trainingInput', None) == \ + training_request['trainingInput'] + try: + finished_training_job = hook.create_job( + self._project_id, training_request, check_existing_job) + except errors.HttpError: + raise + + if finished_training_job['state'] != 'SUCCEEDED': + self.log.error('MLEngine training job failed: {}'.format( + str(finished_training_job))) + raise RuntimeError(finished_training_job['errorMessage']) diff --git a/airflow/contrib/operators/mlengine_operator_utils.py b/airflow/contrib/operators/mlengine_operator_utils.py new file mode 100644 index 0000000000000..5fda6aebed277 --- /dev/null +++ b/airflow/contrib/operators/mlengine_operator_utils.py @@ -0,0 +1,245 @@ +# +# Licensed to the Apache Software Foundation (ASF) under one or more +# contributor license agreements. See the NOTICE file distributed with +# this work for additional information regarding copyright ownership. +# The ASF licenses this file to You under the Apache License, Version 2.0 +# (the 'License'); you may not use this file except in compliance with +# the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an 'AS IS' BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import base64 +import json +import os +import re + +import dill + +from airflow.contrib.hooks.gcs_hook import GoogleCloudStorageHook +from airflow.contrib.operators.mlengine_operator import MLEngineBatchPredictionOperator +from airflow.contrib.operators.dataflow_operator import DataFlowPythonOperator +from airflow.exceptions import AirflowException +from airflow.operators.python_operator import PythonOperator +from six.moves.urllib.parse import urlsplit + +def create_evaluate_ops(task_prefix, + data_format, + input_paths, + prediction_path, + metric_fn_and_keys, + validate_fn, + batch_prediction_job_id=None, + project_id=None, + region=None, + dataflow_options=None, + model_uri=None, + model_name=None, + version_name=None, + dag=None): + """ + Creates Operators needed for model evaluation and returns. + + It gets prediction over inputs via Cloud ML Engine BatchPrediction API by + calling MLEngineBatchPredictionOperator, then summarize and validate + the result via Cloud Dataflow using DataFlowPythonOperator. + + For details and pricing about Batch prediction, please refer to the website + https://cloud.google.com/ml-engine/docs/how-tos/batch-predict + and for Cloud Dataflow, https://cloud.google.com/dataflow/docs/ + + It returns three chained operators for prediction, summary, and validation, + named as -prediction, -summary, and -validation, + respectively. + ( should contain only alphanumeric characters or hyphen.) + + The upstream and downstream can be set accordingly like: + pred, _, val = create_evaluate_ops(...) + pred.set_upstream(upstream_op) + ... + downstream_op.set_upstream(val) + + Callers will provide two python callables, metric_fn and validate_fn, in + order to customize the evaluation behavior as they wish. + - metric_fn receives a dictionary per instance derived from json in the + batch prediction result. The keys might vary depending on the model. + It should return a tuple of metrics. + - validation_fn receives a dictionary of the averaged metrics that metric_fn + generated over all instances. + The key/value of the dictionary matches to what's given by + metric_fn_and_keys arg. + The dictionary contains an additional metric, 'count' to represent the + total number of instances received for evaluation. + The function would raise an exception to mark the task as failed, in a + case the validation result is not okay to proceed (i.e. to set the trained + version as default). + + Typical examples are like this: + + def get_metric_fn_and_keys(): + import math # imports should be outside of the metric_fn below. + def error_and_squared_error(inst): + label = float(inst['input_label']) + classes = float(inst['classes']) # 0 or 1 + err = abs(classes-label) + squared_err = math.pow(classes-label, 2) + return (err, squared_err) # returns a tuple. + return error_and_squared_error, ['err', 'mse'] # key order must match. + + def validate_err_and_count(summary): + if summary['err'] > 0.2: + raise ValueError('Too high err>0.2; summary=%s' % summary) + if summary['mse'] > 0.05: + raise ValueError('Too high mse>0.05; summary=%s' % summary) + if summary['count'] < 1000: + raise ValueError('Too few instances<1000; summary=%s' % summary) + return summary + + For the details on the other BatchPrediction-related arguments (project_id, + job_id, region, data_format, input_paths, prediction_path, model_uri), + please refer to MLEngineBatchPredictionOperator too. + + :param task_prefix: a prefix for the tasks. Only alphanumeric characters and + hyphen are allowed (no underscores), since this will be used as dataflow + job name, which doesn't allow other characters. + :type task_prefix: string + + :param data_format: either of 'TEXT', 'TF_RECORD', 'TF_RECORD_GZIP' + :type data_format: string + + :param input_paths: a list of input paths to be sent to BatchPrediction. + :type input_paths: list of strings + + :param prediction_path: GCS path to put the prediction results in. + :type prediction_path: string + + :param metric_fn_and_keys: a tuple of metric_fn and metric_keys: + - metric_fn is a function that accepts a dictionary (for an instance), + and returns a tuple of metric(s) that it calculates. + - metric_keys is a list of strings to denote the key of each metric. + :type metric_fn_and_keys: tuple of a function and a list of strings + + :param validate_fn: a function to validate whether the averaged metric(s) is + good enough to push the model. + :type validate_fn: function + + :param batch_prediction_job_id: the id to use for the Cloud ML Batch + prediction job. Passed directly to the MLEngineBatchPredictionOperator as + the job_id argument. + :type batch_prediction_job_id: string + + :param project_id: the Google Cloud Platform project id in which to execute + Cloud ML Batch Prediction and Dataflow jobs. If None, then the `dag`'s + `default_args['project_id']` will be used. + :type project_id: string + + :param region: the Google Cloud Platform region in which to execute Cloud ML + Batch Prediction and Dataflow jobs. If None, then the `dag`'s + `default_args['region']` will be used. + :type region: string + + :param dataflow_options: options to run Dataflow jobs. If None, then the + `dag`'s `default_args['dataflow_default_options']` will be used. + :type dataflow_options: dictionary + + :param model_uri: GCS path of the model exported by Tensorflow using + tensorflow.estimator.export_savedmodel(). It cannot be used with + model_name or version_name below. See MLEngineBatchPredictionOperator for + more detail. + :type model_uri: string + + :param model_name: Used to indicate a model to use for prediction. Can be + used in combination with version_name, but cannot be used together with + model_uri. See MLEngineBatchPredictionOperator for more detail. If None, + then the `dag`'s `default_args['model_name']` will be used. + :type model_name: string + + :param version_name: Used to indicate a model version to use for prediciton, + in combination with model_name. Cannot be used together with model_uri. + See MLEngineBatchPredictionOperator for more detail. If None, then the + `dag`'s `default_args['version_name']` will be used. + :type version_name: string + + :param dag: The `DAG` to use for all Operators. + :type dag: airflow.DAG + + :returns: a tuple of three operators, (prediction, summary, validation) + :rtype: tuple(DataFlowPythonOperator, DataFlowPythonOperator, + PythonOperator) + """ + + # Verify that task_prefix doesn't have any special characters except hyphen + # '-', which is the only allowed non-alphanumeric character by Dataflow. + if not re.match(r"^[a-zA-Z][-A-Za-z0-9]*$", task_prefix): + raise AirflowException( + "Malformed task_id for DataFlowPythonOperator (only alphanumeric " + "and hyphens are allowed but got: " + task_prefix) + + metric_fn, metric_keys = metric_fn_and_keys + if not callable(metric_fn): + raise AirflowException("`metric_fn` param must be callable.") + if not callable(validate_fn): + raise AirflowException("`validate_fn` param must be callable.") + + if dag is not None and dag.default_args is not None: + default_args = dag.default_args + project_id = project_id or default_args.get('project_id') + region = region or default_args.get('region') + model_name = model_name or default_args.get('model_name') + version_name = version_name or default_args.get('version_name') + dataflow_options = dataflow_options or \ + default_args.get('dataflow_default_options') + + evaluate_prediction = MLEngineBatchPredictionOperator( + task_id=(task_prefix + "-prediction"), + project_id=project_id, + job_id=batch_prediction_job_id, + region=region, + data_format=data_format, + input_paths=input_paths, + output_path=prediction_path, + uri=model_uri, + model_name=model_name, + version_name=version_name, + dag=dag) + + metric_fn_encoded = base64.b64encode(dill.dumps(metric_fn, recurse=True)) + evaluate_summary = DataFlowPythonOperator( + task_id=(task_prefix + "-summary"), + py_options=["-m"], + py_file="airflow.contrib.operators.mlengine_prediction_summary", + dataflow_default_options=dataflow_options, + options={ + "prediction_path": prediction_path, + "metric_fn_encoded": metric_fn_encoded, + "metric_keys": ','.join(metric_keys) + }, + dag=dag) + evaluate_summary.set_upstream(evaluate_prediction) + + def apply_validate_fn(*args, **kwargs): + prediction_path = kwargs["templates_dict"]["prediction_path"] + scheme, bucket, obj, _, _ = urlsplit(prediction_path) + if scheme != "gs" or not bucket or not obj: + raise ValueError("Wrong format prediction_path: %s", + prediction_path) + summary = os.path.join(obj.strip("/"), + "prediction.summary.json") + gcs_hook = GoogleCloudStorageHook() + summary = json.loads(gcs_hook.download(bucket, summary)) + return validate_fn(summary) + + evaluate_validation = PythonOperator( + task_id=(task_prefix + "-validation"), + python_callable=apply_validate_fn, + provide_context=True, + templates_dict={"prediction_path": prediction_path}, + dag=dag) + evaluate_validation.set_upstream(evaluate_summary) + + return evaluate_prediction, evaluate_summary, evaluate_validation diff --git a/airflow/contrib/operators/mlengine_prediction_summary.py b/airflow/contrib/operators/mlengine_prediction_summary.py new file mode 100644 index 0000000000000..17fc2c090379e --- /dev/null +++ b/airflow/contrib/operators/mlengine_prediction_summary.py @@ -0,0 +1,175 @@ +# +# Licensed to the Apache Software Foundation (ASF) under one or more +# contributor license agreements. See the NOTICE file distributed with +# this work for additional information regarding copyright ownership. +# The ASF licenses this file to You under the Apache License, Version 2.0 +# (the 'License'); you may not use this file except in compliance with +# the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an 'AS IS' BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""A template called by DataFlowPythonOperator to summarize BatchPrediction. + +It accepts a user function to calculate the metric(s) per instance in +the prediction results, then aggregates to output as a summary. + +Args: + --prediction_path: + The GCS folder that contains BatchPrediction results, containing + prediction.results-NNNNN-of-NNNNN files in the json format. + Output will be also stored in this folder, as 'prediction.summary.json'. + + --metric_fn_encoded: + An encoded function that calculates and returns a tuple of metric(s) + for a given instance (as a dictionary). It should be encoded + via base64.b64encode(dill.dumps(fn, recurse=True)). + + --metric_keys: + A comma-separated key(s) of the aggregated metric(s) in the summary + output. The order and the size of the keys must match to the output + of metric_fn. + The summary will have an additional key, 'count', to represent the + total number of instances, so the keys shouldn't include 'count'. + +# Usage example: +def get_metric_fn(): + import math # all imports must be outside of the function to be passed. + def metric_fn(inst): + label = float(inst["input_label"]) + classes = float(inst["classes"]) + prediction = float(inst["scores"][1]) + log_loss = math.log(1 + math.exp( + -(label * 2 - 1) * math.log(prediction / (1 - prediction)))) + squared_err = (classes-label)**2 + return (log_loss, squared_err) + return metric_fn +metric_fn_encoded = base64.b64encode(dill.dumps(get_metric_fn(), recurse=True)) + +airflow.contrib.operators.DataFlowPythonOperator( + task_id="summary-prediction", + py_options=["-m"], + py_file="airflow.contrib.operators.mlengine_prediction_summary", + options={ + "prediction_path": prediction_path, + "metric_fn_encoded": metric_fn_encoded, + "metric_keys": "log_loss,mse" + }, + dataflow_default_options={ + "project": "xxx", "region": "us-east1", + "staging_location": "gs://yy", "temp_location": "gs://zz", + }) + >> dag + +# When the input file is like the following: +{"inputs": "1,x,y,z", "classes": 1, "scores": [0.1, 0.9]} +{"inputs": "0,o,m,g", "classes": 0, "scores": [0.7, 0.3]} +{"inputs": "1,o,m,w", "classes": 0, "scores": [0.6, 0.4]} +{"inputs": "1,b,r,b", "classes": 1, "scores": [0.2, 0.8]} + +# The output file will be: +{"log_loss": 0.43890510565304547, "count": 4, "mse": 0.25} + +# To test outside of the dag: +subprocess.check_call(["python", + "-m", + "airflow.contrib.operators.mlengine_prediction_summary", + "--prediction_path=gs://...", + "--metric_fn_encoded=" + metric_fn_encoded, + "--metric_keys=log_loss,mse", + "--runner=DataflowRunner", + "--staging_location=gs://...", + "--temp_location=gs://...", + ]) + +""" +from __future__ import absolute_import +from __future__ import division +from __future__ import print_function + +import argparse +import base64 +import json +import os + +import apache_beam as beam +import dill + + +class JsonCoder(object): + def encode(self, x): + return json.dumps(x) + + def decode(self, x): + return json.loads(x) + + +@beam.ptransform_fn +def MakeSummary(pcoll, metric_fn, metric_keys): # pylint: disable=invalid-name + return ( + pcoll + | "ApplyMetricFnPerInstance" >> beam.Map(metric_fn) + | "PairWith1" >> beam.Map(lambda tup: tup + (1,)) + | "SumTuple" >> beam.CombineGlobally(beam.combiners.TupleCombineFn( + *([sum] * (len(metric_keys) + 1)))) + | "AverageAndMakeDict" >> beam.Map( + lambda tup: dict( + [(name, tup[i]/tup[-1]) for i, name in enumerate(metric_keys)] + + [("count", tup[-1])]))) + + +def run(argv=None): + parser = argparse.ArgumentParser() + parser.add_argument( + "--prediction_path", required=True, + help=( + "The GCS folder that contains BatchPrediction results, containing " + "prediction.results-NNNNN-of-NNNNN files in the json format. " + "Output will be also stored in this folder, as a file" + "'prediction.summary.json'.")) + parser.add_argument( + "--metric_fn_encoded", required=True, + help=( + "An encoded function that calculates and returns a tuple of " + "metric(s) for a given instance (as a dictionary). It should be " + "encoded via base64.b64encode(dill.dumps(fn, recurse=True)).")) + parser.add_argument( + "--metric_keys", required=True, + help=( + "A comma-separated keys of the aggregated metric(s) in the summary " + "output. The order and the size of the keys must match to the " + "output of metric_fn. The summary will have an additional key, " + "'count', to represent the total number of instances, so this flag " + "shouldn't include 'count'.")) + known_args, pipeline_args = parser.parse_known_args(argv) + + metric_fn = dill.loads(base64.b64decode(known_args.metric_fn_encoded)) + if not callable(metric_fn): + raise ValueError("--metric_fn_encoded must be an encoded callable.") + metric_keys = known_args.metric_keys.split(",") + + with beam.Pipeline( + options=beam.pipeline.PipelineOptions(pipeline_args)) as p: + # This is apache-beam ptransform's convention + # pylint: disable=no-value-for-parameter + _ = (p + | "ReadPredictionResult" >> beam.io.ReadFromText( + os.path.join(known_args.prediction_path, + "prediction.results-*-of-*"), + coder=JsonCoder()) + | "Summary" >> MakeSummary(metric_fn, metric_keys) + | "Write" >> beam.io.WriteToText( + os.path.join(known_args.prediction_path, + "prediction.summary.json"), + shard_name_template='', # without trailing -NNNNN-of-NNNNN. + coder=JsonCoder())) + # pylint: enable=no-value-for-parameter + + +if __name__ == "__main__": + run() diff --git a/airflow/contrib/operators/mysql_to_gcs.py b/airflow/contrib/operators/mysql_to_gcs.py new file mode 100644 index 0000000000000..f94bc243486e8 --- /dev/null +++ b/airflow/contrib/operators/mysql_to_gcs.py @@ -0,0 +1,223 @@ +# -*- coding: utf-8 -*- +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import json +import time + +from airflow.contrib.hooks.gcs_hook import GoogleCloudStorageHook +from airflow.hooks.mysql_hook import MySqlHook +from airflow.models import BaseOperator +from airflow.utils.decorators import apply_defaults +from datetime import date, datetime +from decimal import Decimal +from MySQLdb.constants import FIELD_TYPE +from tempfile import NamedTemporaryFile + + +class MySqlToGoogleCloudStorageOperator(BaseOperator): + """ + Copy data from MySQL to Google cloud storage in JSON format. + """ + template_fields = ('sql', 'bucket', 'filename', 'schema_filename') + template_ext = ('.sql',) + ui_color = '#a0e08c' + + @apply_defaults + def __init__(self, + sql, + bucket, + filename, + schema_filename=None, + approx_max_file_size_bytes=1900000000, + mysql_conn_id='mysql_default', + google_cloud_storage_conn_id='google_cloud_storage_default', + delegate_to=None, + *args, + **kwargs): + """ + :param sql: The SQL to execute on the MySQL table. + :type sql: string + :param bucket: The bucket to upload to. + :type bucket: string + :param filename: The filename to use as the object name when uploading + to Google cloud storage. A {} should be specified in the filename + to allow the operator to inject file numbers in cases where the + file is split due to size. + :type filename: string + :param schema_filename: If set, the filename to use as the object name + when uploading a .json file containing the BigQuery schema fields + for the table that was dumped from MySQL. + :type schema_filename: string + :param approx_max_file_size_bytes: This operator supports the ability + to split large table dumps into multiple files (see notes in the + filenamed param docs above). Google cloud storage allows for files + to be a maximum of 4GB. This param allows developers to specify the + file size of the splits. + :type approx_max_file_size_bytes: long + :param mysql_conn_id: Reference to a specific MySQL hook. + :type mysql_conn_id: string + :param google_cloud_storage_conn_id: Reference to a specific Google + cloud storage hook. + :type google_cloud_storage_conn_id: string + :param delegate_to: The account to impersonate, if any. For this to + work, the service account making the request must have domain-wide + delegation enabled. + """ + super(MySqlToGoogleCloudStorageOperator, self).__init__(*args, **kwargs) + self.sql = sql + self.bucket = bucket + self.filename = filename + self.schema_filename = schema_filename + self.approx_max_file_size_bytes = approx_max_file_size_bytes + self.mysql_conn_id = mysql_conn_id + self.google_cloud_storage_conn_id = google_cloud_storage_conn_id + self.delegate_to = delegate_to + + def execute(self, context): + cursor = self._query_mysql() + files_to_upload = self._write_local_data_files(cursor) + + # If a schema is set, create a BQ schema JSON file. + if self.schema_filename: + files_to_upload.update(self._write_local_schema_file(cursor)) + + # Flush all files before uploading + for file_handle in files_to_upload.values(): + file_handle.flush() + + self._upload_to_gcs(files_to_upload) + + # Close all temp file handles. + for file_handle in files_to_upload.values(): + file_handle.close() + + def _query_mysql(self): + """ + Queries mysql and returns a cursor to the results. + """ + mysql = MySqlHook(mysql_conn_id=self.mysql_conn_id) + conn = mysql.get_conn() + cursor = conn.cursor() + cursor.execute(self.sql) + return cursor + + def _write_local_data_files(self, cursor): + """ + Takes a cursor, and writes results to a local file. + + :return: A dictionary where keys are filenames to be used as object + names in GCS, and values are file handles to local files that + contain the data for the GCS objects. + """ + schema = list(map(lambda schema_tuple: schema_tuple[0], cursor.description)) + file_no = 0 + tmp_file_handle = NamedTemporaryFile(delete=True) + tmp_file_handles = {self.filename.format(file_no): tmp_file_handle} + + for row in cursor: + # Convert datetime objects to utc seconds, and decimals to floats + row = map(self.convert_types, row) + row_dict = dict(zip(schema, row)) + + # TODO validate that row isn't > 2MB. BQ enforces a hard row size of 2MB. + json.dump(row_dict, tmp_file_handle) + + # Append newline to make dumps BigQuery compatible. + tmp_file_handle.write('\n') + + # Stop if the file exceeds the file size limit. + if tmp_file_handle.tell() >= self.approx_max_file_size_bytes: + file_no += 1 + tmp_file_handle = NamedTemporaryFile(delete=True) + tmp_file_handles[self.filename.format(file_no)] = tmp_file_handle + + return tmp_file_handles + + def _write_local_schema_file(self, cursor): + """ + Takes a cursor, and writes the BigQuery schema for the results to a + local file system. + + :return: A dictionary where key is a filename to be used as an object + name in GCS, and values are file handles to local files that + contains the BigQuery schema fields in .json format. + """ + schema = [] + for field in cursor.description: + # See PEP 249 for details about the description tuple. + field_name = field[0] + field_type = self.type_map(field[1]) + # Always allow TIMESTAMP to be nullable. MySQLdb returns None types + # for required fields because some MySQL timestamps can't be + # represented by Python's datetime (e.g. 0000-00-00 00:00:00). + field_mode = 'NULLABLE' if field[6] or field_type == 'TIMESTAMP' else 'REQUIRED' + schema.append({ + 'name': field_name, + 'type': field_type, + 'mode': field_mode, + }) + + self.log.info('Using schema for %s: %s', self.schema_filename, schema) + tmp_schema_file_handle = NamedTemporaryFile(delete=True) + json.dump(schema, tmp_schema_file_handle) + return {self.schema_filename: tmp_schema_file_handle} + + def _upload_to_gcs(self, files_to_upload): + """ + Upload all of the file splits (and optionally the schema .json file) to + Google cloud storage. + """ + hook = GoogleCloudStorageHook(google_cloud_storage_conn_id=self.google_cloud_storage_conn_id, + delegate_to=self.delegate_to) + for object, tmp_file_handle in files_to_upload.items(): + hook.upload(self.bucket, object, tmp_file_handle.name, 'application/json') + + @classmethod + def convert_types(cls, value): + """ + Takes a value from MySQLdb, and converts it to a value that's safe for + JSON/Google cloud storage/BigQuery. Dates are converted to UTC seconds. + Decimals are converted to floats. + """ + if type(value) in (datetime, date): + return time.mktime(value.timetuple()) + elif isinstance(value, Decimal): + return float(value) + else: + return value + + @classmethod + def type_map(cls, mysql_type): + """ + Helper function that maps from MySQL fields to BigQuery fields. Used + when a schema_filename is set. + """ + d = { + FIELD_TYPE.INT24: 'INTEGER', + FIELD_TYPE.TINY: 'INTEGER', + FIELD_TYPE.BIT: 'INTEGER', + FIELD_TYPE.DATETIME: 'TIMESTAMP', + FIELD_TYPE.DATE: 'TIMESTAMP', + FIELD_TYPE.DECIMAL: 'FLOAT', + FIELD_TYPE.NEWDECIMAL: 'FLOAT', + FIELD_TYPE.DOUBLE: 'FLOAT', + FIELD_TYPE.FLOAT: 'FLOAT', + FIELD_TYPE.INT24: 'INTEGER', + FIELD_TYPE.LONG: 'INTEGER', + FIELD_TYPE.LONGLONG: 'INTEGER', + FIELD_TYPE.SHORT: 'INTEGER', + FIELD_TYPE.TIMESTAMP: 'TIMESTAMP', + FIELD_TYPE.YEAR: 'INTEGER', + } + return d[mysql_type] if mysql_type in d else 'STRING' diff --git a/airflow/contrib/operators/pubsub_operator.py b/airflow/contrib/operators/pubsub_operator.py new file mode 100644 index 0000000000000..f68c6a9e729eb --- /dev/null +++ b/airflow/contrib/operators/pubsub_operator.py @@ -0,0 +1,170 @@ +# -*- coding: utf-8 -*- +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from airflow.contrib.hooks.gcp_pubsub_hook import PubSubHook +from airflow.models import BaseOperator +from airflow.utils.decorators import apply_defaults + + +class PubSubTopicCreateOperator(BaseOperator): + """Create a PubSub topic. + + By default, if the topic already exists, this operator will + not cause the DAG to fail. + ``` + with DAG('successful DAG') as dag: + ( + dag + >> PubSubTopicCreateOperator(topic='my_new_topic') + >> PubSubTopicCreateOperator(topic='my_new_topic') + ) + ``` + + The operator can be configured to fail if the topic already exists. + ``` + with DAG('failing DAG') as dag: + ( + dag + >> PubSubTopicCreateOperator(topic='my_new_topic') + >> PubSubTopicCreateOperator(topic='my_new_topic', + fail_if_exists=True) + ) + ``` + + Both ``project`` and ``topic`` are templated so you can use + variables in them. + """ + template_fields = ['project', 'topic'] + ui_color = '#0273d4' + + @apply_defaults + def __init__( + self, + project, + topic, + fail_if_exists=False, + gcp_conn_id='google_cloud_default', + delegate_to=None, + *args, + **kwargs): + """ + :param project: the GCP project name or ID in which to work + (templated) + :type project: string + :param topic: the topic to create. Do not include the + full topic path. In other words, instead of + ``projects/{project}/topics/{topic}``, provide only + ``{topic}``. (templated) + :type topic: string + :param gcp_conn_id: The connection ID to use connecting to + Google Cloud Platform. + :type gcp_conn_id: string + :param delegate_to: The account to impersonate, if any. + For this to work, the service account making the request + must have domain-wide delegation enabled. + :type delegate_to: string + """ + super(PubSubTopicCreateOperator, self).__init__(*args, **kwargs) + + self.project = project + self.topic = topic + self.fail_if_exists = fail_if_exists + self.gcp_conn_id = gcp_conn_id + self.delegate_to = delegate_to + + def execute(self, context): + hook = PubSubHook(gcp_conn_id=self.gcp_conn_id, + delegate_to=self.delegate_to) + + hook.create_topic(self.project, self.topic, + fail_if_exists=self.fail_if_exists) + + +class PubSubPublishOperator(BaseOperator): + """Publish messages to a PubSub topic. + + Each Task publishes all provided messages to the same topic + in a single GCP project. If the topic does not exist, this + task will fail. + + ``` + from base64 import b64encode as b64e + + m1 = {'data': b64e('Hello, World!'), + 'attributes': {'type': 'greeting'} + } + m2 = {'data': b64e('Knock, knock')} + m3 = {'attributes': {'foo': ''}} + + t1 = PubSubPublishOperator( + topic='my_topic', + messages=[m1, m2, m3], + create_topic=True, + dag=dag) + ``` + Both ``project`` and ``topic`` are templated so you can use + variables in them. + """ + template_fields = ['project', 'topic', 'messages'] + ui_color = '#0273d4' + + @apply_defaults + def __init__( + self, + project, + topic, + messages, + gcp_conn_id='google_cloud_default', + delegate_to=None, + *args, + **kwargs): + """ + :param project: the GCP project name or ID in which to work + (templated) + :type project: string + :param topic: the topic to which to publish. Do not include the + full topic path. In other words, instead of + ``projects/{project}/topics/{topic}``, provide only + ``{topic}``. (templated) + :type topic: string + :param messages: a list of messages to be published to the + topic. Each message is a dict with one or more of the + following keys-value mappings: + * 'data': a base64-encoded string + * 'attributes': {'key1': 'value1', ...} + Each message must contain at least a non-empty 'data' value + or an attribute dict with at least one key. See + https://cloud.google.com/pubsub/docs/reference/rest/v1/PubsubMessage + (templated) + :type messages: list + :param gcp_conn_id: The connection ID to use connecting to + Google Cloud Platform. + :type gcp_conn_id: string + :param delegate_to: The account to impersonate, if any. + For this to work, the service account making the request + must have domain-wide delegation enabled. + :type delegate_to: string + """ + super(PubSubPublishOperator, self).__init__(*args, **kwargs) + + self.gcp_conn_id = gcp_conn_id + self.delegate_to = delegate_to + self.project = project + self.topic = topic + self.messages = messages + + def execute(self, context): + hook = PubSubHook(gcp_conn_id=self.gcp_conn_id, + delegate_to=self.delegate_to) + hook.publish(self.project, self.topic, self.messages) diff --git a/airflow/contrib/operators/qubole_operator.py b/airflow/contrib/operators/qubole_operator.py new file mode 100755 index 0000000000000..a5e9f5ed6318d --- /dev/null +++ b/airflow/contrib/operators/qubole_operator.py @@ -0,0 +1,171 @@ +# -*- coding: utf-8 -*- +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from airflow.models import BaseOperator +from airflow.utils.decorators import apply_defaults +from airflow.contrib.hooks.qubole_hook import QuboleHook + + +class QuboleOperator(BaseOperator): + """ + Execute tasks (commands) on QDS (https://qubole.com). + + :param qubole_conn_id: Connection id which consists of qds auth_token + :type qubole_conn_id: str + + kwargs: + :command_type: type of command to be executed, e.g. hivecmd, shellcmd, hadoopcmd + :tags: array of tags to be assigned with the command + :cluster_label: cluster label on which the command will be executed + :name: name to be given to command + :notify: whether to send email on command completion or not (default is False) + + **Arguments specific to command types** + + hivecmd: + :query: inline query statement + :script_location: s3 location containing query statement + :sample_size: size of sample in bytes on which to run query + :macros: macro values which were used in query + prestocmd: + :query: inline query statement + :script_location: s3 location containing query statement + :macros: macro values which were used in query + hadoopcmd: + :sub_commnad: must be one these ["jar", "s3distcp", "streaming"] followed by + 1 or more args + shellcmd: + :script: inline command with args + :script_location: s3 location containing query statement + :files: list of files in s3 bucket as file1,file2 format. These files will be + copied into the working directory where the qubole command is being + executed. + :archives: list of archives in s3 bucket as archive1,archive2 format. These + will be unarchived intothe working directory where the qubole command is + being executed + :parameters: any extra args which need to be passed to script (only when + script_location is supplied) + pigcmd: + :script: inline query statement (latin_statements) + :script_location: s3 location containing pig query + :parameters: any extra args which need to be passed to script (only when + script_location is supplied + sparkcmd: + :program: the complete Spark Program in Scala, SQL, Command, R, or Python + :cmdline: spark-submit command line, all required information must be specify + in cmdline itself. + :sql: inline sql query + :script_location: s3 location containing query statement + :language: language of the program, Scala, SQL, Command, R, or Python + :app_id: ID of an Spark job server app + :arguments: spark-submit command line arguments + :user_program_arguments: arguments that the user program takes in + :macros: macro values which were used in query + dbtapquerycmd: + :db_tap_id: data store ID of the target database, in Qubole. + :query: inline query statement + :macros: macro values which were used in query + dbexportcmd: + :mode: 1 (simple), 2 (advance) + :hive_table: Name of the hive table + :partition_spec: partition specification for Hive table. + :dbtap_id: data store ID of the target database, in Qubole. + :db_table: name of the db table + :db_update_mode: allowinsert or updateonly + :db_update_keys: columns used to determine the uniqueness of rows + :export_dir: HDFS/S3 location from which data will be exported. + :fields_terminated_by: hex of the char used as column separator in the dataset + dbimportcmd: + :mode: 1 (simple), 2 (advance) + :hive_table: Name of the hive table + :dbtap_id: data store ID of the target database, in Qubole. + :db_table: name of the db table + :where_clause: where clause, if any + :parallelism: number of parallel db connections to use for extracting data + :extract_query: SQL query to extract data from db. $CONDITIONS must be part + of the where clause. + :boundary_query: Query to be used get range of row IDs to be extracted + :split_column: Column used as row ID to split data into ranges (mode 2) + + .. note:: Following fields are template-supported : ``query``, ``script_location``, + ``sub_command``, ``script``, ``files``, ``archives``, ``program``, ``cmdline``, + ``sql``, ``where_clause``, ``extract_query``, ``boundary_query``, ``macros``, + ``tags``, ``name``, ``parameters``, ``dbtap_id``, ``hive_table``, ``db_table``, + ``split_column``, ``note_id``, ``db_update_keys``, ``export_dir``, + ``partition_spec``, ``qubole_conn_id``, ``arguments``, ``user_program_arguments``. + You can also use ``.txt`` files for template driven use cases. + + .. note:: In QuboleOperator there is a default handler for task failures and retries, + which generally kills the command running at QDS for the corresponding task + instance. You can override this behavior by providing your own failure and retry + handler in task definition. + """ + + template_fields = ('query', 'script_location', 'sub_command', 'script', 'files', + 'archives', 'program', 'cmdline', 'sql', 'where_clause', 'tags', + 'extract_query', 'boundary_query', 'macros', 'name', 'parameters', + 'dbtap_id', 'hive_table', 'db_table', 'split_column', 'note_id', + 'db_update_keys', 'export_dir', 'partition_spec', 'qubole_conn_id', + 'arguments', 'user_program_arguments') + + template_ext = ('.txt',) + ui_color = '#3064A1' + ui_fgcolor = '#fff' + + @apply_defaults + def __init__(self, qubole_conn_id="qubole_default", *args, **kwargs): + self.args = args + self.kwargs = kwargs + self.kwargs['qubole_conn_id'] = qubole_conn_id + super(QuboleOperator, self).__init__(*args, **kwargs) + + if self.on_failure_callback is None: + self.on_failure_callback = QuboleHook.handle_failure_retry + + if self.on_retry_callback is None: + self.on_retry_callback = QuboleHook.handle_failure_retry + + def execute(self, context): + return self.get_hook().execute(context) + + def on_kill(self, ti=None): + self.get_hook().kill(ti) + + def get_results(self, ti=None, fp=None, inline=True, delim=None, fetch=True): + return self.get_hook().get_results(ti, fp, inline, delim, fetch) + + def get_log(self, ti): + return self.get_hook().get_log(ti) + + def get_jobs_id(self, ti): + return self.get_hook().get_jobs_id(ti) + + def get_hook(self): + # Reinitiating the hook, as some template fields might have changed + return QuboleHook(*self.args, **self.kwargs) + + def __getattribute__(self, name): + if name in QuboleOperator.template_fields: + if name in self.kwargs: + return self.kwargs[name] + else: + return '' + else: + return object.__getattribute__(self, name) + + def __setattr__(self, name, value): + if name in QuboleOperator.template_fields: + self.kwargs[name] = value + else: + object.__setattr__(self, name, value) diff --git a/airflow/contrib/operators/sftp_operator.py b/airflow/contrib/operators/sftp_operator.py new file mode 100644 index 0000000000000..44ea66d6a2640 --- /dev/null +++ b/airflow/contrib/operators/sftp_operator.py @@ -0,0 +1,96 @@ +# -*- coding: utf-8 -*- +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +from airflow.contrib.hooks.ssh_hook import SSHHook +from airflow.exceptions import AirflowException +from airflow.models import BaseOperator +from airflow.utils.decorators import apply_defaults + + +class SFTPOperation(object): + PUT = 'put' + GET = 'get' + + +class SFTPOperator(BaseOperator): + """ + SFTPOperator for transferring files from remote host to local or vice a versa. + This operator uses ssh_hook to open sftp trasport channel that serve as basis + for file transfer. + + :param ssh_hook: predefined ssh_hook to use for remote execution + :type ssh_hook: :class:`SSHHook` + :param ssh_conn_id: connection id from airflow Connections + :type ssh_conn_id: str + :param remote_host: remote host to connect + :type remote_host: str + :param local_filepath: local file path to get or put + :type local_filepath: str + :param remote_filepath: remote file path to get or put + :type remote_filepath: str + :param operation: specify operation 'get' or 'put', defaults to get + :type get: bool + """ + template_fields = ('local_filepath', 'remote_filepath') + + @apply_defaults + def __init__(self, + ssh_hook=None, + ssh_conn_id=None, + remote_host=None, + local_filepath=None, + remote_filepath=None, + operation=SFTPOperation.PUT, + *args, + **kwargs): + super(SFTPOperator, self).__init__(*args, **kwargs) + self.ssh_hook = ssh_hook + self.ssh_conn_id = ssh_conn_id + self.remote_host = remote_host + self.local_filepath = local_filepath + self.remote_filepath = remote_filepath + self.operation = operation + if not (self.operation.lower() == SFTPOperation.GET or self.operation.lower() == SFTPOperation.PUT): + raise TypeError("unsupported operation value {0}, expected {1} or {2}" + .format(self.operation, SFTPOperation.GET, SFTPOperation.PUT)) + + def execute(self, context): + file_msg = None + try: + if self.ssh_conn_id and not self.ssh_hook: + self.ssh_hook = SSHHook(ssh_conn_id=self.ssh_conn_id) + + if not self.ssh_hook: + raise AirflowException("can not operate without ssh_hook or ssh_conn_id") + + if self.remote_host is not None: + self.ssh_hook.remote_host = self.remote_host + + ssh_client = self.ssh_hook.get_conn() + sftp_client = ssh_client.open_sftp() + if self.operation.lower() == SFTPOperation.GET: + file_msg = "from {0} to {1}".format(self.remote_filepath, + self.local_filepath) + self.log.debug("Starting to transfer %s", file_msg) + sftp_client.get(self.remote_filepath, self.local_filepath) + else: + file_msg = "from {0} to {1}".format(self.local_filepath, + self.remote_filepath) + self.log.debug("Starting to transfer file %s", file_msg) + sftp_client.put(self.local_filepath, self.remote_filepath) + + except Exception as e: + raise AirflowException("Error while transferring {0}, error: {1}" + .format(file_msg, str(e))) + + return None diff --git a/airflow/contrib/operators/spark_sql_operator.py b/airflow/contrib/operators/spark_sql_operator.py new file mode 100644 index 0000000000000..f6cba5955c34e --- /dev/null +++ b/airflow/contrib/operators/spark_sql_operator.py @@ -0,0 +1,103 @@ +# -*- coding: utf-8 -*- +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +from airflow.models import BaseOperator +from airflow.utils.decorators import apply_defaults +from airflow.contrib.hooks.spark_sql_hook import SparkSqlHook + + +class SparkSqlOperator(BaseOperator): + """ + Execute Spark SQL query + :param sql: The SQL query to execute + :type sql: str + :param conf: arbitrary Spark configuration property + :type conf: str (format: PROP=VALUE) + :param conn_id: connection_id string + :type conn_id: str + :param total_executor_cores: (Standalone & Mesos only) Total cores for all executors (Default: all the available cores on the worker) + :type total_executor_cores: int + :param executor_cores: (Standalone & YARN only) Number of cores per executor (Default: 2) + :type executor_cores: int + :param executor_memory: Memory per executor (e.g. 1000M, 2G) (Default: 1G) + :type executor_memory: str + :param keytab: Full path to the file that contains the keytab + :type keytab: str + :param master: spark://host:port, mesos://host:port, yarn, or local + :type master: str + :param name: Name of the job + :type name: str + :param num_executors: Number of executors to launch + :type num_executors: int + :param verbose: Whether to pass the verbose flag to spark-sql + :type verbose: bool + :param yarn_queue: The YARN queue to submit to (Default: "default") + :type yarn_queue: str + """ + + template_fields = ["_sql"] + template_ext = [".sql", ".hql"] + + @apply_defaults + def __init__(self, + sql, + conf=None, + conn_id='spark_sql_default', + total_executor_cores=None, + executor_cores=None, + executor_memory=None, + keytab=None, + principal=None, + master='yarn', + name='default-name', + num_executors=None, + yarn_queue='default', + *args, + **kwargs): + super(SparkSqlOperator, self).__init__(*args, **kwargs) + self._sql = sql + self._conf = conf + self._conn_id = conn_id + self._total_executor_cores = total_executor_cores + self._executor_cores = executor_cores + self._executor_memory = executor_memory + self._keytab = keytab + self._principal = principal + self._master = master + self._name = name + self._num_executors = num_executors + self._yarn_queue = yarn_queue + self._hook = None + + def execute(self, context): + """ + Call the SparkSqlHook to run the provided sql query + """ + self._hook = SparkSqlHook(sql=self._sql, + conf=self._conf, + conn_id=self._conn_id, + total_executor_cores=self._total_executor_cores, + executor_cores=self._executor_cores, + executor_memory=self._executor_memory, + keytab=self._keytab, + principal=self._principal, + name=self._name, + num_executors=self._num_executors, + master=self._master, + yarn_queue=self._yarn_queue + ) + self._hook.run_query() + + def on_kill(self): + self._hook.kill() diff --git a/airflow/contrib/operators/spark_submit_operator.py b/airflow/contrib/operators/spark_submit_operator.py new file mode 100644 index 0000000000000..277c55ff6099d --- /dev/null +++ b/airflow/contrib/operators/spark_submit_operator.py @@ -0,0 +1,137 @@ +# -*- coding: utf-8 -*- +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +from airflow.contrib.hooks.spark_submit_hook import SparkSubmitHook +from airflow.models import BaseOperator +from airflow.settings import WEB_COLORS +from airflow.utils.decorators import apply_defaults + + +class SparkSubmitOperator(BaseOperator): + """ + This hook is a wrapper around the spark-submit binary to kick off a spark-submit job. + It requires that the "spark-submit" binary is in the PATH or the spark-home is set + in the extra on the connection. + :param application: The application that submitted as a job, either jar or py file. + :type application: str + :param conf: Arbitrary Spark configuration properties + :type conf: dict + :param conn_id: The connection id as configured in Airflow administration. When an + invalid connection_id is supplied, it will default to yarn. + :type conn_id: str + :param files: Upload additional files to the container running the job, separated by a + comma. For example hive-site.xml. + :type files: str + :param py_files: Additional python files used by the job, can be .zip, .egg or .py. + :type py_files: str + :param jars: Submit additional jars to upload and place them in executor classpath. + :type jars: str + :param java_class: the main class of the Java application + :type java_class: str + :param packages: Comma-separated list of maven coordinates of jars to include on the driver and executor classpaths + :type packages: str + :param total_executor_cores: (Standalone & Mesos only) Total cores for all executors (Default: all the available cores on the worker) + :type total_executor_cores: int + :param executor_cores: (Standalone & YARN only) Number of cores per executor (Default: 2) + :type executor_cores: int + :param executor_memory: Memory per executor (e.g. 1000M, 2G) (Default: 1G) + :type executor_memory: str + :param driver_memory: Memory allocated to the driver (e.g. 1000M, 2G) (Default: 1G) + :type driver_memory: str + :param keytab: Full path to the file that contains the keytab + :type keytab: str + :param principal: The name of the kerberos principal used for keytab + :type principal: str + :param name: Name of the job (default airflow-spark) + :type name: str + :param num_executors: Number of executors to launch + :type num_executors: int + :param application_args: Arguments for the application being submitted + :type application_args: list + :param verbose: Whether to pass the verbose flag to spark-submit process for debugging + :type verbose: bool + """ + template_fields = ('_name', '_application_args',) + ui_color = WEB_COLORS['LIGHTORANGE'] + + @apply_defaults + def __init__(self, + application='', + conf=None, + conn_id='spark_default', + files=None, + py_files=None, + jars=None, + java_class=None, + packages=None, + total_executor_cores=None, + executor_cores=None, + executor_memory=None, + driver_memory=None, + keytab=None, + principal=None, + name='airflow-spark', + num_executors=None, + application_args=None, + verbose=False, + *args, + **kwargs): + super(SparkSubmitOperator, self).__init__(*args, **kwargs) + self._application = application + self._conf = conf + self._files = files + self._py_files = py_files + self._jars = jars + self._java_class = java_class + self._packages = packages + self._total_executor_cores = total_executor_cores + self._executor_cores = executor_cores + self._executor_memory = executor_memory + self._driver_memory = driver_memory + self._keytab = keytab + self._principal = principal + self._name = name + self._num_executors = num_executors + self._application_args = application_args + self._verbose = verbose + self._hook = None + self._conn_id = conn_id + + def execute(self, context): + """ + Call the SparkSubmitHook to run the provided spark job + """ + self._hook = SparkSubmitHook( + conf=self._conf, + conn_id=self._conn_id, + files=self._files, + py_files=self._py_files, + jars=self._jars, + java_class=self._java_class, + packages=self._packages, + total_executor_cores=self._total_executor_cores, + executor_cores=self._executor_cores, + executor_memory=self._executor_memory, + driver_memory=self._driver_memory, + keytab=self._keytab, + principal=self._principal, + name=self._name, + num_executors=self._num_executors, + application_args=self._application_args, + verbose=self._verbose + ) + self._hook.submit(self._application) + + def on_kill(self): + self._hook.on_kill() diff --git a/airflow/contrib/operators/sqoop_operator.py b/airflow/contrib/operators/sqoop_operator.py new file mode 100644 index 0000000000000..c3da176de4644 --- /dev/null +++ b/airflow/contrib/operators/sqoop_operator.py @@ -0,0 +1,186 @@ +# -*- coding: utf-8 -*- +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +""" +This module contains a sqoop 1 operator +""" + +from airflow.contrib.hooks.sqoop_hook import SqoopHook +from airflow.exceptions import AirflowException +from airflow.models import BaseOperator +from airflow.utils.decorators import apply_defaults + + +class SqoopOperator(BaseOperator): + """ + execute sqoop job + """ + + @apply_defaults + def __init__(self, + conn_id='sqoop_default', + cmd_type='import', + table=None, + query=None, + target_dir=None, + append=None, + file_type=None, + columns=None, + num_mappers=None, + split_by=None, + where=None, + export_dir=None, + input_null_string=None, + input_null_non_string=None, + staging_table=None, + clear_staging_table=False, + enclosed_by=None, + escaped_by=None, + input_fields_terminated_by=None, + input_lines_terminated_by=None, + input_optionally_enclosed_by=None, + batch=False, + direct=False, + driver=None, + verbose=False, + relaxed_isolation=False, + properties=None, + hcatalog_database=None, + hcatalog_table=None, + *args, + **kwargs): + """ + :param conn_id: str + :param cmd_type: str specify command to execute "export" or "import" + :param table: Table to read + :param target_dir: HDFS destination directory where the data + from the rdbms will be written + :param append: Append data to an existing dataset in HDFS + :param file_type: "avro", "sequence", "text" Imports data to + into the specified format. Defaults to text. + :param columns: Columns to import from table + :param num_mappers: Use n mapper tasks to import/export in parallel + :param split_by: Column of the table used to split work units + :param where: WHERE clause to use during import + :param export_dir: HDFS Hive database directory to export to the rdbms + :param input_null_string: The string to be interpreted as null + for string columns + :param input_null_non_string: The string to be interpreted as null + for non-string columns + :param staging_table: The table in which data will be staged before + being inserted into the destination table + :param clear_staging_table: Indicate that any data present in the + staging table can be deleted + :param enclosed_by: Sets a required field enclosing character + :param escaped_by: Sets the escape character + :param input_fields_terminated_by: Sets the input field separator + :param input_lines_terminated_by: Sets the input end-of-line character + :param input_optionally_enclosed_by: Sets a field enclosing character + :param batch: Use batch mode for underlying statement execution + :param direct: Use direct export fast path + :param driver: Manually specify JDBC driver class to use + :param verbose: Switch to more verbose logging for debug purposes + :param relaxed_isolation: use read uncommitted isolation level + :param hcatalog_database: Specifies the database name for the HCatalog table + :param hcatalog_table: The argument value for this option is the HCatalog table + :param properties: additional JVM properties passed to sqoop + """ + super(SqoopOperator, self).__init__(*args, **kwargs) + self.conn_id = conn_id + self.cmd_type = cmd_type + self.table = table + self.query = query + self.target_dir = target_dir + self.append = append + self.file_type = file_type + self.columns = columns + self.num_mappers = num_mappers + self.split_by = split_by + self.where = where + self.export_dir = export_dir + self.input_null_string = input_null_string + self.input_null_non_string = input_null_non_string + self.staging_table = staging_table + self.clear_staging_table = clear_staging_table + self.enclosed_by = enclosed_by + self.escaped_by = escaped_by + self.input_fields_terminated_by = input_fields_terminated_by + self.input_lines_terminated_by = input_lines_terminated_by + self.input_optionally_enclosed_by = input_optionally_enclosed_by + self.batch = batch + self.direct = direct + self.driver = driver + self.verbose = verbose + self.relaxed_isolation = relaxed_isolation + self.hcatalog_database = hcatalog_database + self.hcatalog_table = hcatalog_table + # No mutable types in the default parameters + if properties is None: + properties = {} + self.properties = properties + + def execute(self, context): + """ + Execute sqoop job + """ + hook = SqoopHook(conn_id=self.conn_id, + verbose=self.verbose, + num_mappers=self.num_mappers, + hcatalog_database=self.hcatalog_database, + hcatalog_table=self.hcatalog_table, + properties=self.properties) + + if self.cmd_type == 'export': + hook.export_table( + table=self.table, + export_dir=self.export_dir, + input_null_string=self.input_null_string, + input_null_non_string=self.input_null_non_string, + staging_table=self.staging_table, + clear_staging_table=self.clear_staging_table, + enclosed_by=self.enclosed_by, + escaped_by=self.escaped_by, + input_fields_terminated_by=self.input_fields_terminated_by, + input_lines_terminated_by=self.input_lines_terminated_by, + input_optionally_enclosed_by=self.input_optionally_enclosed_by, + batch=self.batch, + relaxed_isolation=self.relaxed_isolation) + elif self.cmd_type == 'import': + if not self.table: + hook.import_table( + table=self.table, + target_dir=self.target_dir, + append=self.append, + file_type=self.file_type, + columns=self.columns, + split_by=self.split_by, + where=self.where, + direct=self.direct, + driver=self.driver) + elif not self.query: + hook.import_query( + query=self.table, + target_dir=self.target_dir, + append=self.append, + file_type=self.file_type, + split_by=self.split_by, + direct=self.direct, + driver=self.driver) + else: + raise AirflowException( + "Provide query or table parameter to import using Sqoop" + ) + else: + raise AirflowException("cmd_type should be 'import' or 'export'") diff --git a/airflow/contrib/operators/ssh_operator.py b/airflow/contrib/operators/ssh_operator.py new file mode 100644 index 0000000000000..897cd1a92bd72 --- /dev/null +++ b/airflow/contrib/operators/ssh_operator.py @@ -0,0 +1,113 @@ +# -*- coding: utf-8 -*- +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from base64 import b64encode + +from airflow import configuration +from airflow.contrib.hooks.ssh_hook import SSHHook +from airflow.exceptions import AirflowException +from airflow.models import BaseOperator +from airflow.utils.decorators import apply_defaults + + +class SSHOperator(BaseOperator): + + """ + SSHOperator to execute commands on given remote host using the ssh_hook. + + :param ssh_hook: predefined ssh_hook to use for remote execution + :type ssh_hook: :class:`SSHHook` + :param ssh_conn_id: connection id from airflow Connections + :type ssh_conn_id: str + :param remote_host: remote host to connect + :type remote_host: str + :param command: command to execute on remote host + :type command: str + :param timeout: timeout for executing the command. + :type timeout: int + :param do_xcom_push: return the stdout which also get set in xcom by airflow platform + :type do_xcom_push: bool + """ + + template_fields = ('command',) + + @apply_defaults + def __init__(self, + ssh_hook=None, + ssh_conn_id=None, + remote_host=None, + command=None, + timeout=10, + do_xcom_push=False, + *args, + **kwargs): + super(SSHOperator, self).__init__(*args, **kwargs) + self.ssh_hook = ssh_hook + self.ssh_conn_id = ssh_conn_id + self.remote_host = remote_host + self.command = command + self.timeout = timeout + self.do_xcom_push = do_xcom_push + + def execute(self, context): + try: + if self.ssh_conn_id and not self.ssh_hook: + self.ssh_hook = SSHHook(ssh_conn_id=self.ssh_conn_id) + + if not self.ssh_hook: + raise AirflowException("can not operate without ssh_hook or ssh_conn_id") + + if self.remote_host is not None: + self.ssh_hook.remote_host = self.remote_host + + ssh_client = self.ssh_hook.get_conn() + + if not self.command: + raise AirflowException("no command specified so nothing to execute here.") + + # Auto apply tty when its required in case of sudo + get_pty = False + if self.command.startswith('sudo'): + get_pty = True + + # set timeout taken as params + stdin, stdout, stderr = ssh_client.exec_command(command=self.command, + get_pty=get_pty, + timeout=self.timeout + ) + exit_status = stdout.channel.recv_exit_status() + if exit_status is 0: + # only returning on output if do_xcom_push is set + # otherwise its not suppose to be disclosed + if self.do_xcom_push: + enable_pickling = configuration.getboolean('core', + 'enable_xcom_pickling') + if enable_pickling: + return stdout.read() + else: + return b64encode(stdout.read()).decode('utf-8') + + else: + error_msg = stderr.read() + raise AirflowException("error running cmd: {0}, error: {1}" + .format(self.command, error_msg)) + + except Exception as e: + raise AirflowException("SSH operator error: {0}".format(str(e))) + + return True + + def tunnel(self): + ssh_client = self.ssh_hook.get_conn() + ssh_client.get_transport() diff --git a/airflow/contrib/operators/vertica_operator.py b/airflow/contrib/operators/vertica_operator.py new file mode 100644 index 0000000000000..7a319f2ccbee2 --- /dev/null +++ b/airflow/contrib/operators/vertica_operator.py @@ -0,0 +1,44 @@ +# -*- coding: utf-8 -*- +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +from airflow.contrib.hooks.vertica_hook import VerticaHook +from airflow.models import BaseOperator +from airflow.utils.decorators import apply_defaults + + +class VerticaOperator(BaseOperator): + """ + Executes sql code in a specific Vertica database + + :param vertica_conn_id: reference to a specific Vertica database + :type vertica_conn_id: string + :param sql: the sql code to be executed + :type sql: Can receive a str representing a sql statement, + a list of str (sql statements), or reference to a template file. + Template reference are recognized by str ending in '.sql' + """ + + template_fields = ('sql',) + template_ext = ('.sql',) + ui_color = '#b4e0ff' + + @apply_defaults + def __init__(self, sql, vertica_conn_id='vertica_default', *args, **kwargs): + super(VerticaOperator, self).__init__(*args, **kwargs) + self.vertica_conn_id = vertica_conn_id + self.sql = sql + + def execute(self, context): + self.log.info('Executing: %s', self.sql) + hook = VerticaHook(vertica_conn_id=self.vertica_conn_id) + hook.run(self.sql) diff --git a/airflow/contrib/operators/vertica_to_hive.py b/airflow/contrib/operators/vertica_to_hive.py new file mode 100644 index 0000000000000..5e769fcd903de --- /dev/null +++ b/airflow/contrib/operators/vertica_to_hive.py @@ -0,0 +1,130 @@ +# -*- coding: utf-8 -*- +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from builtins import chr +from collections import OrderedDict +import unicodecsv as csv +from tempfile import NamedTemporaryFile + +from airflow.hooks.hive_hooks import HiveCliHook +from airflow.contrib.hooks.vertica_hook import VerticaHook +from airflow.models import BaseOperator +from airflow.utils.decorators import apply_defaults + + +class VerticaToHiveTransfer(BaseOperator): + """ + Moves data from Vertia to Hive. The operator runs + your query against Vertia, stores the file locally + before loading it into a Hive table. If the ``create`` or + ``recreate`` arguments are set to ``True``, + a ``CREATE TABLE`` and ``DROP TABLE`` statements are generated. + Hive data types are inferred from the cursor's metadata. + Note that the table generated in Hive uses ``STORED AS textfile`` + which isn't the most efficient serialization format. If a + large amount of data is loaded and/or if the table gets + queried considerably, you may want to use this operator only to + stage the data into a temporary table before loading it into its + final destination using a ``HiveOperator``. + + :param sql: SQL query to execute against the Vertia database + :type sql: str + :param hive_table: target Hive table, use dot notation to target a + specific database + :type hive_table: str + :param create: whether to create the table if it doesn't exist + :type create: bool + :param recreate: whether to drop and recreate the table at every execution + :type recreate: bool + :param partition: target partition as a dict of partition columns and values + :type partition: dict + :param delimiter: field delimiter in the file + :type delimiter: str + :param vertica_conn_id: source Vertica connection + :type vertica_conn_id: str + :param hive_conn_id: destination hive connection + :type hive_conn_id: str + + """ + + template_fields = ('sql', 'partition', 'hive_table') + template_ext = ('.sql',) + ui_color = '#b4e0ff' + + @apply_defaults + def __init__( + self, + sql, + hive_table, + create=True, + recreate=False, + partition=None, + delimiter=chr(1), + vertica_conn_id='vertica_default', + hive_cli_conn_id='hive_cli_default', + *args, **kwargs): + super(VerticaToHiveTransfer, self).__init__(*args, **kwargs) + self.sql = sql + self.hive_table = hive_table + self.partition = partition + self.create = create + self.recreate = recreate + self.delimiter = str(delimiter) + self.vertica_conn_id = vertica_conn_id + self.hive_cli_conn_id = hive_cli_conn_id + self.partition = partition or {} + + @classmethod + def type_map(cls, vertica_type): + # vertica-python datatype.py donot provied the full type mapping access. + # Manual hack. Reference: https://github.com/uber/vertica-python/blob/master/vertica_python/vertica/column.py + d = { + 5: 'BOOLEAN', + 6: 'INT', + 7: 'FLOAT', + 8: 'STRING', + 9: 'STRING', + 16: 'FLOAT', + } + return d[vertica_type] if vertica_type in d else 'STRING' + + def execute(self, context): + hive = HiveCliHook(hive_cli_conn_id=self.hive_cli_conn_id) + vertica = VerticaHook(vertica_conn_id=self.vertica_conn_id) + + self.log.info("Dumping Vertica query results to local file") + conn = vertica.get_conn() + cursor = conn.cursor() + cursor.execute(self.sql) + with NamedTemporaryFile("w") as f: + csv_writer = csv.writer(f, delimiter=self.delimiter, encoding='utf-8') + field_dict = OrderedDict() + col_count = 0 + for field in cursor.description: + col_count += 1 + col_position = "Column{position}".format(position=col_count) + field_dict[col_position if field[0] == '' else field[0]] = self.type_map(field[1]) + csv_writer.writerows(cursor.iterate()) + f.flush() + cursor.close() + conn.close() + self.log.info("Loading file into Hive") + hive.load_file( + f.name, + self.hive_table, + field_dict=field_dict, + create=self.create, + partition=self.partition, + delimiter=self.delimiter, + recreate=self.recreate) diff --git a/airflow/contrib/plugins/metastore_browser/main.py b/airflow/contrib/plugins/metastore_browser/main.py new file mode 100644 index 0000000000000..b3754daafbbe8 --- /dev/null +++ b/airflow/contrib/plugins/metastore_browser/main.py @@ -0,0 +1,173 @@ +# -*- coding: utf-8 -*- +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from datetime import datetime +import json + +from flask import Blueprint, request +from flask_admin import BaseView, expose +import pandas as pd + +from airflow.hooks.hive_hooks import HiveMetastoreHook, HiveCliHook +from airflow.hooks.mysql_hook import MySqlHook +from airflow.hooks.presto_hook import PrestoHook +from airflow.plugins_manager import AirflowPlugin +from airflow.www import utils as wwwutils + +METASTORE_CONN_ID = 'metastore_default' +METASTORE_MYSQL_CONN_ID = 'metastore_mysql' +PRESTO_CONN_ID = 'presto_default' +HIVE_CLI_CONN_ID = 'hive_default' +DEFAULT_DB = 'default' +DB_WHITELIST = None +DB_BLACKLIST = ['tmp'] +TABLE_SELECTOR_LIMIT = 2000 + +# Keeping pandas from truncating long strings +pd.set_option('display.max_colwidth', -1) + + +# Creating a flask admin BaseView +class MetastoreBrowserView(BaseView, wwwutils.DataProfilingMixin): + + @expose('/') + def index(self): + sql = """ + SELECT + a.name as db, db_location_uri as location, + count(1) as object_count, a.desc as description + FROM DBS a + JOIN TBLS b ON a.DB_ID = b.DB_ID + GROUP BY a.name, db_location_uri, a.desc + """.format(**locals()) + h = MySqlHook(METASTORE_MYSQL_CONN_ID) + df = h.get_pandas_df(sql) + df.db = ( + '' + df.db + '') + table = df.to_html( + classes="table table-striped table-bordered table-hover", + index=False, + escape=False, + na_rep='',) + return self.render( + "metastore_browser/dbs.html", table=table) + + @expose('/table/') + def table(self): + table_name = request.args.get("table") + m = HiveMetastoreHook(METASTORE_CONN_ID) + table = m.get_table(table_name) + return self.render( + "metastore_browser/table.html", + table=table, table_name=table_name, datetime=datetime, int=int) + + @expose('/db/') + def db(self): + db = request.args.get("db") + m = HiveMetastoreHook(METASTORE_CONN_ID) + tables = sorted(m.get_tables(db=db), key=lambda x: x.tableName) + return self.render( + "metastore_browser/db.html", tables=tables, db=db) + + @wwwutils.gzipped + @expose('/partitions/') + def partitions(self): + schema, table = request.args.get("table").split('.') + sql = """ + SELECT + a.PART_NAME, + a.CREATE_TIME, + c.LOCATION, + c.IS_COMPRESSED, + c.INPUT_FORMAT, + c.OUTPUT_FORMAT + FROM PARTITIONS a + JOIN TBLS b ON a.TBL_ID = b.TBL_ID + JOIN DBS d ON b.DB_ID = d.DB_ID + JOIN SDS c ON a.SD_ID = c.SD_ID + WHERE + b.TBL_NAME like '{table}' AND + d.NAME like '{schema}' + ORDER BY PART_NAME DESC + """.format(**locals()) + h = MySqlHook(METASTORE_MYSQL_CONN_ID) + df = h.get_pandas_df(sql) + return df.to_html( + classes="table table-striped table-bordered table-hover", + index=False, + na_rep='',) + + @wwwutils.gzipped + @expose('/objects/') + def objects(self): + where_clause = '' + if DB_WHITELIST: + dbs = ",".join(["'" + db + "'" for db in DB_WHITELIST]) + where_clause = "AND b.name IN ({})".format(dbs) + if DB_BLACKLIST: + dbs = ",".join(["'" + db + "'" for db in DB_BLACKLIST]) + where_clause = "AND b.name NOT IN ({})".format(dbs) + sql = """ + SELECT CONCAT(b.NAME, '.', a.TBL_NAME), TBL_TYPE + FROM TBLS a + JOIN DBS b ON a.DB_ID = b.DB_ID + WHERE + a.TBL_NAME NOT LIKE '%tmp%' AND + a.TBL_NAME NOT LIKE '%temp%' AND + b.NAME NOT LIKE '%tmp%' AND + b.NAME NOT LIKE '%temp%' + {where_clause} + LIMIT {LIMIT}; + """.format(where_clause=where_clause, LIMIT=TABLE_SELECTOR_LIMIT) + h = MySqlHook(METASTORE_MYSQL_CONN_ID) + d = [ + {'id': row[0], 'text': row[0]} + for row in h.get_records(sql)] + return json.dumps(d) + + @wwwutils.gzipped + @expose('/data/') + def data(self): + table = request.args.get("table") + sql = "SELECT * FROM {table} LIMIT 1000;".format(table=table) + h = PrestoHook(PRESTO_CONN_ID) + df = h.get_pandas_df(sql) + return df.to_html( + classes="table table-striped table-bordered table-hover", + index=False, + na_rep='',) + + @expose('/ddl/') + def ddl(self): + table = request.args.get("table") + sql = "SHOW CREATE TABLE {table};".format(table=table) + h = HiveCliHook(HIVE_CLI_CONN_ID) + return h.run_cli(sql) + +v = MetastoreBrowserView(category="Plugins", name="Hive Metadata Browser") + +# Creating a flask blueprint to intergrate the templates and static folder +bp = Blueprint( + "metastore_browser", __name__, + template_folder='templates', + static_folder='static', + static_url_path='/static/metastore_browser') + + +# Defining the plugin class +class MetastoreBrowserPlugin(AirflowPlugin): + name = "metastore_browser" + flask_blueprints = [bp] + admin_views = [v] diff --git a/airflow/contrib/plugins/metastore_browser/templates/metastore_browser/base.html b/airflow/contrib/plugins/metastore_browser/templates/metastore_browser/base.html new file mode 100644 index 0000000000000..ba1e7e2890c6e --- /dev/null +++ b/airflow/contrib/plugins/metastore_browser/templates/metastore_browser/base.html @@ -0,0 +1,55 @@ +{# + Licensed to the Apache Software Foundation (ASF) under one or more + contributor license agreements. See the NOTICE file distributed with + this work for additional information regarding copyright ownership. + The ASF licenses this file to You under the Apache License, Version 2.0 + (the "License"); you may not use this file except in compliance with + the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + +#} +{% extends 'airflow/master.html' %} + +{% block body %} +
+

+ {% block page_header %}Hive Metastore Browser{% endblock%} +

+
+
+
+{% block plugin_content %}{% endblock %} +{% endblock %} + +{% block head %} +{{ super() }} + + +{% endblock %} + +{% block tail %} +{{ super() }} + + + + +{% endblock %} diff --git a/airflow/contrib/plugins/metastore_browser/templates/metastore_browser/db.html b/airflow/contrib/plugins/metastore_browser/templates/metastore_browser/db.html new file mode 100644 index 0000000000000..846832d7ecc44 --- /dev/null +++ b/airflow/contrib/plugins/metastore_browser/templates/metastore_browser/db.html @@ -0,0 +1,45 @@ +{# + Licensed to the Apache Software Foundation (ASF) under one or more + contributor license agreements. See the NOTICE file distributed with + this work for additional information regarding copyright ownership. + The ASF licenses this file to You under the Apache License, Version 2.0 + (the "License"); you may not use this file except in compliance with + the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + +#} +{% extends 'metastore_browser/base.html' %} + +{% block plugin_content %} +

+ Database: + {{ db }} +

+
+ + + + + + + + {%for table in tables %} + + + + + {% endfor %} + +
TableOwner
+ + {{ table.tableName }} + + {{ table.owner }}
+{% endblock %} diff --git a/airflow/contrib/plugins/metastore_browser/templates/metastore_browser/dbs.html b/airflow/contrib/plugins/metastore_browser/templates/metastore_browser/dbs.html new file mode 100644 index 0000000000000..a414522f0cafe --- /dev/null +++ b/airflow/contrib/plugins/metastore_browser/templates/metastore_browser/dbs.html @@ -0,0 +1,25 @@ +{# + Licensed to the Apache Software Foundation (ASF) under one or more + contributor license agreements. See the NOTICE file distributed with + this work for additional information regarding copyright ownership. + The ASF licenses this file to You under the Apache License, Version 2.0 + (the "License"); you may not use this file except in compliance with + the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + +#} +{% extends 'metastore_browser/base.html' %} + +{% block plugin_content %} +

+ Hive Databases +

+ {{ table|safe }} +{% endblock %} diff --git a/airflow/contrib/plugins/metastore_browser/templates/metastore_browser/table.html b/airflow/contrib/plugins/metastore_browser/templates/metastore_browser/table.html new file mode 100644 index 0000000000000..3a9d7ca671ad2 --- /dev/null +++ b/airflow/contrib/plugins/metastore_browser/templates/metastore_browser/table.html @@ -0,0 +1,151 @@ +{# + Licensed to the Apache Software Foundation (ASF) under one or more + contributor license agreements. See the NOTICE file distributed with + this work for additional information regarding copyright ownership. + The ASF licenses this file to You under the Apache License, Version 2.0 + (the "License"); you may not use this file except in compliance with + the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + +#} +{% extends 'metastore_browser/base.html' %} + +{% block plugin_content %} +
+

+ Table: + {{ table.dbName }}.{{ table.tableName }} +

+
+ +
+
+ + + + + + + + + + {%for col in table.partitionKeys %} + + + + + + {% endfor %} + {%for col in table.sd.cols %} + + + + + + {% endfor %} + +
ColumnTypeComment
{{ col.name }} (PARTITION){{ col.type }}{{ col.comment or '' }}
{{ col.name }}{{ col.type }}{{ col.comment or '' }}
+
+
+ +
+
+ +
+
+ + + + + + + + + {%for k, v in table.__dict__.items() %} + {% if v and k not in ('sd', 'partitionKeys', 'tableName', 'parameters') %} + + + {% if k.endswith('Time') %} + + {% else %} + + {% endif %} + + {% endif %} + {% endfor %} + +
AttributeValue
{{ k }}{{ datetime.fromtimestamp(int(v)).isoformat() }}{{ v }}
+
+
+ + + + + + + + + {%for k, v in table.parameters.items() %} + {% if v and k not in [] %} + + + {% if k.endswith('Time') %} + + {% else %} + + {% endif %} + + {% endif %} + {% endfor %} + +
ParameterValue
{{ k }}{{ datetime.fromtimestamp(int(v)).isoformat() }}{{ v }}
+
+
+
+        
+      
+
+
+{% endblock %} + +{% block tail %} +{{ super() }} + +{% endblock %} diff --git a/airflow/contrib/sensors/__init__.py b/airflow/contrib/sensors/__init__.py new file mode 100644 index 0000000000000..9d7677a99b293 --- /dev/null +++ b/airflow/contrib/sensors/__init__.py @@ -0,0 +1,13 @@ +# -*- coding: utf-8 -*- +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. diff --git a/airflow/contrib/sensors/bigquery_sensor.py b/airflow/contrib/sensors/bigquery_sensor.py new file mode 100644 index 0000000000000..90a3264b6d711 --- /dev/null +++ b/airflow/contrib/sensors/bigquery_sensor.py @@ -0,0 +1,66 @@ +# -*- coding: utf-8 -*- +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +from airflow.operators.sensors import BaseSensorOperator +from airflow.contrib.hooks.bigquery_hook import BigQueryHook +from airflow.utils.decorators import apply_defaults + + +class BigQueryTableSensor(BaseSensorOperator): + """ + Checks for the existence of a table in Google Bigquery. + """ + template_fields = ('project_id', 'dataset_id', 'table_id',) + ui_color = '#f0eee4' + + @apply_defaults + def __init__( + self, + project_id, + dataset_id, + table_id, + bigquery_conn_id='bigquery_default_conn', + delegate_to=None, + *args, + **kwargs): + """ + Create a new BigQueryTableSensor. + + :param project_id: The Google cloud project in which to look for the table. The connection supplied to the hook + must provide access to the specified project. + :type project_id: string + :param dataset_id: The name of the dataset in which to look for the table. + storage bucket. + :type dataset_id: string + :param table_id: The name of the table to check the existence of. + :type table_id: string + :param bigquery_conn_id: The connection ID to use when connecting to Google BigQuery. + :type bigquery_conn_id: string + :param delegate_to: The account to impersonate, if any. + For this to work, the service account making the request must have domain-wide delegation enabled. + :type delegate_to: string + """ + super(BigQueryTableSensor, self).__init__(*args, **kwargs) + self.project_id = project_id + self.dataset_id = dataset_id + self.table_id = table_id + self.bigquery_conn_id = bigquery_conn_id + self.delegate_to = delegate_to + + def poke(self, context): + table_uri = '{0}:{1}.{2}'.format(self.project_id, self.dataset_id, self.table_id) + self.log.info('Sensor checks existence of table: %s', table_uri) + hook = BigQueryHook( + bigquery_conn_id=self.bigquery_conn_id, + delegate_to=self.delegate_to) + return hook.table_exists(self.project_id, self.dataset_id, self.table_id) diff --git a/airflow/contrib/sensors/datadog_sensor.py b/airflow/contrib/sensors/datadog_sensor.py new file mode 100644 index 0000000000000..e1a916974332c --- /dev/null +++ b/airflow/contrib/sensors/datadog_sensor.py @@ -0,0 +1,78 @@ +# -*- coding: utf-8 -*- +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +from airflow.operators.sensors import BaseSensorOperator +from airflow.contrib.hooks.datadog_hook import DatadogHook +from airflow.utils import apply_defaults +from airflow.exceptions import AirflowException +from datadog import api + + +class DatadogSensor(BaseSensorOperator): + """ + A sensor to listen, with a filter, to datadog event streams and determine + if some event was emitted. + + Depends on the datadog API, which has to be deployed on the same server where + Airflow runs. + + :param datadog_conn_id: The connection to datadog, containing metadata for api keys. + :param datadog_conn_id: string + """ + ui_color = '#66c3dd' + + @apply_defaults + def __init__( + self, + datadog_conn_id='datadog_default', + from_seconds_ago=3600, + up_to_seconds_from_now=0, + priority=None, + sources=None, + tags=None, + response_check=None, + *args, + **kwargs): + super(DatadogSensor, self).__init__(*args, **kwargs) + self.datadog_conn_id = datadog_conn_id + self.from_seconds_ago = from_seconds_ago + self.up_to_seconds_from_now = up_to_seconds_from_now + self.priority = priority + self.sources = sources + self.tags = tags + self.response_check = response_check + + def poke(self, context): + # This instantiates the hook, but doesn't need it further, + # because the API authenticates globally (unfortunately), + # but for airflow this shouldn't matter too much, because each + # task instance runs in its own process anyway. + DatadogHook(datadog_conn_id=self.datadog_conn_id) + + response = api.Event.query( + start=self.from_seconds_ago, + end=self.up_to_seconds_from_now, + priority=self.priority, + sources=self.sources, + tags=self.tags) + + if isinstance(response, dict) and response.get('status', 'ok') != 'ok': + self.log.error("Unexpected Datadog result: %s", response) + raise AirflowException("Datadog returned unexpected result") + + if self.response_check: + # run content check on response + return self.response_check(response) + + # If no check was inserted, assume any event that matched yields true. + return len(response) > 0 diff --git a/airflow/contrib/sensors/emr_base_sensor.py b/airflow/contrib/sensors/emr_base_sensor.py new file mode 100644 index 0000000000000..3ecaa42781635 --- /dev/null +++ b/airflow/contrib/sensors/emr_base_sensor.py @@ -0,0 +1,51 @@ +# -*- coding: utf-8 -*- +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +from airflow.operators.sensors import BaseSensorOperator +from airflow.utils import apply_defaults +from airflow.exceptions import AirflowException + + +class EmrBaseSensor(BaseSensorOperator): + """ + Contains general sensor behavior for EMR. + Subclasses should implement get_emr_response() and state_from_response() methods. + Subclasses should also implement NON_TERMINAL_STATES and FAILED_STATE constants. + """ + ui_color = '#66c3ff' + + @apply_defaults + def __init__( + self, + aws_conn_id='aws_default', + *args, **kwargs): + super(EmrBaseSensor, self).__init__(*args, **kwargs) + self.aws_conn_id = aws_conn_id + + def poke(self, context): + response = self.get_emr_response() + + if not response['ResponseMetadata']['HTTPStatusCode'] == 200: + self.log.info('Bad HTTP response: %s', response) + return False + + state = self.state_from_response(response) + self.log.info('Job flow currently %s', state) + + if state in self.NON_TERMINAL_STATES: + return False + + if state == self.FAILED_STATE: + raise AirflowException('EMR job failed') + + return True diff --git a/airflow/contrib/sensors/emr_job_flow_sensor.py b/airflow/contrib/sensors/emr_job_flow_sensor.py new file mode 100644 index 0000000000000..87b65c884593e --- /dev/null +++ b/airflow/contrib/sensors/emr_job_flow_sensor.py @@ -0,0 +1,48 @@ +# -*- coding: utf-8 -*- +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +from airflow.contrib.hooks.emr_hook import EmrHook +from airflow.contrib.sensors.emr_base_sensor import EmrBaseSensor +from airflow.utils import apply_defaults + + +class EmrJobFlowSensor(EmrBaseSensor): + """ + Asks for the state of the JobFlow until it reaches a terminal state. + If it fails the sensor errors, failing the task. + + :param job_flow_id: job_flow_id to check the state of + :type job_flow_id: string + """ + + NON_TERMINAL_STATES = ['STARTING', 'BOOTSTRAPPING', 'RUNNING', 'WAITING', 'TERMINATING'] + FAILED_STATE = 'TERMINATED_WITH_ERRORS' + template_fields = ['job_flow_id'] + template_ext = () + + @apply_defaults + def __init__( + self, + job_flow_id, + *args, **kwargs): + super(EmrJobFlowSensor, self).__init__(*args, **kwargs) + self.job_flow_id = job_flow_id + + def get_emr_response(self): + emr = EmrHook(aws_conn_id=self.aws_conn_id).get_conn() + + self.log.info('Poking cluster %s', self.job_flow_id) + return emr.describe_cluster(ClusterId=self.job_flow_id) + + def state_from_response(self, response): + return response['Cluster']['Status']['State'] diff --git a/airflow/contrib/sensors/emr_step_sensor.py b/airflow/contrib/sensors/emr_step_sensor.py new file mode 100644 index 0000000000000..003d2d1a06c17 --- /dev/null +++ b/airflow/contrib/sensors/emr_step_sensor.py @@ -0,0 +1,52 @@ +# -*- coding: utf-8 -*- +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +from airflow.contrib.hooks.emr_hook import EmrHook +from airflow.contrib.sensors.emr_base_sensor import EmrBaseSensor +from airflow.utils import apply_defaults + + +class EmrStepSensor(EmrBaseSensor): + """ + Asks for the state of the step until it reaches a terminal state. + If it fails the sensor errors, failing the task. + + :param job_flow_id: job_flow_idwhich contains the step check the state of + :type job_flow_id: string + :param step_id: step to check the state of + :type step_id: string + """ + + NON_TERMINAL_STATES = ['PENDING', 'RUNNING', 'CONTINUE'] + FAILED_STATE = 'FAILED' + template_fields = ['job_flow_id', 'step_id'] + template_ext = () + + @apply_defaults + def __init__( + self, + job_flow_id, + step_id, + *args, **kwargs): + super(EmrStepSensor, self).__init__(*args, **kwargs) + self.job_flow_id = job_flow_id + self.step_id = step_id + + def get_emr_response(self): + emr = EmrHook(aws_conn_id=self.aws_conn_id).get_conn() + + self.log.info('Poking step %s on cluster %s', self.step_id, self.job_flow_id) + return emr.describe_step(ClusterId=self.job_flow_id, StepId=self.step_id) + + def state_from_response(self, response): + return response['Step']['Status']['State'] diff --git a/airflow/contrib/sensors/ftp_sensor.py b/airflow/contrib/sensors/ftp_sensor.py new file mode 100644 index 0000000000000..bd66c32c184c6 --- /dev/null +++ b/airflow/contrib/sensors/ftp_sensor.py @@ -0,0 +1,62 @@ +# -*- coding: utf-8 -*- +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +import ftplib + +from airflow.contrib.hooks.ftp_hook import FTPHook, FTPSHook +from airflow.operators.sensors import BaseSensorOperator +from airflow.utils.decorators import apply_defaults + + +class FTPSensor(BaseSensorOperator): + """ + Waits for a file or directory to be present on FTP. + + :param path: Remote file or directory path + :type path: str + :param ftp_conn_id: The connection to run the sensor against + :type ftp_conn_id: str + """ + template_fields = ('path',) + + @apply_defaults + def __init__(self, path, ftp_conn_id='ftp_default', *args, **kwargs): + super(FTPSensor, self).__init__(*args, **kwargs) + + self.path = path + self.ftp_conn_id = ftp_conn_id + + def _create_hook(self): + """Return connection hook.""" + return FTPHook(ftp_conn_id=self.ftp_conn_id) + + def poke(self, context): + with self._create_hook() as hook: + self.log.info('Poking for %s', self.path) + try: + hook.get_mod_time(self.path) + except ftplib.error_perm as e: + error = str(e).split(None, 1) + if error[1] != "Can't check for file existence": + raise e + + return False + + return True + + +class FTPSSensor(FTPSensor): + """Waits for a file or directory to be present on FTP over SSL.""" + def _create_hook(self): + """Return connection hook.""" + return FTPSHook(ftp_conn_id=self.ftp_conn_id) diff --git a/airflow/contrib/sensors/gcs_sensor.py b/airflow/contrib/sensors/gcs_sensor.py new file mode 100644 index 0000000000000..384e26fe04289 --- /dev/null +++ b/airflow/contrib/sensors/gcs_sensor.py @@ -0,0 +1,123 @@ +# -*- coding: utf-8 -*- +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +from airflow.contrib.hooks.gcs_hook import GoogleCloudStorageHook +from airflow.operators.sensors import BaseSensorOperator +from airflow.utils.decorators import apply_defaults + + +class GoogleCloudStorageObjectSensor(BaseSensorOperator): + """ + Checks for the existence of a file in Google Cloud Storage. + """ + template_fields = ('bucket', 'object') + ui_color = '#f0eee4' + + @apply_defaults + def __init__( + self, + bucket, + object, # pylint:disable=redefined-builtin + google_cloud_conn_id='google_cloud_storage_default', + delegate_to=None, + *args, + **kwargs): + """ + Create a new GoogleCloudStorageObjectSensor. + + :param bucket: The Google cloud storage bucket where the object is. + :type bucket: string + :param object: The name of the object to check in the Google cloud + storage bucket. + :type object: string + :param google_cloud_storage_conn_id: The connection ID to use when + connecting to Google cloud storage. + :type google_cloud_storage_conn_id: string + :param delegate_to: The account to impersonate, if any. + For this to work, the service account making the request must have domain-wide delegation enabled. + :type delegate_to: string + """ + super(GoogleCloudStorageObjectSensor, self).__init__(*args, **kwargs) + self.bucket = bucket + self.object = object + self.google_cloud_conn_id = google_cloud_conn_id + self.delegate_to = delegate_to + + def poke(self, context): + self.log.info('Sensor checks existence of : %s, %s', self.bucket, self.object) + hook = GoogleCloudStorageHook( + google_cloud_storage_conn_id=self.google_cloud_conn_id, + delegate_to=self.delegate_to) + return hook.exists(self.bucket, self.object) + + +def ts_function(context): + """ + Default callback for the GoogleCloudStorageObjectUpdatedSensor. The default + behaviour is check for the object being updated after execution_date + + schedule_interval. + """ + return context['execution_date'] + context['dag'].schedule_interval + + +class GoogleCloudStorageObjectUpdatedSensor(BaseSensorOperator): + """ + Checks if an object is updated in Google Cloud Storage. + """ + template_fields = ('bucket', 'object') + template_ext = ('.sql',) + ui_color = '#f0eee4' + + @apply_defaults + def __init__( + self, + bucket, + object, # pylint:disable=redefined-builtin + ts_func=ts_function, + google_cloud_conn_id='google_cloud_storage_default', + delegate_to=None, + *args, + **kwargs): + """ + Create a new GoogleCloudStorageObjectUpdatedSensor. + + :param bucket: The Google cloud storage bucket where the object is. + :type bucket: string + :param object: The name of the object to download in the Google cloud + storage bucket. + :type object: string + :param ts_func: Callback for defining the update condition. The default callback + returns execution_date + schedule_interval. The callback takes the context + as parameter. + :type ts_func: function + :param google_cloud_storage_conn_id: The connection ID to use when + connecting to Google cloud storage. + :type google_cloud_storage_conn_id: string + :param delegate_to: The account to impersonate, if any. + For this to work, the service account making the request must have domain-wide + delegation enabled. + :type delegate_to: string + """ + super(GoogleCloudStorageObjectUpdatedSensor, self).__init__(*args, **kwargs) + self.bucket = bucket + self.object = object + self.ts_func = ts_func + self.google_cloud_conn_id = google_cloud_conn_id + self.delegate_to = delegate_to + + def poke(self, context): + self.log.info('Sensor checks existence of : %s, %s', self.bucket, self.object) + hook = GoogleCloudStorageHook( + google_cloud_storage_conn_id=self.google_cloud_conn_id, + delegate_to=self.delegate_to) + return hook.is_updated_after(self.bucket, self.object, self.ts_func(context)) diff --git a/airflow/contrib/sensors/hdfs_sensors.py b/airflow/contrib/sensors/hdfs_sensors.py new file mode 100644 index 0000000000000..1893f01b366ce --- /dev/null +++ b/airflow/contrib/sensors/hdfs_sensors.py @@ -0,0 +1,66 @@ +# -*- coding: utf-8 -*- +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +from airflow.operators.sensors import HdfsSensor + + +class HdfsSensorRegex(HdfsSensor): + def __init__( + self, + regex, + *args, **kwargs): + super(HdfsSensorRegex, self).__init__(*args, **kwargs) + self.regex = regex + + def poke(self, context): + """ + poke matching files in a directory with self.regex + :return: Bool depending on the search criteria + """ + sb = self.hook(self.hdfs_conn_id).get_conn() + self.log.info( + 'Poking for {self.filepath} to be a directory with files matching {self.regex.pattern}'.format(**locals()) + ) + result = [f for f in sb.ls([self.filepath], include_toplevel=False) if + f['file_type'] == 'f' and self.regex.match(f['path'].replace('%s/' % self.filepath, ''))] + result = self.filter_for_ignored_ext(result, self.ignored_ext, self.ignore_copying) + result = self.filter_for_filesize(result, self.file_size) + return bool(result) + + +class HdfsSensorFolder(HdfsSensor): + def __init__( + self, + be_empty=False, + *args, **kwargs): + super(HdfsSensorFolder, self).__init__(*args, **kwargs) + self.be_empty = be_empty + + def poke(self, context): + """ + poke for a non empty directory + :return: Bool depending on the search criteria + """ + sb = self.hook(self.hdfs_conn_id).get_conn() + result = [f for f in sb.ls([self.filepath], include_toplevel=True)] + result = self.filter_for_ignored_ext(result, self.ignored_ext, self.ignore_copying) + result = self.filter_for_filesize(result, self.file_size) + if self.be_empty: + self.log.info('Poking for filepath {self.filepath} to a empty directory'.format(**locals())) + return len(result) == 1 and result[0]['path'] == self.filepath + else: + self.log.info('Poking for filepath {self.filepath} to a non empty directory'.format(**locals())) + result.pop(0) + return bool(result) and result[0]['file_type'] == 'f' + + diff --git a/airflow/contrib/sensors/jira_sensor.py b/airflow/contrib/sensors/jira_sensor.py new file mode 100644 index 0000000000000..1dc7b50d29a50 --- /dev/null +++ b/airflow/contrib/sensors/jira_sensor.py @@ -0,0 +1,141 @@ +# -*- coding: utf-8 -*- +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +from jira.resources import Resource + +from airflow.contrib.operators.jira_operator import JIRAError +from airflow.contrib.operators.jira_operator import JiraOperator +from airflow.operators.sensors import BaseSensorOperator +from airflow.utils.decorators import apply_defaults + + +class JiraSensor(BaseSensorOperator): + """ + Monitors a jira ticket for any change. + + :param jira_conn_id: reference to a pre-defined Jira Connection + :type jira_conn_id: str + :param method_name: method name from jira-python-sdk to be execute + :type method_name: str + :param method_params: parameters for the method method_name + :type method_params: dict + :param result_processor: function that return boolean and act as a sensor response + :type result_processor: function + """ + + @apply_defaults + def __init__(self, + jira_conn_id='jira_default', + method_name=None, + method_params=None, + result_processor=None, + *args, + **kwargs): + super(JiraSensor, self).__init__(*args, **kwargs) + self.jira_conn_id = jira_conn_id + self.result_processor = None + if result_processor is not None: + self.result_processor = result_processor + self.method_name = method_name + self.method_params = method_params + self.jira_operator = JiraOperator(task_id=self.task_id, + jira_conn_id=self.jira_conn_id, + jira_method=self.method_name, + jira_method_args=self.method_params, + result_processor=self.result_processor) + + def poke(self, context): + return self.jira_operator.execute(context=context) + + +class JiraTicketSensor(JiraSensor): + """ + Monitors a jira ticket for given change in terms of function. + + :param jira_conn_id: reference to a pre-defined Jira Connection + :type jira_conn_id: str + :param ticket_id: id of the ticket to be monitored + :type ticket_id: str + :param field: field of the ticket to be monitored + :type field: str + :param expected_value: expected value of the field + :type expected_value: str + :param result_processor: function that return boolean and act as a sensor response + :type result_processor: function + """ + + template_fields = ("ticket_id",) + + @apply_defaults + def __init__(self, + jira_conn_id='jira_default', + ticket_id=None, + field=None, + expected_value=None, + field_checker_func=None, + *args, **kwargs): + + self.jira_conn_id = jira_conn_id + self.ticket_id = ticket_id + self.field = field + self.expected_value = expected_value + if field_checker_func is None: + field_checker_func = self.issue_field_checker + + super(JiraTicketSensor, self).__init__(jira_conn_id=jira_conn_id, + result_processor=field_checker_func, + *args, **kwargs) + + def poke(self, context): + self.log.info('Jira Sensor checking for change in ticket: %s', self.ticket_id) + + self.jira_operator.method_name = "issue" + self.jira_operator.jira_method_args = { + 'id': self.ticket_id, + 'fields': self.field + } + return JiraSensor.poke(self, context=context) + + def issue_field_checker(self, context, issue): + result = None + try: + if issue is not None \ + and self.field is not None \ + and self.expected_value is not None: + + field_value = getattr(issue.fields, self.field) + if field_value is not None: + if isinstance(field_value, list): + result = self.expected_value in field_value + elif isinstance(field_value, str): + result = self.expected_value.lower() == field_value.lower() + elif isinstance(field_value, Resource) \ + and getattr(field_value, 'name'): + result = self.expected_value.lower() == field_value.name.lower() + else: + self.log.warning( + "Not implemented checker for issue field %s which " + "is neither string nor list nor Jira Resource", + self.field + ) + + except JIRAError as jira_error: + self.log.error("Jira error while checking with expected value: %s", jira_error) + except Exception as e: + self.log.error("Error while checking with expected value %s:", self.expected_value) + self.log.exception(e) + if result is True: + self.log.info("Issue field %s has expected value %s, returning success", self.field, self.expected_value) + else: + self.log.info("Issue field %s don't have expected value %s yet.", self.field, self.expected_value) + return result diff --git a/airflow/contrib/sensors/redis_key_sensor.py b/airflow/contrib/sensors/redis_key_sensor.py new file mode 100644 index 0000000000000..6cc314bb5881d --- /dev/null +++ b/airflow/contrib/sensors/redis_key_sensor.py @@ -0,0 +1,42 @@ +# -*- coding: utf-8 -*- +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +from airflow.contrib.hooks.redis_hook import RedisHook +from airflow.operators.sensors import BaseSensorOperator +from airflow.utils.decorators import apply_defaults + + +class RedisKeySensor(BaseSensorOperator): + """ + Checks for the existence of a key in a Redis database + """ + template_fields = ('key',) + ui_color = '#f0eee4' + + @apply_defaults + def __init__(self, key, redis_conn_id, *args, **kwargs): + """ + Create a new RedisKeySensor + + :param key: The key to be monitored + :type key: string + :param redis_conn_id: The connection ID to use when connecting to Redis DB. + :type redis_conn_id: string + """ + super(RedisKeySensor, self).__init__(*args, **kwargs) + self.redis_conn_id = redis_conn_id + self.key = key + + def poke(self, context): + self.log.info('Sensor check existence of key: %s', self.key) + return RedisHook(self.redis_conn_id).key_exists(self.key) diff --git a/airflow/contrib/sensors/wasb_sensor.py b/airflow/contrib/sensors/wasb_sensor.py new file mode 100644 index 0000000000000..4295a25141d05 --- /dev/null +++ b/airflow/contrib/sensors/wasb_sensor.py @@ -0,0 +1,94 @@ +# -*- coding: utf-8 -*- +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +from airflow.contrib.hooks.wasb_hook import WasbHook +from airflow.operators.sensors import BaseSensorOperator +from airflow.utils.decorators import apply_defaults + + +class WasbBlobSensor(BaseSensorOperator): + """ + Waits for a blob to arrive on Azure Blob Storage. + + :param container_name: Name of the container. + :type container_name: str + :param blob_name: Name of the blob. + :type blob_name: str + :param wasb_conn_id: Reference to the wasb connection. + :type wasb_conn_id: str + :param check_options: Optional keyword arguments that + `WasbHook.check_for_blob()` takes. + :type check_options: dict + """ + + template_fields = ('container_name', 'blob_name') + + @apply_defaults + def __init__(self, container_name, blob_name, + wasb_conn_id='wasb_default', check_options=None, *args, + **kwargs): + super(WasbBlobSensor, self).__init__(*args, **kwargs) + if check_options is None: + check_options = {} + self.wasb_conn_id = wasb_conn_id + self.container_name = container_name + self.blob_name = blob_name + self.check_options = check_options + + def poke(self, context): + self.log.info( + 'Poking for blob: {self.blob_name}\n' + 'in wasb://{self.container_name}'.format(**locals()) + ) + hook = WasbHook(wasb_conn_id=self.wasb_conn_id) + return hook.check_for_blob(self.container_name, self.blob_name, + **self.check_options) + + +class WasbPrefixSensor(BaseSensorOperator): + """ + Waits for blobs matching a prefix to arrive on Azure Blob Storage. + + :param container_name: Name of the container. + :type container_name: str + :param prefix: Prefix of the blob. + :type prefix: str + :param wasb_conn_id: Reference to the wasb connection. + :type wasb_conn_id: str + :param check_options: Optional keyword arguments that + `WasbHook.check_for_prefix()` takes. + :type check_options: dict + """ + + template_fields = ('container_name', 'prefix') + + @apply_defaults + def __init__(self, container_name, prefix, wasb_conn_id='wasb_default', + check_options=None, *args, **kwargs): + super(WasbPrefixSensor, self).__init__(*args, **kwargs) + if check_options is None: + check_options = {} + self.wasb_conn_id = wasb_conn_id + self.container_name = container_name + self.prefix = prefix + self.check_options = check_options + + def poke(self, context): + self.log.info( + 'Poking for prefix: {self.prefix}\n' + 'in wasb://{self.container_name}'.format(**locals()) + ) + hook = WasbHook(wasb_conn_id=self.wasb_conn_id) + return hook.check_for_prefix(self.container_name, self.prefix, + **self.check_options) diff --git a/airflow/contrib/task_runner/__init__.py b/airflow/contrib/task_runner/__init__.py new file mode 100644 index 0000000000000..9d7677a99b293 --- /dev/null +++ b/airflow/contrib/task_runner/__init__.py @@ -0,0 +1,13 @@ +# -*- coding: utf-8 -*- +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. diff --git a/airflow/contrib/task_runner/cgroup_task_runner.py b/airflow/contrib/task_runner/cgroup_task_runner.py new file mode 100644 index 0000000000000..87767a6a51edb --- /dev/null +++ b/airflow/contrib/task_runner/cgroup_task_runner.py @@ -0,0 +1,203 @@ +# -*- coding: utf-8 -*- +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import datetime +import getpass +import os +import uuid + +from cgroupspy import trees +import psutil + +from airflow.task_runner.base_task_runner import BaseTaskRunner +from airflow.utils.helpers import kill_process_tree + + +class CgroupTaskRunner(BaseTaskRunner): + """ + Runs the raw Airflow task in a cgroup that has containment for memory and + cpu. It uses the resource requirements defined in the task to construct + the settings for the cgroup. + + Note that this task runner will only work if the Airflow user has root privileges, + e.g. if the airflow user is called `airflow` then the following entries (or an even + less restrictive ones) are needed in the sudoers file (replacing + /CGROUPS_FOLDER with your system's cgroups folder, e.g. '/sys/fs/cgroup/'): + airflow ALL= (root) NOEXEC: /bin/chown /CGROUPS_FOLDER/memory/airflow/* + airflow ALL= (root) NOEXEC: !/bin/chown /CGROUPS_FOLDER/memory/airflow/*..* + airflow ALL= (root) NOEXEC: !/bin/chown /CGROUPS_FOLDER/memory/airflow/* * + airflow ALL= (root) NOEXEC: /bin/chown /CGROUPS_FOLDER/cpu/airflow/* + airflow ALL= (root) NOEXEC: !/bin/chown /CGROUPS_FOLDER/cpu/airflow/*..* + airflow ALL= (root) NOEXEC: !/bin/chown /CGROUPS_FOLDER/cpu/airflow/* * + airflow ALL= (root) NOEXEC: /bin/chmod /CGROUPS_FOLDER/memory/airflow/* + airflow ALL= (root) NOEXEC: !/bin/chmod /CGROUPS_FOLDER/memory/airflow/*..* + airflow ALL= (root) NOEXEC: !/bin/chmod /CGROUPS_FOLDER/memory/airflow/* * + airflow ALL= (root) NOEXEC: /bin/chmod /CGROUPS_FOLDER/cpu/airflow/* + airflow ALL= (root) NOEXEC: !/bin/chmod /CGROUPS_FOLDER/cpu/airflow/*..* + airflow ALL= (root) NOEXEC: !/bin/chmod /CGROUPS_FOLDER/cpu/airflow/* * + """ + + def __init__(self, local_task_job): + super(CgroupTaskRunner, self).__init__(local_task_job) + self.process = None + self._finished_running = False + self._cpu_shares = None + self._mem_mb_limit = None + self._created_cpu_cgroup = False + self._created_mem_cgroup = False + self._cur_user = getpass.getuser() + + def _create_cgroup(self, path): + """ + Create the specified cgroup. + + :param path: The path of the cgroup to create. + E.g. cpu/mygroup/mysubgroup + :return: the Node associated with the created cgroup. + :rtype: cgroupspy.nodes.Node + """ + node = trees.Tree().root + path_split = path.split(os.sep) + for path_element in path_split: + name_to_node = {x.name: x for x in node.children} + if path_element not in name_to_node: + self.log.debug("Creating cgroup %s in %s", path_element, node.path) + node = node.create_cgroup(path_element) + else: + self.log.debug( + "Not creating cgroup %s in %s since it already exists", + path_element, node.path + ) + node = name_to_node[path_element] + return node + + def _delete_cgroup(self, path): + """ + Delete the specified cgroup. + + :param path: The path of the cgroup to delete. + E.g. cpu/mygroup/mysubgroup + """ + node = trees.Tree().root + path_split = path.split("/") + for path_element in path_split: + name_to_node = {x.name: x for x in node.children} + if path_element not in name_to_node: + self.log.warning("Cgroup does not exist: %s", path) + return + else: + node = name_to_node[path_element] + # node is now the leaf node + parent = node.parent + self.log.debug("Deleting cgroup %s/%s", parent, node.name) + parent.delete_cgroup(node.name) + + def start(self): + # Use bash if it's already in a cgroup + cgroups = self._get_cgroup_names() + if cgroups["cpu"] != "/" or cgroups["memory"] != "/": + self.log.debug( + "Already running in a cgroup (cpu: %s memory: %s) so not creating another one", + cgroups.get("cpu"), cgroups.get("memory") + ) + self.process = self.run_command(['bash', '-c'], join_args=True) + return + + # Create a unique cgroup name + cgroup_name = "airflow/{}/{}".format(datetime.datetime.utcnow(). + strftime("%Y-%m-%d"), + str(uuid.uuid1())) + + self.mem_cgroup_name = "memory/{}".format(cgroup_name) + self.cpu_cgroup_name = "cpu/{}".format(cgroup_name) + + # Get the resource requirements from the task + task = self._task_instance.task + resources = task.resources + cpus = resources.cpus.qty + self._cpu_shares = cpus * 1024 + self._mem_mb_limit = resources.ram.qty + + # Create the memory cgroup + mem_cgroup_node = self._create_cgroup(self.mem_cgroup_name) + self._created_mem_cgroup = True + if self._mem_mb_limit > 0: + self.log.debug( + "Setting %s with %s MB of memory", + self.mem_cgroup_name, self._mem_mb_limit + ) + mem_cgroup_node.controller.limit_in_bytes = self._mem_mb_limit * 1024 * 1024 + + # Create the CPU cgroup + cpu_cgroup_node = self._create_cgroup(self.cpu_cgroup_name) + self._created_cpu_cgroup = True + if self._cpu_shares > 0: + self.log.debug( + "Setting %s with %s CPU shares", + self.cpu_cgroup_name, self._cpu_shares + ) + cpu_cgroup_node.controller.shares = self._cpu_shares + + # Start the process w/ cgroups + self.log.debug( + "Starting task process with cgroups cpu,memory: %s", + cgroup_name + ) + self.process = self.run_command( + ['cgexec', '-g', 'cpu,memory:{}'.format(cgroup_name)] + ) + + def return_code(self): + return_code = self.process.poll() + # TODO(plypaul) Monitoring the the control file in the cgroup fs is better than + # checking the return code here. The PR to use this is here: + # https://github.com/plypaul/airflow/blob/e144e4d41996300ffa93947f136eab7785b114ed/airflow/contrib/task_runner/cgroup_task_runner.py#L43 + # but there were some issues installing the python butter package and + # libseccomp-dev on some hosts for some reason. + # I wasn't able to track down the root cause of the package install failures, but + # we might want to revisit that approach at some other point. + if return_code == 137: + self.log.warning("Task failed with return code of 137. This may indicate " + "that it was killed due to excessive memory usage. " + "Please consider optimizing your task or using the " + "resources argument to reserve more memory for your task") + return return_code + + def terminate(self): + if self.process and psutil.pid_exists(self.process.pid): + kill_process_tree(self.log, self.process.pid) + + def on_finish(self): + # Let the OOM watcher thread know we're done to avoid false OOM alarms + self._finished_running = True + # Clean up the cgroups + if self._created_mem_cgroup: + self._delete_cgroup(self.mem_cgroup_name) + if self._created_cpu_cgroup: + self._delete_cgroup(self.cpu_cgroup_name) + + def _get_cgroup_names(self): + """ + :return: a mapping between the subsystem name to the cgroup name + :rtype: dict[str, str] + """ + with open("/proc/self/cgroup") as f: + lines = f.readlines() + d = {} + for line in lines: + line_split = line.rstrip().split(":") + subsystem = line_split[1] + group_name = line_split[2] + d[subsystem] = group_name + return d diff --git a/airflow/dag/__init__.py b/airflow/dag/__init__.py new file mode 100644 index 0000000000000..9d7677a99b293 --- /dev/null +++ b/airflow/dag/__init__.py @@ -0,0 +1,13 @@ +# -*- coding: utf-8 -*- +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. diff --git a/airflow/dag/base_dag.py b/airflow/dag/base_dag.py new file mode 100644 index 0000000000000..83ecfb90e90d2 --- /dev/null +++ b/airflow/dag/base_dag.py @@ -0,0 +1,96 @@ +# -*- coding: utf-8 -*- +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +from __future__ import absolute_import +from __future__ import division +from __future__ import print_function +from __future__ import unicode_literals + +from abc import ABCMeta, abstractmethod, abstractproperty + + +class BaseDag(object): + """ + Base DAG object that both the SimpleDag and DAG inherit. + """ + __metaclass__ = ABCMeta + + @abstractproperty + def dag_id(self): + """ + :return: the DAG ID + :rtype: unicode + """ + raise NotImplementedError() + + @abstractproperty + def task_ids(self): + """ + :return: A list of task IDs that are in this DAG + :rtype: List[unicode] + """ + raise NotImplementedError() + + @abstractproperty + def full_filepath(self): + """ + :return: The absolute path to the file that contains this DAG's definition + :rtype: unicode + """ + raise NotImplementedError() + + @abstractmethod + def concurrency(self): + """ + :return: maximum number of tasks that can run simultaneously from this DAG + :rtype: int + """ + raise NotImplementedError() + + @abstractmethod + def is_paused(self): + """ + :return: whether this DAG is paused or not + :rtype: bool + """ + raise NotImplementedError() + + @abstractmethod + def pickle_id(self): + """ + :return: The pickle ID for this DAG, if it has one. Otherwise None. + :rtype: unicode + """ + raise NotImplementedError + + +class BaseDagBag(object): + """ + Base object that both the SimpleDagBag and DagBag inherit. + """ + @abstractproperty + def dag_ids(self): + """ + :return: a list of DAG IDs in this bag + :rtype: List[unicode] + """ + raise NotImplementedError() + + @abstractmethod + def get_dag(self, dag_id): + """ + :return: whether the task exists in this bag + :rtype: BaseDag + """ + raise NotImplementedError() diff --git a/airflow/default_login.py b/airflow/default_login.py new file mode 100644 index 0000000000000..653a9c0fd1fcd --- /dev/null +++ b/airflow/default_login.py @@ -0,0 +1,88 @@ +# -*- coding: utf-8 -*- +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +''' +Override this file to handle your authenticating / login. + +Copy and alter this file and put in your PYTHONPATH as airflow_login.py, +the new module will override this one. +''' + +import flask_login +from flask_login import login_required, current_user, logout_user + +from flask import url_for, redirect + +from airflow import settings +from airflow import models + +DEFAULT_USERNAME = 'airflow' + +login_manager = flask_login.LoginManager() +login_manager.login_view = 'airflow.login' # Calls login() below +login_manager.login_message = None + + +class DefaultUser(object): + def __init__(self, user): + self.user = user + + def is_active(self): + '''Required by flask_login''' + return True + + def is_authenticated(self): + '''Required by flask_login''' + return True + + def is_anonymous(self): + '''Required by flask_login''' + return False + + def data_profiling(self): + '''Provides access to data profiling tools''' + return True + + def is_superuser(self): + '''Access all the things''' + return True + +#models.User = User # hack! +#del User + + +@login_manager.user_loader +def load_user(userid): + session = settings.Session() + user = session.query(models.User).filter(models.User.id == userid).first() + session.expunge_all() + session.commit() + session.close() + return DefaultUser(user) + + +def login(self, request): + session = settings.Session() + user = session.query(models.User).filter( + models.User.username == DEFAULT_USERNAME).first() + if not user: + user = models.User( + username=DEFAULT_USERNAME, + is_superuser=True) + session.merge(user) + session.commit() + flask_login.login_user(DefaultUser(user)) + session.commit() + session.close() + return redirect(request.args.get("next") or url_for("index")) diff --git a/airflow/example_dags/__init__.py b/airflow/example_dags/__init__.py new file mode 100644 index 0000000000000..c82f5790fe906 --- /dev/null +++ b/airflow/example_dags/__init__.py @@ -0,0 +1,14 @@ +# -*- coding: utf-8 -*- +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + diff --git a/airflow/example_dags/docker_copy_data.py b/airflow/example_dags/docker_copy_data.py new file mode 100644 index 0000000000000..7d7172cd2ff5b --- /dev/null +++ b/airflow/example_dags/docker_copy_data.py @@ -0,0 +1,97 @@ +# -*- coding: utf-8 -*- +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +''' +This sample "listen to directory". move the new file and print it, using docker-containers. +The following operators are being used: DockerOperator, BashOperator & ShortCircuitOperator. +TODO: Review the workflow, change it accordingly to to your environment & enable the code. +''' + +# from __future__ import print_function +# +# from airflow import DAG +# import airflow +# from datetime import datetime, timedelta +# from airflow.operators import BashOperator +# from airflow.operators import ShortCircuitOperator +# from airflow.operators.docker_operator import DockerOperator +# +# default_args = { +# 'owner': 'airflow', +# 'depends_on_past': False, +# 'start_date': datetime.utcnow(), +# 'email': ['airflow@example.com'], +# 'email_on_failure': False, +# 'email_on_retry': False, +# 'retries': 1, +# 'retry_delay': timedelta(minutes=5), +# } +# +# dag = DAG( +# 'docker_sample_copy_data', default_args=default_args, schedule_interval=timedelta(minutes=10)) +# +# locate_file_cmd = """ +# sleep 10 +# find {{params.source_location}} -type f -printf "%f\n" | head -1 +# """ +# +# t_view = BashOperator( +# task_id='view_file', +# bash_command=locate_file_cmd, +# xcom_push=True, +# params={'source_location': '/your/input_dir/path'}, +# dag=dag) +# +# +# def is_data_available(*args, **kwargs): +# ti = kwargs['ti'] +# data = ti.xcom_pull(key=None, task_ids='view_file') +# return not data == '' +# +# +# t_is_data_available = ShortCircuitOperator( +# task_id='check_if_data_available', +# provide_context=True, +# python_callable=is_data_available, +# dag=dag) +# +# t_move = DockerOperator( +# api_version='1.19', +# docker_url='tcp://localhost:2375', # replace it with swarm/docker endpoint +# image='centos:latest', +# network_mode='bridge', +# volumes=['/your/host/input_dir/path:/your/input_dir/path', +# '/your/host/output_dir/path:/your/output_dir/path'], +# command='./entrypoint.sh', +# task_id='move_data', +# xcom_push=True, +# params={'source_location': '/your/input_dir/path', +# 'target_location': '/your/output_dir/path'}, +# dag=dag) +# +# print_templated_cmd = """ +# cat {{ ti.xcom_pull('move_data') }} +# """ +# +# t_print = DockerOperator( +# api_version='1.19', +# docker_url='tcp://localhost:2375', +# image='centos:latest', +# volumes=['/your/host/output_dir/path:/your/output_dir/path'], +# command=print_templated_cmd, +# task_id='print', +# dag=dag) +# +# t_view.set_downstream(t_is_data_available) +# t_is_data_available.set_downstream(t_move) +# t_move.set_downstream(t_print) diff --git a/airflow/example_dags/entrypoint.sh b/airflow/example_dags/entrypoint.sh new file mode 100644 index 0000000000000..44214535034ea --- /dev/null +++ b/airflow/example_dags/entrypoint.sh @@ -0,0 +1,14 @@ +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +["/bin/bash", "-c", "/bin/sleep 30; /bin/mv {{params.source_location}}/{{ ti.xcom_pull('view_file') }} {{params.target_location}}; /bin/echo '{{params.target_location}}/{{ ti.xcom_pull('view_file') }}';"] diff --git a/airflow/example_dags/example_bash_operator.py b/airflow/example_dags/example_bash_operator.py new file mode 100644 index 0000000000000..6887fa906b6fc --- /dev/null +++ b/airflow/example_dags/example_bash_operator.py @@ -0,0 +1,55 @@ +# -*- coding: utf-8 -*- +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import airflow +from builtins import range +from airflow.operators.bash_operator import BashOperator +from airflow.operators.dummy_operator import DummyOperator +from airflow.models import DAG +from datetime import timedelta + + +args = { + 'owner': 'airflow', + 'start_date': airflow.utils.dates.days_ago(2) +} + +dag = DAG( + dag_id='example_bash_operator', default_args=args, + schedule_interval='0 0 * * *', + dagrun_timeout=timedelta(minutes=60)) + +cmd = 'ls -l' +run_this_last = DummyOperator(task_id='run_this_last', dag=dag) + +run_this = BashOperator( + task_id='run_after_loop', bash_command='echo 1', dag=dag) +run_this.set_downstream(run_this_last) + +for i in range(3): + i = str(i) + task = BashOperator( + task_id='runme_'+i, + bash_command='echo "{{ task_instance_key_str }}" && sleep 1', + dag=dag) + task.set_downstream(run_this) + +task = BashOperator( + task_id='also_run_this', + bash_command='echo "run_id={{ run_id }} | dag_run={{ dag_run }}"', + dag=dag) +task.set_downstream(run_this_last) + +if __name__ == "__main__": + dag.cli() diff --git a/airflow/example_dags/example_branch_operator.py b/airflow/example_dags/example_branch_operator.py new file mode 100644 index 0000000000000..2b11d91363697 --- /dev/null +++ b/airflow/example_dags/example_branch_operator.py @@ -0,0 +1,54 @@ +# -*- coding: utf-8 -*- +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import airflow +from airflow.operators.python_operator import BranchPythonOperator +from airflow.operators.dummy_operator import DummyOperator +from airflow.models import DAG +import random + + +args = { + 'owner': 'airflow', + 'start_date': airflow.utils.dates.days_ago(2) +} + +dag = DAG( + dag_id='example_branch_operator', + default_args=args, + schedule_interval="@daily") + +cmd = 'ls -l' +run_this_first = DummyOperator(task_id='run_this_first', dag=dag) + +options = ['branch_a', 'branch_b', 'branch_c', 'branch_d'] + +branching = BranchPythonOperator( + task_id='branching', + python_callable=lambda: random.choice(options), + dag=dag) +branching.set_upstream(run_this_first) + +join = DummyOperator( + task_id='join', + trigger_rule='one_success', + dag=dag +) + +for option in options: + t = DummyOperator(task_id=option, dag=dag) + t.set_upstream(branching) + dummy_follow = DummyOperator(task_id='follow_' + option, dag=dag) + t.set_downstream(dummy_follow) + dummy_follow.set_downstream(join) diff --git a/airflow/example_dags/example_branch_python_dop_operator_3.py b/airflow/example_dags/example_branch_python_dop_operator_3.py new file mode 100644 index 0000000000000..6da7b68244d42 --- /dev/null +++ b/airflow/example_dags/example_branch_python_dop_operator_3.py @@ -0,0 +1,57 @@ +# -*- coding: utf-8 -*- +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +import airflow +from airflow.operators.python_operator import BranchPythonOperator +from airflow.operators.dummy_operator import DummyOperator +from airflow.models import DAG +from datetime import datetime, timedelta + +args = { + 'owner': 'airflow', + 'start_date': airflow.utils.dates.days_ago(2), + 'depends_on_past': True, +} + +# BranchPython operator that depends on past +# and where tasks may run or be skipped on +# alternating runs +dag = DAG(dag_id='example_branch_dop_operator_v3',schedule_interval='*/1 * * * *', default_args=args) + + +def should_run(ds, **kwargs): + + print("------------- exec dttm = {} and minute = {}".format(kwargs['execution_date'], kwargs['execution_date'].minute)) + if kwargs['execution_date'].minute % 2 == 0: + return "oper_1" + else: + return "oper_2" + + +cond = BranchPythonOperator( + task_id='condition', + provide_context=True, + python_callable=should_run, + dag=dag) + +oper_1 = DummyOperator( + task_id='oper_1', + dag=dag) +oper_1.set_upstream(cond) + +oper_2 = DummyOperator( + task_id='oper_2', + dag=dag) +oper_2.set_upstream(cond) diff --git a/airflow/example_dags/example_docker_operator.py b/airflow/example_dags/example_docker_operator.py new file mode 100644 index 0000000000000..2fed013e77ee9 --- /dev/null +++ b/airflow/example_dags/example_docker_operator.py @@ -0,0 +1,63 @@ +# -*- coding: utf-8 -*- +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +""" +from airflow import DAG +from airflow.operators import BashOperator +from datetime import datetime, timedelta +from airflow.operators.docker_operator import DockerOperator + +default_args = { + 'owner': 'airflow', + 'depends_on_past': False, + 'start_date': datetime.utcnow(), + 'email': ['airflow@example.com'], + 'email_on_failure': False, + 'email_on_retry': False, + 'retries': 1, + 'retry_delay': timedelta(minutes=5) +} + +dag = DAG( + 'docker_sample', default_args=default_args, schedule_interval=timedelta(minutes=10)) + +t1 = BashOperator( + task_id='print_date', + bash_command='date', + dag=dag) + +t2 = BashOperator( + task_id='sleep', + bash_command='sleep 5', + retries=3, + dag=dag) + +t3 = DockerOperator(api_version='1.19', + docker_url='tcp://localhost:2375', #Set your docker URL + command='/bin/sleep 30', + image='centos:latest', + network_mode='bridge', + task_id='docker_op_tester', + dag=dag) + + +t4 = BashOperator( + task_id='print_hello', + bash_command='echo "hello world!!!"', + dag=dag) + + +t1.set_downstream(t2) +t1.set_downstream(t3) +t3.set_downstream(t4) +""" diff --git a/airflow/example_dags/example_http_operator.py b/airflow/example_dags/example_http_operator.py new file mode 100644 index 0000000000000..10fc3b1b5c469 --- /dev/null +++ b/airflow/example_dags/example_http_operator.py @@ -0,0 +1,93 @@ +# -*- coding: utf-8 -*- +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +""" +### Example HTTP operator and sensor +""" +import airflow +from airflow import DAG +from airflow.operators.http_operator import SimpleHttpOperator +from airflow.operators.sensors import HttpSensor +from datetime import timedelta +import json + + +default_args = { + 'owner': 'airflow', + 'depends_on_past': False, + 'start_date': airflow.utils.dates.days_ago(2), + 'email': ['airflow@example.com'], + 'email_on_failure': False, + 'email_on_retry': False, + 'retries': 1, + 'retry_delay': timedelta(minutes=5), +} + +dag = DAG('example_http_operator', default_args=default_args) + +dag.doc_md = __doc__ + +# t1, t2 and t3 are examples of tasks created by instantiating operators +t1 = SimpleHttpOperator( + task_id='post_op', + endpoint='api/v1.0/nodes', + data=json.dumps({"priority": 5}), + headers={"Content-Type": "application/json"}, + response_check=lambda response: True if len(response.json()) == 0 else False, + dag=dag) + +t5 = SimpleHttpOperator( + task_id='post_op_formenc', + endpoint='nodes/url', + data="name=Joe", + headers={"Content-Type": "application/x-www-form-urlencoded"}, + dag=dag) + +t2 = SimpleHttpOperator( + task_id='get_op', + method='GET', + endpoint='api/v1.0/nodes', + data={"param1": "value1", "param2": "value2"}, + headers={}, + dag=dag) + +t3 = SimpleHttpOperator( + task_id='put_op', + method='PUT', + endpoint='api/v1.0/nodes', + data=json.dumps({"priority": 5}), + headers={"Content-Type": "application/json"}, + dag=dag) + +t4 = SimpleHttpOperator( + task_id='del_op', + method='DELETE', + endpoint='api/v1.0/nodes', + data="some=data", + headers={"Content-Type": "application/x-www-form-urlencoded"}, + dag=dag) + +sensor = HttpSensor( + task_id='http_sensor_check', + http_conn_id='http_default', + endpoint='', + request_params={}, + response_check=lambda response: True if "Google" in response.content else False, + poke_interval=5, + dag=dag) + +t1.set_upstream(sensor) +t2.set_upstream(t1) +t3.set_upstream(t2) +t4.set_upstream(t3) +t5.set_upstream(t4) diff --git a/airflow/example_dags/example_latest_only.py b/airflow/example_dags/example_latest_only.py new file mode 100644 index 0000000000000..38ee9000af0fa --- /dev/null +++ b/airflow/example_dags/example_latest_only.py @@ -0,0 +1,34 @@ +# -*- coding: utf-8 -*- +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +""" +Example of the LatestOnlyOperator +""" +import datetime as dt + +import airflow +from airflow.models import DAG +from airflow.operators.dummy_operator import DummyOperator +from airflow.operators.latest_only_operator import LatestOnlyOperator +from airflow.utils.trigger_rule import TriggerRule + +dag = DAG( + dag_id='latest_only', + schedule_interval=dt.timedelta(hours=4), + start_date=airflow.utils.dates.days_ago(2), +) + +latest_only = LatestOnlyOperator(task_id='latest_only', dag=dag) + +task1 = DummyOperator(task_id='task1', dag=dag) +task1.set_upstream(latest_only) diff --git a/airflow/example_dags/example_latest_only_with_trigger.py b/airflow/example_dags/example_latest_only_with_trigger.py new file mode 100644 index 0000000000000..f2afdcfe4c054 --- /dev/null +++ b/airflow/example_dags/example_latest_only_with_trigger.py @@ -0,0 +1,43 @@ +# -*- coding: utf-8 -*- +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +""" +Example LatestOnlyOperator and TriggerRule interactions +""" +import datetime as dt + +import airflow +from airflow.models import DAG +from airflow.operators.dummy_operator import DummyOperator +from airflow.operators.latest_only_operator import LatestOnlyOperator +from airflow.utils.trigger_rule import TriggerRule + +dag = DAG( + dag_id='latest_only_with_trigger', + schedule_interval=dt.timedelta(hours=4), + start_date=airflow.utils.dates.days_ago(2), +) + +latest_only = LatestOnlyOperator(task_id='latest_only', dag=dag) + +task1 = DummyOperator(task_id='task1', dag=dag) +task1.set_upstream(latest_only) + +task2 = DummyOperator(task_id='task2', dag=dag) + +task3 = DummyOperator(task_id='task3', dag=dag) +task3.set_upstream([task1, task2]) + +task4 = DummyOperator(task_id='task4', dag=dag, + trigger_rule=TriggerRule.ALL_DONE) +task4.set_upstream([task1, task2]) diff --git a/airflow/example_dags/example_passing_params_via_test_command.py b/airflow/example_dags/example_passing_params_via_test_command.py new file mode 100644 index 0000000000000..448effb630c2f --- /dev/null +++ b/airflow/example_dags/example_passing_params_via_test_command.py @@ -0,0 +1,58 @@ +# -*- coding: utf-8 -*- +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +from datetime import timedelta +import airflow +from airflow import DAG +from airflow.operators.bash_operator import BashOperator +from airflow.operators.python_operator import PythonOperator + +dag = DAG("example_passing_params_via_test_command", + default_args={"owner": "airflow", + "start_date": airflow.utils.dates.days_ago(1)}, + schedule_interval='*/1 * * * *', + dagrun_timeout=timedelta(minutes=4) + ) + + +def my_py_command(ds, **kwargs): + # Print out the "foo" param passed in via + # `airflow test example_passing_params_via_test_command run_this + # -tp '{"foo":"bar"}'` + if kwargs["test_mode"]: + print(" 'foo' was passed in via test={} command : kwargs[params][foo] \ + = {}".format(kwargs["test_mode"], kwargs["params"]["foo"])) + # Print out the value of "miff", passed in below via the Python Operator + print(" 'miff' was passed in via task params = {}".format(kwargs["params"]["miff"])) + return 1 + +my_templated_command = """ + echo " 'foo was passed in via Airflow CLI Test command with value {{ params.foo }} " + echo " 'miff was passed in via BashOperator with value {{ params.miff }} " +""" + +run_this = PythonOperator( + task_id='run_this', + provide_context=True, + python_callable=my_py_command, + params={"miff":"agg"}, + dag=dag) + +also_run_this = BashOperator( + task_id='also_run_this', + bash_command=my_templated_command, + params={"miff":"agg"}, + dag=dag) +also_run_this.set_upstream(run_this) diff --git a/airflow/example_dags/example_python_operator.py b/airflow/example_dags/example_python_operator.py new file mode 100644 index 0000000000000..8108e1e40bb90 --- /dev/null +++ b/airflow/example_dags/example_python_operator.py @@ -0,0 +1,57 @@ +# -*- coding: utf-8 -*- +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +from __future__ import print_function +from builtins import range +import airflow +from airflow.operators.python_operator import PythonOperator +from airflow.models import DAG + +import time +from pprint import pprint + +args = { + 'owner': 'airflow', + 'start_date': airflow.utils.dates.days_ago(2) +} + +dag = DAG( + dag_id='example_python_operator', default_args=args, + schedule_interval=None) + + +def my_sleeping_function(random_base): + """This is a function that will run within the DAG execution""" + time.sleep(random_base) + + +def print_context(ds, **kwargs): + pprint(kwargs) + print(ds) + return 'Whatever you return gets printed in the logs' + +run_this = PythonOperator( + task_id='print_the_context', + provide_context=True, + python_callable=print_context, + dag=dag) + +# Generate 10 sleeping tasks, sleeping from 0 to 9 seconds respectively +for i in range(10): + task = PythonOperator( + task_id='sleep_for_' + str(i), + python_callable=my_sleeping_function, + op_kwargs={'random_base': float(i) / 10}, + dag=dag) + + task.set_upstream(run_this) diff --git a/airflow/example_dags/example_short_circuit_operator.py b/airflow/example_dags/example_short_circuit_operator.py new file mode 100644 index 0000000000000..c9812acb5fc00 --- /dev/null +++ b/airflow/example_dags/example_short_circuit_operator.py @@ -0,0 +1,38 @@ +# -*- coding: utf-8 -*- +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +import airflow +from airflow.operators.python_operator import ShortCircuitOperator +from airflow.operators.dummy_operator import DummyOperator +from airflow.models import DAG +import airflow.utils.helpers + + +args = { + 'owner': 'airflow', + 'start_date': airflow.utils.dates.days_ago(2) +} + +dag = DAG(dag_id='example_short_circuit_operator', default_args=args) + +cond_true = ShortCircuitOperator( + task_id='condition_is_True', python_callable=lambda: True, dag=dag) + +cond_false = ShortCircuitOperator( + task_id='condition_is_False', python_callable=lambda: False, dag=dag) + +ds_true = [DummyOperator(task_id='true_' + str(i), dag=dag) for i in [1, 2]] +ds_false = [DummyOperator(task_id='false_' + str(i), dag=dag) for i in [1, 2]] + +airflow.utils.helpers.chain(cond_true, *ds_true) +airflow.utils.helpers.chain(cond_false, *ds_false) diff --git a/airflow/example_dags/example_skip_dag.py b/airflow/example_dags/example_skip_dag.py new file mode 100644 index 0000000000000..b93602057c72b --- /dev/null +++ b/airflow/example_dags/example_skip_dag.py @@ -0,0 +1,54 @@ +# -*- coding: utf-8 -*- +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import airflow +from airflow.operators.dummy_operator import DummyOperator +from airflow.models import DAG +from airflow.exceptions import AirflowSkipException + + +args = { + 'owner': 'airflow', + 'start_date': airflow.utils.dates.days_ago(2) +} + + +# Create some placeholder operators +class DummySkipOperator(DummyOperator): + ui_color = '#e8b7e4' + + def execute(self, context): + raise AirflowSkipException + + +dag = DAG(dag_id='example_skip_dag', default_args=args) + + +def create_test_pipeline(suffix, trigger_rule, dag): + + skip_operator = DummySkipOperator(task_id='skip_operator_{}'.format(suffix), dag=dag) + + always_true = DummyOperator(task_id='always_true_{}'.format(suffix), dag=dag) + + join = DummyOperator(task_id=trigger_rule, dag=dag, trigger_rule=trigger_rule) + + join.set_upstream(skip_operator) + join.set_upstream(always_true) + + final = DummyOperator(task_id='final_{}'.format(suffix), dag=dag) + final.set_upstream(join) + + +create_test_pipeline('1', 'all_success', dag) +create_test_pipeline('2', 'one_success', dag) diff --git a/airflow/example_dags/example_subdag_operator.py b/airflow/example_dags/example_subdag_operator.py new file mode 100644 index 0000000000000..0c1178779c80d --- /dev/null +++ b/airflow/example_dags/example_subdag_operator.py @@ -0,0 +1,71 @@ +# -*- coding: utf-8 -*- +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +import airflow + +from airflow.models import DAG +from airflow.operators.dummy_operator import DummyOperator +from airflow.operators.subdag_operator import SubDagOperator + +from airflow.example_dags.subdags.subdag import subdag + + +DAG_NAME = 'example_subdag_operator' + +args = { + 'owner': 'airflow', + 'start_date': airflow.utils.dates.days_ago(2), +} + +dag = DAG( + dag_id=DAG_NAME, + default_args=args, + schedule_interval="@once", +) + +start = DummyOperator( + task_id='start', + default_args=args, + dag=dag, +) + +section_1 = SubDagOperator( + task_id='section-1', + subdag=subdag(DAG_NAME, 'section-1', args), + default_args=args, + dag=dag, +) + +some_other_task = DummyOperator( + task_id='some-other-task', + default_args=args, + dag=dag, +) + +section_2 = SubDagOperator( + task_id='section-2', + subdag=subdag(DAG_NAME, 'section-2', args), + default_args=args, + dag=dag, +) + +end = DummyOperator( + task_id='end', + default_args=args, + dag=dag, +) + +start.set_downstream(section_1) +section_1.set_downstream(some_other_task) +some_other_task.set_downstream(section_2) +section_2.set_downstream(end) diff --git a/airflow/example_dags/example_trigger_controller_dag.py b/airflow/example_dags/example_trigger_controller_dag.py new file mode 100644 index 0000000000000..3e5a261b78f50 --- /dev/null +++ b/airflow/example_dags/example_trigger_controller_dag.py @@ -0,0 +1,63 @@ +# -*- coding: utf-8 -*- +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +"""This example illustrates the use of the TriggerDagRunOperator. There are 2 +entities at work in this scenario: +1. The Controller DAG - the DAG that conditionally executes the trigger +2. The Target DAG - DAG being triggered (in example_trigger_target_dag.py) + +This example illustrates the following features : +1. A TriggerDagRunOperator that takes: + a. A python callable that decides whether or not to trigger the Target DAG + b. An optional params dict passed to the python callable to help in + evaluating whether or not to trigger the Target DAG + c. The id (name) of the Target DAG + d. The python callable can add contextual info to the DagRun created by + way of adding a Pickleable payload (e.g. dictionary of primitives). This + state is then made available to the TargetDag +2. A Target DAG : c.f. example_trigger_target_dag.py +""" + +from airflow import DAG +from airflow.operators.dagrun_operator import TriggerDagRunOperator +from datetime import datetime + +import pprint + +pp = pprint.PrettyPrinter(indent=4) + + +def conditionally_trigger(context, dag_run_obj): + """This function decides whether or not to Trigger the remote DAG""" + c_p =context['params']['condition_param'] + print("Controller DAG : conditionally_trigger = {}".format(c_p)) + if context['params']['condition_param']: + dag_run_obj.payload = {'message': context['params']['message']} + pp.pprint(dag_run_obj.payload) + return dag_run_obj + + +# Define the DAG +dag = DAG(dag_id='example_trigger_controller_dag', + default_args={"owner": "airflow", + "start_date": datetime.utcnow()}, + schedule_interval='@once') + + +# Define the single task in this controller example DAG +trigger = TriggerDagRunOperator(task_id='test_trigger_dagrun', + trigger_dag_id="example_trigger_target_dag", + python_callable=conditionally_trigger, + params={'condition_param': True, + 'message': 'Hello World'}, + dag=dag) diff --git a/airflow/example_dags/example_trigger_target_dag.py b/airflow/example_dags/example_trigger_target_dag.py new file mode 100644 index 0000000000000..d8c8675486f5f --- /dev/null +++ b/airflow/example_dags/example_trigger_target_dag.py @@ -0,0 +1,63 @@ +# -*- coding: utf-8 -*- +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +from airflow.operators.bash_operator import BashOperator +from airflow.operators.python_operator import PythonOperator +from airflow.models import DAG +from datetime import datetime + +import pprint +pp = pprint.PrettyPrinter(indent=4) + +# This example illustrates the use of the TriggerDagRunOperator. There are 2 +# entities at work in this scenario: +# 1. The Controller DAG - the DAG that conditionally executes the trigger +# (in example_trigger_controller.py) +# 2. The Target DAG - DAG being triggered +# +# This example illustrates the following features : +# 1. A TriggerDagRunOperator that takes: +# a. A python callable that decides whether or not to trigger the Target DAG +# b. An optional params dict passed to the python callable to help in +# evaluating whether or not to trigger the Target DAG +# c. The id (name) of the Target DAG +# d. The python callable can add contextual info to the DagRun created by +# way of adding a Pickleable payload (e.g. dictionary of primitives). This +# state is then made available to the TargetDag +# 2. A Target DAG : c.f. example_trigger_target_dag.py + +args = { + 'start_date': datetime.utcnow(), + 'owner': 'airflow', +} + +dag = DAG( + dag_id='example_trigger_target_dag', + default_args=args, + schedule_interval=None) + + +def run_this_func(ds, **kwargs): + print("Remotely received value of {} for key=message".format(kwargs['dag_run'].conf['message'])) + +run_this = PythonOperator( + task_id='run_this', + provide_context=True, + python_callable=run_this_func, + dag=dag) + +# You can also access the DagRun object in templates +bash_task = BashOperator( + task_id="bash_task", + bash_command='echo "Here is the message: {{ dag_run.conf["message"] if dag_run else "" }}" ', + dag=dag) diff --git a/airflow/example_dags/example_xcom.py b/airflow/example_dags/example_xcom.py new file mode 100644 index 0000000000000..b41421bc0bf85 --- /dev/null +++ b/airflow/example_dags/example_xcom.py @@ -0,0 +1,69 @@ +# -*- coding: utf-8 -*- +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +from __future__ import print_function +import airflow +from airflow import DAG +from airflow.operators.python_operator import PythonOperator + +args = { + 'owner': 'airflow', + 'start_date': airflow.utils.dates.days_ago(2), + 'provide_context': True +} + +dag = DAG( + 'example_xcom', + schedule_interval="@once", + default_args=args) + +value_1 = [1, 2, 3] +value_2 = {'a': 'b'} + + +def push(**kwargs): + # pushes an XCom without a specific target + kwargs['ti'].xcom_push(key='value from pusher 1', value=value_1) + + +def push_by_returning(**kwargs): + # pushes an XCom without a specific target, just by returning it + return value_2 + + +def puller(**kwargs): + ti = kwargs['ti'] + + # get value_1 + v1 = ti.xcom_pull(key=None, task_ids='push') + assert v1 == value_1 + + # get value_2 + v2 = ti.xcom_pull(task_ids='push_by_returning') + assert v2 == value_2 + + # get both value_1 and value_2 + v1, v2 = ti.xcom_pull(key=None, task_ids=['push', 'push_by_returning']) + assert (v1, v2) == (value_1, value_2) + + +push1 = PythonOperator( + task_id='push', dag=dag, python_callable=push) + +push2 = PythonOperator( + task_id='push_by_returning', dag=dag, python_callable=push_by_returning) + +pull = PythonOperator( + task_id='puller', dag=dag, python_callable=puller) + +pull.set_upstream([push1, push2]) diff --git a/airflow/example_dags/subdags/__init__.py b/airflow/example_dags/subdags/__init__.py new file mode 100644 index 0000000000000..c82f5790fe906 --- /dev/null +++ b/airflow/example_dags/subdags/__init__.py @@ -0,0 +1,14 @@ +# -*- coding: utf-8 -*- +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + diff --git a/airflow/example_dags/subdags/subdag.py b/airflow/example_dags/subdags/subdag.py new file mode 100644 index 0000000000000..eee28d870f82d --- /dev/null +++ b/airflow/example_dags/subdags/subdag.py @@ -0,0 +1,33 @@ +# -*- coding: utf-8 -*- +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from airflow.models import DAG +from airflow.operators.dummy_operator import DummyOperator + + +def subdag(parent_dag_name, child_dag_name, args): + dag_subdag = DAG( + dag_id='%s.%s' % (parent_dag_name, child_dag_name), + default_args=args, + schedule_interval="@daily", + ) + + for i in range(5): + DummyOperator( + task_id='%s-task-%s' % (child_dag_name, i + 1), + default_args=args, + dag=dag_subdag, + ) + + return dag_subdag diff --git a/airflow/example_dags/test_utils.py b/airflow/example_dags/test_utils.py new file mode 100644 index 0000000000000..0ed9bdbb0d2dc --- /dev/null +++ b/airflow/example_dags/test_utils.py @@ -0,0 +1,30 @@ +# -*- coding: utf-8 -*- +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +"""Used for unit tests""" +import airflow +from airflow.operators.bash_operator import BashOperator +from airflow.models import DAG +from datetime import datetime + +dag = DAG( + dag_id='test_utils', + schedule_interval=None, +) + +task = BashOperator( + task_id='sleeps_forever', + dag=dag, + bash_command="sleep 10000000000", + start_date=airflow.utils.dates.days_ago(2), + owner='airflow') diff --git a/airflow/example_dags/tutorial.py b/airflow/example_dags/tutorial.py new file mode 100644 index 0000000000000..5229b4579cb35 --- /dev/null +++ b/airflow/example_dags/tutorial.py @@ -0,0 +1,96 @@ +# -*- coding: utf-8 -*- +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +""" +### Tutorial Documentation +Documentation that goes along with the Airflow tutorial located +[here](http://pythonhosted.org/airflow/tutorial.html) +""" +import airflow +from airflow import DAG +from airflow.operators.bash_operator import BashOperator +from datetime import timedelta + + +# these args will get passed on to each operator +# you can override them on a per-task basis during operator initialization +default_args = { + 'owner': 'airflow', + 'depends_on_past': False, + 'start_date': airflow.utils.dates.days_ago(2), + 'email': ['airflow@example.com'], + 'email_on_failure': False, + 'email_on_retry': False, + 'retries': 1, + 'retry_delay': timedelta(minutes=5), + # 'queue': 'bash_queue', + # 'pool': 'backfill', + # 'priority_weight': 10, + # 'end_date': datetime(2016, 1, 1), + # 'wait_for_downstream': False, + # 'dag': dag, + # 'adhoc':False, + # 'sla': timedelta(hours=2), + # 'execution_timeout': timedelta(seconds=300), + # 'on_failure_callback': some_function, + # 'on_success_callback': some_other_function, + # 'on_retry_callback': another_function, + # 'trigger_rule': u'all_success' +} + +dag = DAG( + 'tutorial', + default_args=default_args, + description='A simple tutorial DAG', + schedule_interval=timedelta(days=1)) + +# t1, t2 and t3 are examples of tasks created by instantiating operators +t1 = BashOperator( + task_id='print_date', + bash_command='date', + dag=dag) + +t1.doc_md = """\ +#### Task Documentation +You can document your task using the attributes `doc_md` (markdown), +`doc` (plain text), `doc_rst`, `doc_json`, `doc_yaml` which gets +rendered in the UI's Task Instance Details page. +![img](http://montcs.bloomu.edu/~bobmon/Semesters/2012-01/491/import%20soul.png) +""" + +dag.doc_md = __doc__ + +t2 = BashOperator( + task_id='sleep', + depends_on_past=False, + bash_command='sleep 5', + dag=dag) + +templated_command = """ +{% for i in range(5) %} + echo "{{ ds }}" + echo "{{ macros.ds_add(ds, 7)}}" + echo "{{ params.my_param }}" +{% endfor %} +""" + +t3 = BashOperator( + task_id='templated', + depends_on_past=False, + bash_command=templated_command, + params={'my_param': 'Parameter I passed in'}, + dag=dag) + +t2.set_upstream(t1) +t3.set_upstream(t1) diff --git a/airflow/exceptions.py b/airflow/exceptions.py new file mode 100644 index 0000000000000..90d3e22a70743 --- /dev/null +++ b/airflow/exceptions.py @@ -0,0 +1,36 @@ +# -*- coding: utf-8 -*- +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +# Note: Any AirflowException raised is expected to cause the TaskInstance +# to be marked in an ERROR state + + +class AirflowException(Exception): + pass + + +class AirflowConfigException(AirflowException): + pass + + +class AirflowSensorTimeout(AirflowException): + pass + + +class AirflowTaskTimeout(AirflowException): + pass + + +class AirflowSkipException(AirflowException): + pass diff --git a/airflow/executors/__init__.py b/airflow/executors/__init__.py new file mode 100644 index 0000000000000..c387eeb8c9744 --- /dev/null +++ b/airflow/executors/__init__.py @@ -0,0 +1,81 @@ +# -*- coding: utf-8 -*- +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +import sys + +from airflow import configuration +from airflow.executors.base_executor import BaseExecutor +from airflow.executors.local_executor import LocalExecutor +from airflow.executors.sequential_executor import SequentialExecutor + +from airflow.exceptions import AirflowException +from airflow.utils.log.logging_mixin import LoggingMixin + +DEFAULT_EXECUTOR = None + +def _integrate_plugins(): + """Integrate plugins to the context.""" + from airflow.plugins_manager import executors_modules + for executors_module in executors_modules: + sys.modules[executors_module.__name__] = executors_module + globals()[executors_module._name] = executors_module + +def GetDefaultExecutor(): + """Creates a new instance of the configured executor if none exists and returns it""" + global DEFAULT_EXECUTOR + + if DEFAULT_EXECUTOR is not None: + return DEFAULT_EXECUTOR + + executor_name = configuration.get('core', 'EXECUTOR') + + DEFAULT_EXECUTOR = _get_executor(executor_name) + + log = LoggingMixin().log + log.info("Using executor %s", executor_name) + + return DEFAULT_EXECUTOR + + +def _get_executor(executor_name): + """ + Creates a new instance of the named executor. In case the executor name is not know in airflow, + look for it in the plugins + """ + if executor_name == 'LocalExecutor': + return LocalExecutor() + elif executor_name == 'SequentialExecutor': + return SequentialExecutor() + elif executor_name == 'CeleryExecutor': + from airflow.executors.celery_executor import CeleryExecutor + return CeleryExecutor() + elif executor_name == 'DaskExecutor': + from airflow.executors.dask_executor import DaskExecutor + return DaskExecutor() + elif executor_name == 'MesosExecutor': + from airflow.contrib.executors.mesos_executor import MesosExecutor + return MesosExecutor() + else: + # Loading plugins + _integrate_plugins() + executor_path = executor_name.split('.') + if len(executor_path) != 2: + raise AirflowException( + "Executor {0} not supported: please specify in format plugin_module.executor".format(executor_name)) + + if executor_path[0] in globals(): + return globals()[executor_path[0]].__dict__[executor_path[1]]() + else: + raise AirflowException("Executor {0} not supported.".format(executor_name)) + + diff --git a/airflow/executors/base_executor.py b/airflow/executors/base_executor.py new file mode 100644 index 0000000000000..410a558922ac1 --- /dev/null +++ b/airflow/executors/base_executor.py @@ -0,0 +1,170 @@ +# -*- coding: utf-8 -*- +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +from builtins import range + +from airflow import configuration +from airflow.utils.log.logging_mixin import LoggingMixin +from airflow.utils.state import State + +PARALLELISM = configuration.getint('core', 'PARALLELISM') + + +class BaseExecutor(LoggingMixin): + + def __init__(self, parallelism=PARALLELISM): + """ + Class to derive in order to interface with executor-type systems + like Celery, Mesos, Yarn and the likes. + + :param parallelism: how many jobs should run at one time. Set to + ``0`` for infinity + :type parallelism: int + """ + self.parallelism = parallelism + self.queued_tasks = {} + self.running = {} + self.event_buffer = {} + + def start(self): # pragma: no cover + """ + Executors may need to get things started. For example LocalExecutor + starts N workers. + """ + pass + + def queue_command(self, task_instance, command, priority=1, queue=None): + key = task_instance.key + if key not in self.queued_tasks and key not in self.running: + self.log.info("Adding to queue: %s", command) + self.queued_tasks[key] = (command, priority, queue, task_instance) + + def queue_task_instance( + self, + task_instance, + mark_success=False, + pickle_id=None, + ignore_all_deps=False, + ignore_depends_on_past=False, + ignore_task_deps=False, + ignore_ti_state=False, + pool=None): + pool = pool or task_instance.pool + command = task_instance.command( + local=True, + mark_success=mark_success, + ignore_all_deps=ignore_all_deps, + ignore_depends_on_past=ignore_depends_on_past, + ignore_task_deps=ignore_task_deps, + ignore_ti_state=ignore_ti_state, + pool=pool, + pickle_id=pickle_id) + self.queue_command( + task_instance, + command, + priority=task_instance.task.priority_weight_total, + queue=task_instance.task.queue) + + def has_task(self, task_instance): + """ + Checks if a task is either queued or running in this executor + :param task_instance: TaskInstance + :return: True if the task is known to this executor + """ + if task_instance.key in self.queued_tasks or task_instance.key in self.running: + return True + + def sync(self): + """ + Sync will get called periodically by the heartbeat method. + Executors should override this to perform gather statuses. + """ + pass + + def heartbeat(self): + + # Triggering new jobs + if not self.parallelism: + open_slots = len(self.queued_tasks) + else: + open_slots = self.parallelism - len(self.running) + + self.log.debug("%s running task instances", len(self.running)) + self.log.debug("%s in queue", len(self.queued_tasks)) + self.log.debug("%s open slots", open_slots) + + sorted_queue = sorted( + [(k, v) for k, v in self.queued_tasks.items()], + key=lambda x: x[1][1], + reverse=True) + for i in range(min((open_slots, len(self.queued_tasks)))): + key, (command, _, queue, ti) = sorted_queue.pop(0) + # TODO(jlowin) without a way to know what Job ran which tasks, + # there is a danger that another Job started running a task + # that was also queued to this executor. This is the last chance + # to check if that happened. The most probable way is that a + # Scheduler tried to run a task that was originally queued by a + # Backfill. This fix reduces the probability of a collision but + # does NOT eliminate it. + self.queued_tasks.pop(key) + ti.refresh_from_db() + if ti.state != State.RUNNING: + self.running[key] = command + self.execute_async(key, command=command, queue=queue) + else: + self.log.debug( + 'Task is already running, not sending to executor: %s', + key + ) + + # Calling child class sync method + self.log.debug("Calling the %s sync method", self.__class__) + self.sync() + + def change_state(self, key, state): + self.running.pop(key) + self.event_buffer[key] = state + + def fail(self, key): + self.change_state(key, State.FAILED) + + def success(self, key): + self.change_state(key, State.SUCCESS) + + def get_event_buffer(self): + """ + Returns and flush the event buffer + """ + d = self.event_buffer + self.event_buffer = {} + return d + + def execute_async(self, key, command, queue=None): # pragma: no cover + """ + This method will execute the command asynchronously. + """ + raise NotImplementedError() + + def end(self): # pragma: no cover + """ + This method is called when the caller is done submitting job and is + wants to wait synchronously for the job submitted previously to be + all done. + """ + raise NotImplementedError() + + def terminate(self): + """ + This method is called when the daemon receives a SIGTERM + """ + raise NotImplementedError() diff --git a/airflow/executors/celery_executor.py b/airflow/executors/celery_executor.py new file mode 100644 index 0000000000000..d3809b344fcc7 --- /dev/null +++ b/airflow/executors/celery_executor.py @@ -0,0 +1,110 @@ +# -*- coding: utf-8 -*- +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import subprocess +import time + +from celery import Celery +from celery import states as celery_states + +from airflow.config_templates.default_celery import DEFAULT_CELERY_CONFIG +from airflow.exceptions import AirflowException +from airflow.executors.base_executor import BaseExecutor +from airflow import configuration +from airflow.utils.log.logging_mixin import LoggingMixin +from airflow.utils.module_loading import import_string + +PARALLELISM = configuration.get('core', 'PARALLELISM') + +''' +To start the celery worker, run the command: +airflow worker +''' + +if configuration.has_option('celery', 'celery_config_options'): + celery_configuration = import_string( + configuration.get('celery', 'celery_config_options') + ) +else: + celery_configuration = DEFAULT_CELERY_CONFIG + +app = Celery( + configuration.get('celery', 'CELERY_APP_NAME'), + config_source=celery_configuration) + + +@app.task +def execute_command(command): + log = LoggingMixin().log + log.info("Executing command in Celery: %s", command) + try: + subprocess.check_call(command, shell=True) + except subprocess.CalledProcessError as e: + log.error(e) + raise AirflowException('Celery command failed') + + +class CeleryExecutor(BaseExecutor): + """ + CeleryExecutor is recommended for production use of Airflow. It allows + distributing the execution of task instances to multiple worker nodes. + + Celery is a simple, flexible and reliable distributed system to process + vast amounts of messages, while providing operations with the tools + required to maintain such a system. + """ + def start(self): + self.tasks = {} + self.last_state = {} + + def execute_async(self, key, command, + queue=DEFAULT_CELERY_CONFIG['task_default_queue']): + self.log.info( "[celery] queuing {key} through celery, " + "queue={queue}".format(**locals())) + self.tasks[key] = execute_command.apply_async( + args=[command], queue=queue) + self.last_state[key] = celery_states.PENDING + + def sync(self): + self.log.debug("Inquiring about %s celery task(s)", len(self.tasks)) + for key, async in list(self.tasks.items()): + try: + state = async.state + if self.last_state[key] != state: + if state == celery_states.SUCCESS: + self.success(key) + del self.tasks[key] + del self.last_state[key] + elif state == celery_states.FAILURE: + self.fail(key) + del self.tasks[key] + del self.last_state[key] + elif state == celery_states.REVOKED: + self.fail(key) + del self.tasks[key] + del self.last_state[key] + else: + self.log.info("Unexpected state: %s", async.state) + self.last_state[key] = async.state + except Exception as e: + self.log.error("Error syncing the celery executor, ignoring it:") + self.log.exception(e) + + def end(self, synchronous=False): + if synchronous: + while any([ + async.state not in celery_states.READY_STATES + for async in self.tasks.values()]): + time.sleep(5) + self.sync() diff --git a/airflow/executors/dask_executor.py b/airflow/executors/dask_executor.py new file mode 100644 index 0000000000000..07b8a824f4535 --- /dev/null +++ b/airflow/executors/dask_executor.py @@ -0,0 +1,76 @@ +# -*- coding: utf-8 -*- +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import distributed +import subprocess +import warnings + +from airflow import configuration +from airflow.executors.base_executor import BaseExecutor + + +class DaskExecutor(BaseExecutor): + """ + DaskExecutor submits tasks to a Dask Distributed cluster. + """ + def __init__(self, cluster_address=None): + if cluster_address is None: + cluster_address = configuration.get('dask', 'cluster_address') + if not cluster_address: + raise ValueError( + 'Please provide a Dask cluster address in airflow.cfg') + self.cluster_address = cluster_address + super(DaskExecutor, self).__init__(parallelism=0) + + def start(self): + self.client = distributed.Client(self.cluster_address) + self.futures = {} + + def execute_async(self, key, command, queue=None): + if queue is not None: + warnings.warn( + 'DaskExecutor does not support queues. All tasks will be run in the same cluster' + ) + + def airflow_run(): + return subprocess.check_call(command, shell=True) + + future = self.client.submit(airflow_run, pure=False) + self.futures[future] = key + + def _process_future(self, future): + if future.done(): + key = self.futures[future] + if future.exception(): + self.log.error("Failed to execute task: %s", repr(future.exception())) + self.fail(key) + elif future.cancelled(): + self.log.error("Failed to execute task") + self.fail(key) + else: + self.success(key) + self.futures.pop(future) + + def sync(self): + # make a copy so futures can be popped during iteration + for future in self.futures.copy(): + self._process_future(future) + + def end(self): + for future in distributed.as_completed(self.futures.copy()): + self._process_future(future) + + def terminate(self): + self.client.cancel(self.futures.keys()) + self.end() diff --git a/airflow/executors/local_executor.py b/airflow/executors/local_executor.py new file mode 100644 index 0000000000000..71bee22ff59cd --- /dev/null +++ b/airflow/executors/local_executor.py @@ -0,0 +1,226 @@ +# -*- coding: utf-8 -*- +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +""" +LocalExecutor runs tasks by spawning processes in a controlled fashion in different +modes. Given that BaseExecutor has the option to receive a `parallelism` parameter to +limit the number of process spawned, when this parameter is `0` the number of processes +that LocalExecutor can spawn is unlimited. + +The following strategies are implemented: +1. Unlimited Parallelism (self.parallelism == 0): In this strategy, LocalExecutor will +spawn a process every time `execute_async` is called, that is, every task submitted to the +LocalExecutor will be executed in its own process. Once the task is executed and the +result stored in the `result_queue`, the process terminates. There is no need for a +`task_queue` in this approach, since as soon as a task is received a new process will be +allocated to the task. Processes used in this strategy are of class LocalWorker. + +2. Limited Parallelism (self.parallelism > 0): In this strategy, the LocalExecutor spawns +the number of processes equal to the value of `self.parallelism` at `start` time, +using a `task_queue` to coordinate the ingestion of tasks and the work distribution among +the workers, which will take a task as soon as they are ready. During the lifecycle of +the LocalExecutor, the worker processes are running waiting for tasks, once the +LocalExecutor receives the call to shutdown the executor a poison token is sent to the +workers to terminate them. Processes used in this strategy are of class QueuedLocalWorker. + +Arguably, `SequentialExecutor` could be thought as a LocalExecutor with limited +parallelism of just 1 worker, i.e. `self.parallelism = 1`. +This option could lead to the unification of the executor implementations, running +locally, into just one `LocalExecutor` with multiple modes. +""" + +import multiprocessing +import subprocess +import time + +from builtins import range + +from airflow.executors.base_executor import BaseExecutor +from airflow.utils.log.logging_mixin import LoggingMixin +from airflow.utils.state import State + + +class LocalWorker(multiprocessing.Process, LoggingMixin): + + """LocalWorker Process implementation to run airflow commands. Executes the given + command and puts the result into a result queue when done, terminating execution.""" + + def __init__(self, result_queue): + """ + :param result_queue: the queue to store result states tuples (key, State) + :type result_queue: multiprocessing.Queue + """ + super(LocalWorker, self).__init__() + self.daemon = True + self.result_queue = result_queue + self.key = None + self.command = None + + def execute_work(self, key, command): + """ + Executes command received and stores result state in queue. + :param key: the key to identify the TI + :type key: Tuple(dag_id, task_id, execution_date) + :param command: the command to execute + :type command: string + """ + if key is None: + return + self.log.info("%s running %s", self.__class__.__name__, command) + command = "exec bash -c '{0}'".format(command) + try: + subprocess.check_call(command, shell=True) + state = State.SUCCESS + except subprocess.CalledProcessError as e: + state = State.FAILED + self.log.error("Failed to execute task %s.", str(e)) + # TODO: Why is this commented out? + # raise e + self.result_queue.put((key, state)) + + def run(self): + self.execute_work(self.key, self.command) + time.sleep(1) + + +class QueuedLocalWorker(LocalWorker): + + """LocalWorker implementation that is waiting for tasks from a queue and will + continue executing commands as they become available in the queue. It will terminate + execution once the poison token is found.""" + + def __init__(self, task_queue, result_queue): + super(QueuedLocalWorker, self).__init__(result_queue=result_queue) + self.task_queue = task_queue + + def run(self): + while True: + key, command = self.task_queue.get() + if key is None: + # Received poison pill, no more tasks to run + self.task_queue.task_done() + break + self.execute_work(key, command) + self.task_queue.task_done() + time.sleep(1) + + +class LocalExecutor(BaseExecutor): + """ + LocalExecutor executes tasks locally in parallel. It uses the + multiprocessing Python library and queues to parallelize the execution + of tasks. + """ + + class _UnlimitedParallelism(object): + """Implements LocalExecutor with unlimited parallelism, starting one process + per each command to execute.""" + + def __init__(self, executor): + """ + :param executor: the executor instance to implement. + :type executor: LocalExecutor + """ + self.executor = executor + + def start(self): + self.executor.workers_used = 0 + self.executor.workers_active = 0 + + def execute_async(self, key, command): + """ + :param key: the key to identify the TI + :type key: Tuple(dag_id, task_id, execution_date) + :param command: the command to execute + :type command: string + """ + local_worker = LocalWorker(self.executor.result_queue) + local_worker.key = key + local_worker.command = command + self.executor.workers_used += 1 + self.executor.workers_active += 1 + local_worker.start() + + def sync(self): + while not self.executor.result_queue.empty(): + results = self.executor.result_queue.get() + self.executor.change_state(*results) + self.executor.workers_active -= 1 + + def end(self): + while self.executor.workers_active > 0: + self.executor.sync() + time.sleep(0.5) + + class _LimitedParallelism(object): + """Implements LocalExecutor with limited parallelism using a task queue to + coordinate work distribution.""" + + def __init__(self, executor): + self.executor = executor + + def start(self): + self.executor.queue = multiprocessing.JoinableQueue() + + self.executor.workers = [ + QueuedLocalWorker(self.executor.queue, self.executor.result_queue) + for _ in range(self.executor.parallelism) + ] + + self.executor.workers_used = len(self.executor.workers) + + for w in self.executor.workers: + w.start() + + def execute_async(self, key, command): + """ + :param key: the key to identify the TI + :type key: Tuple(dag_id, task_id, execution_date) + :param command: the command to execute + :type command: string + """ + self.executor.queue.put((key, command)) + + def sync(self): + while not self.executor.result_queue.empty(): + results = self.executor.result_queue.get() + self.executor.change_state(*results) + + def end(self): + # Sending poison pill to all worker + for _ in self.executor.workers: + self.executor.queue.put((None, None)) + + # Wait for commands to finish + self.executor.queue.join() + self.executor.sync() + + def start(self): + self.result_queue = multiprocessing.Queue() + self.queue = None + self.workers = [] + self.workers_used = 0 + self.workers_active = 0 + self.impl = (LocalExecutor._UnlimitedParallelism(self) if self.parallelism == 0 + else LocalExecutor._LimitedParallelism(self)) + + self.impl.start() + + def execute_async(self, key, command, queue=None): + self.impl.execute_async(key=key, command=command) + + def sync(self): + self.impl.sync() + + def end(self): + self.impl.end() diff --git a/airflow/executors/sequential_executor.py b/airflow/executors/sequential_executor.py new file mode 100644 index 0000000000000..a15450dabf576 --- /dev/null +++ b/airflow/executors/sequential_executor.py @@ -0,0 +1,52 @@ +# -*- coding: utf-8 -*- +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from builtins import str +import subprocess + +from airflow.executors.base_executor import BaseExecutor +from airflow.utils.state import State + + +class SequentialExecutor(BaseExecutor): + """ + This executor will only run one task instance at a time, can be used + for debugging. It is also the only executor that can be used with sqlite + since sqlite doesn't support multiple connections. + + Since we want airflow to work out of the box, it defaults to this + SequentialExecutor alongside sqlite as you first install it. + """ + def __init__(self): + super(SequentialExecutor, self).__init__() + self.commands_to_run = [] + + def execute_async(self, key, command, queue=None): + self.commands_to_run.append((key, command,)) + + def sync(self): + for key, command in self.commands_to_run: + self.log.info("Executing command: %s", command) + + try: + subprocess.check_call(command, shell=True) + self.change_state(key, State.SUCCESS) + except subprocess.CalledProcessError as e: + self.change_state(key, State.FAILED) + self.log.error("Failed to execute task %s.", str(e)) + + self.commands_to_run = [] + + def end(self): + self.heartbeat() diff --git a/airflow/hooks/S3_hook.py b/airflow/hooks/S3_hook.py new file mode 100644 index 0000000000000..f8052ca4ba6d1 --- /dev/null +++ b/airflow/hooks/S3_hook.py @@ -0,0 +1,253 @@ +# -*- coding: utf-8 -*- +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from airflow.exceptions import AirflowException +from airflow.contrib.hooks.aws_hook import AwsHook + +from six import StringIO +from urllib.parse import urlparse +import re +import fnmatch + +class S3Hook(AwsHook): + """ + Interact with AWS S3, using the boto3 library. + """ + + def get_conn(self): + return self.get_client_type('s3') + + @staticmethod + def parse_s3_url(s3url): + parsed_url = urlparse(s3url) + if not parsed_url.netloc: + raise AirflowException('Please provide a bucket_name') + else: + bucket_name = parsed_url.netloc + key = parsed_url.path.strip('/') + return (bucket_name, key) + + def check_for_bucket(self, bucket_name): + """ + Check if bucket_name exists. + + :param bucket_name: the name of the bucket + :type bucket_name: str + """ + try: + self.get_conn().head_bucket(Bucket=bucket_name) + return True + except: + return False + + def get_bucket(self, bucket_name): + """ + Returns a boto3.S3.Bucket object + + :param bucket_name: the name of the bucket + :type bucket_name: str + """ + s3 = self.get_resource('s3') + return s3.Bucket(bucket_name) + + def check_for_prefix(self, bucket_name, prefix, delimiter): + """ + Checks that a prefix exists in a bucket + """ + prefix = prefix + delimiter if prefix[-1] != delimiter else prefix + prefix_split = re.split(r'(\w+[{d}])$'.format(d=delimiter), prefix, 1) + previous_level = prefix_split[0] + plist = self.list_prefixes(bucket_name, previous_level, delimiter) + return False if plist is None else prefix in plist + + def list_prefixes(self, bucket_name, prefix='', delimiter=''): + """ + Lists prefixes in a bucket under prefix + + :param bucket_name: the name of the bucket + :type bucket_name: str + :param prefix: a key prefix + :type prefix: str + :param delimiter: the delimiter marks key hierarchy. + :type delimiter: str + """ + response = self.get_conn().list_objects_v2(Bucket=bucket_name, + Prefix=prefix, + Delimiter=delimiter) + return [p.Prefix for p in response['CommonPrefixes']] if response.get('CommonPrefixes') else None + + def list_keys(self, bucket_name, prefix='', delimiter=''): + """ + Lists keys in a bucket under prefix and not containing delimiter + + :param bucket_name: the name of the bucket + :type bucket_name: str + :param prefix: a key prefix + :type prefix: str + :param delimiter: the delimiter marks key hierarchy. + :type delimiter: str + """ + response = self.get_conn().list_objects_v2(Bucket=bucket_name, + Prefix=prefix, + Delimiter=delimiter) + return [k.Key for k in response['Contents']] if response.get('Contents') else None + + def check_for_key(self, key, bucket_name=None): + """ + Checks if a key exists in a bucket + + :param key: S3 key that will point to the file + :type key: str + :param bucket_name: Name of the bucket in which the file is stored + :type bucket_name: str + """ + if not bucket_name: + (bucket_name, key) = self.parse_s3_url(key) + + try: + self.get_conn().head_object(Bucket=bucket_name, Key=key) + return True + except: + return False + + def get_key(self, key, bucket_name=None): + """ + Returns a boto3.S3.Key object + + :param key: the path to the key + :type key: str + :param bucket_name: the name of the bucket + :type bucket_name: str + """ + if not bucket_name: + (bucket_name, key) = self.parse_s3_url(key) + + return self.get_conn().get_object(Bucket=bucket_name, Key=key) + + def read_key(self, key, bucket_name=None): + """ + Reads a key from S3 + + :param key: S3 key that will point to the file + :type key: str + :param bucket_name: Name of the bucket in which the file is stored + :type bucket_name: str + """ + + obj = self.get_key(key, bucket_name) + return obj['Body'].read().decode('utf-8') + + def check_for_wildcard_key(self, + wildcard_key, bucket_name=None, delimiter=''): + """ + Checks that a key matching a wildcard expression exists in a bucket + """ + return self.get_wildcard_key(wildcard_key=wildcard_key, + bucket_name=bucket_name, + delimiter=delimiter) is not None + + def get_wildcard_key(self, wildcard_key, bucket_name=None, delimiter=''): + """ + Returns a boto3.s3.Key object matching the regular expression + + :param regex_key: the path to the key + :type regex_key: str + :param bucket_name: the name of the bucket + :type bucket_name: str + """ + if not bucket_name: + (bucket_name, wildcard_key) = self.parse_s3_url(wildcard_key) + + prefix = re.split(r'[*]', wildcard_key, 1)[0] + klist = self.list_keys(bucket_name, prefix=prefix, delimiter=delimiter) + if klist: + key_matches = [k for k in klist if fnmatch.fnmatch(k, wildcard_key)] + if key_matches: + return self.get_key(key_matches[0], bucket_name) + + def load_file(self, + filename, + key, + bucket_name=None, + replace=False, + encrypt=False): + """ + Loads a local file to S3 + + :param filename: name of the file to load. + :type filename: str + :param key: S3 key that will point to the file + :type key: str + :param bucket_name: Name of the bucket in which to store the file + :type bucket_name: str + :param replace: A flag to decide whether or not to overwrite the key + if it already exists. If replace is False and the key exists, an + error will be raised. + :type replace: bool + :param encrypt: If True, the file will be encrypted on the server-side + by S3 and will be stored in an encrypted form while at rest in S3. + :type encrypt: bool + """ + if not bucket_name: + (bucket_name, key) = self.parse_s3_url(key) + + if not replace and self.check_for_key(key, bucket_name): + raise ValueError("The key {key} already exists.".format(key=key)) + + extra_args={} + if encrypt: + extra_args['ServerSideEncryption'] = "AES256" + + client = self.get_conn() + client.upload_file(filename, bucket_name, key, ExtraArgs=extra_args) + + def load_string(self, + string_data, + key, + bucket_name=None, + replace=False, + encrypt=False): + """ + Loads a string to S3 + + This is provided as a convenience to drop a string in S3. It uses the + boto infrastructure to ship a file to s3. + + :param string_data: string to set as content for the key. + :type string_data: str + :param key: S3 key that will point to the file + :type key: str + :param bucket_name: Name of the bucket in which to store the file + :type bucket_name: str + :param replace: A flag to decide whether or not to overwrite the key + if it already exists + :type replace: bool + :param encrypt: If True, the file will be encrypted on the server-side + by S3 and will be stored in an encrypted form while at rest in S3. + :type encrypt: bool + """ + if not bucket_name: + (bucket_name, key) = self.parse_s3_url(key) + + if not replace and self.check_for_key(key, bucket_name): + raise ValueError("The key {key} already exists.".format(key=key)) + + extra_args={} + if encrypt: + extra_args['ServerSideEncryption'] = "AES256" + + filelike_buffer = StringIO(string_data) + + client = self.get_conn() + client.upload_fileobj(filelike_buffer, bucket_name, key, ExtraArgs=extra_args) diff --git a/airflow/hooks/__init__.py b/airflow/hooks/__init__.py new file mode 100644 index 0000000000000..bb029678ef919 --- /dev/null +++ b/airflow/hooks/__init__.py @@ -0,0 +1,88 @@ +# -*- coding: utf-8 -*- +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + + +import sys + + +# ------------------------------------------------------------------------ +# +# #TODO #FIXME Airflow 2.0 +# +# Old import machinary below. +# +# This is deprecated but should be kept until Airflow 2.0 +# for compatibility. +# +# ------------------------------------------------------------------------ + +# Imports the hooks dynamically while keeping the package API clean, +# abstracting the underlying modules + + +_hooks = { + 'base_hook': ['BaseHook'], + 'hive_hooks': [ + 'HiveCliHook', + 'HiveMetastoreHook', + 'HiveServer2Hook', + ], + 'hdfs_hook': ['HDFSHook'], + 'webhdfs_hook': ['WebHDFSHook'], + 'pig_hook': ['PigCliHook'], + 'mysql_hook': ['MySqlHook'], + 'postgres_hook': ['PostgresHook'], + 'presto_hook': ['PrestoHook'], + 'samba_hook': ['SambaHook'], + 'sqlite_hook': ['SqliteHook'], + 'S3_hook': ['S3Hook'], + 'zendesk_hook': ['ZendeskHook'], + 'http_hook': ['HttpHook'], + 'druid_hook': ['DruidHook'], + 'jdbc_hook': ['JdbcHook'], + 'dbapi_hook': ['DbApiHook'], + 'mssql_hook': ['MsSqlHook'], + 'oracle_hook': ['OracleHook'], +} + +import os as _os +if not _os.environ.get('AIRFLOW_USE_NEW_IMPORTS', False): + from airflow.utils.helpers import AirflowImporter + airflow_importer = AirflowImporter(sys.modules[__name__], _hooks) + + +def _integrate_plugins(): + """Integrate plugins to the context""" + from airflow.plugins_manager import hooks_modules + for hooks_module in hooks_modules: + sys.modules[hooks_module.__name__] = hooks_module + globals()[hooks_module._name] = hooks_module + + ########################################################## + # TODO FIXME Remove in Airflow 2.0 + + if not _os.environ.get('AIRFLOW_USE_NEW_IMPORTS', False): + from zope.deprecation import deprecated as _deprecated + for _hook in hooks_module._objects: + hook_name = _hook.__name__ + globals()[hook_name] = _hook + _deprecated( + hook_name, + "Importing plugin hook '{i}' directly from " + "'airflow.hooks' has been deprecated. Please " + "import from 'airflow.hooks.[plugin_module]' " + "instead. Support for direct imports will be dropped " + "entirely in Airflow 2.0.".format(i=hook_name)) + diff --git a/airflow/hooks/base_hook.py b/airflow/hooks/base_hook.py new file mode 100644 index 0000000000000..92313ca267130 --- /dev/null +++ b/airflow/hooks/base_hook.py @@ -0,0 +1,98 @@ +# -*- coding: utf-8 -*- +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from __future__ import absolute_import +from __future__ import division +from __future__ import print_function +from __future__ import unicode_literals + +import os +import random + +from airflow import settings +from airflow.models import Connection +from airflow.exceptions import AirflowException +from airflow.utils.log.logging_mixin import LoggingMixin + +CONN_ENV_PREFIX = 'AIRFLOW_CONN_' + + +class BaseHook(LoggingMixin): + """ + Abstract base class for hooks, hooks are meant as an interface to + interact with external systems. MySqlHook, HiveHook, PigHook return + object that can handle the connection and interaction to specific + instances of these systems, and expose consistent methods to interact + with them. + """ + def __init__(self, source): + pass + + + @classmethod + def _get_connections_from_db(cls, conn_id): + session = settings.Session() + db = ( + session.query(Connection) + .filter(Connection.conn_id == conn_id) + .all() + ) + session.expunge_all() + session.close() + if not db: + raise AirflowException( + "The conn_id `{0}` isn't defined".format(conn_id)) + return db + + @classmethod + def _get_connection_from_env(cls, conn_id): + environment_uri = os.environ.get(CONN_ENV_PREFIX + conn_id.upper()) + conn = None + if environment_uri: + conn = Connection(conn_id=conn_id, uri=environment_uri) + return conn + + @classmethod + def get_connections(cls, conn_id): + conn = cls._get_connection_from_env(conn_id) + if conn: + conns = [conn] + else: + conns = cls._get_connections_from_db(conn_id) + return conns + + @classmethod + def get_connection(cls, conn_id): + conn = random.choice(cls.get_connections(conn_id)) + if conn.host: + log = LoggingMixin().log + log.info("Using connection to: %s", conn.host) + return conn + + @classmethod + def get_hook(cls, conn_id): + connection = cls.get_connection(conn_id) + return connection.get_hook() + + def get_conn(self): + raise NotImplementedError() + + def get_records(self, sql): + raise NotImplementedError() + + def get_pandas_df(self, sql): + raise NotImplementedError() + + def run(self, sql): + raise NotImplementedError() diff --git a/airflow/hooks/dbapi_hook.py b/airflow/hooks/dbapi_hook.py new file mode 100644 index 0000000000000..bbdedd7b4c93f --- /dev/null +++ b/airflow/hooks/dbapi_hook.py @@ -0,0 +1,266 @@ +# -*- coding: utf-8 -*- +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from builtins import str +from past.builtins import basestring +from datetime import datetime +from contextlib import closing +import sys + +from sqlalchemy import create_engine + +from airflow.hooks.base_hook import BaseHook +from airflow.exceptions import AirflowException + + +class DbApiHook(BaseHook): + """ + Abstract base class for sql hooks. + """ + # Override to provide the connection name. + conn_name_attr = None + # Override to have a default connection id for a particular dbHook + default_conn_name = 'default_conn_id' + # Override if this db supports autocommit. + supports_autocommit = False + # Override with the object that exposes the connect method + connector = None + + def __init__(self, *args, **kwargs): + if not self.conn_name_attr: + raise AirflowException("conn_name_attr is not defined") + elif len(args) == 1: + setattr(self, self.conn_name_attr, args[0]) + elif self.conn_name_attr not in kwargs: + setattr(self, self.conn_name_attr, self.default_conn_name) + else: + setattr(self, self.conn_name_attr, kwargs[self.conn_name_attr]) + + def get_conn(self): + """Returns a connection object + """ + db = self.get_connection(getattr(self, self.conn_name_attr)) + return self.connector.connect( + host=db.host, + port=db.port, + username=db.login, + schema=db.schema) + + def get_uri(self): + conn = self.get_connection(getattr(self, self.conn_name_attr)) + login = '' + if conn.login: + login = '{conn.login}:{conn.password}@'.format(conn=conn) + host = conn.host + if conn.port is not None: + host += ':{port}'.format(port=conn.port) + return '{conn.conn_type}://{login}{host}/{conn.schema}'.format( + conn=conn, login=login, host=host) + + def get_sqlalchemy_engine(self, engine_kwargs=None): + if engine_kwargs is None: + engine_kwargs = {} + return create_engine(self.get_uri(), **engine_kwargs) + + def get_pandas_df(self, sql, parameters=None): + """ + Executes the sql and returns a pandas dataframe + + :param sql: the sql statement to be executed (str) or a list of + sql statements to execute + :type sql: str or list + :param parameters: The parameters to render the SQL query with. + :type parameters: mapping or iterable + """ + if sys.version_info[0] < 3: + sql = sql.encode('utf-8') + import pandas.io.sql as psql + + with closing(self.get_conn()) as conn: + return psql.read_sql(sql, con=conn, params=parameters) + + def get_records(self, sql, parameters=None): + """ + Executes the sql and returns a set of records. + + :param sql: the sql statement to be executed (str) or a list of + sql statements to execute + :type sql: str or list + :param parameters: The parameters to render the SQL query with. + :type parameters: mapping or iterable + """ + if sys.version_info[0] < 3: + sql = sql.encode('utf-8') + + with closing(self.get_conn()) as conn: + with closing(conn.cursor()) as cur: + if parameters is not None: + cur.execute(sql, parameters) + else: + cur.execute(sql) + return cur.fetchall() + + def get_first(self, sql, parameters=None): + """ + Executes the sql and returns the first resulting row. + + :param sql: the sql statement to be executed (str) or a list of + sql statements to execute + :type sql: str or list + :param parameters: The parameters to render the SQL query with. + :type parameters: mapping or iterable + """ + if sys.version_info[0] < 3: + sql = sql.encode('utf-8') + + with closing(self.get_conn()) as conn: + with closing(conn.cursor()) as cur: + if parameters is not None: + cur.execute(sql, parameters) + else: + cur.execute(sql) + return cur.fetchone() + + def run(self, sql, autocommit=False, parameters=None): + """ + Runs a command or a list of commands. Pass a list of sql + statements to the sql parameter to get them to execute + sequentially + + :param sql: the sql statement to be executed (str) or a list of + sql statements to execute + :type sql: str or list + :param autocommit: What to set the connection's autocommit setting to + before executing the query. + :type autocommit: bool + :param parameters: The parameters to render the SQL query with. + :type parameters: mapping or iterable + """ + if isinstance(sql, basestring): + sql = [sql] + + with closing(self.get_conn()) as conn: + if self.supports_autocommit: + self.set_autocommit(conn, autocommit) + + with closing(conn.cursor()) as cur: + for s in sql: + if sys.version_info[0] < 3: + s = s.encode('utf-8') + self.log.info(s) + if parameters is not None: + cur.execute(s, parameters) + else: + cur.execute(s) + + conn.commit() + + def set_autocommit(self, conn, autocommit): + conn.autocommit = autocommit + + def get_cursor(self): + """ + Returns a cursor + """ + return self.get_conn().cursor() + + def insert_rows(self, table, rows, target_fields=None, commit_every=1000): + """ + A generic way to insert a set of tuples into a table, + a new transaction is created every commit_every rows + + :param table: Name of the target table + :type table: str + :param rows: The rows to insert into the table + :type rows: iterable of tuples + :param target_fields: The names of the columns to fill in the table + :type target_fields: iterable of strings + :param commit_every: The maximum number of rows to insert in one + transaction. Set to 0 to insert all rows in one transaction. + :type commit_every: int + """ + if target_fields: + target_fields = ", ".join(target_fields) + target_fields = "({})".format(target_fields) + else: + target_fields = '' + + with closing(self.get_conn()) as conn: + if self.supports_autocommit: + self.set_autocommit(conn, False) + + conn.commit() + + with closing(conn.cursor()) as cur: + for i, row in enumerate(rows, 1): + l = [] + for cell in row: + l.append(self._serialize_cell(cell, conn)) + values = tuple(l) + placeholders = ["%s",]*len(values) + sql = "INSERT INTO {0} {1} VALUES ({2});".format( + table, + target_fields, + ",".join(placeholders)) + cur.execute(sql, values) + if commit_every and i % commit_every == 0: + conn.commit() + self.log.info( + "Loaded {i} into {table} rows so far".format(**locals()) + ) + + conn.commit() + self.log.info( + "Done loading. Loaded a total of {i} rows".format(**locals())) + + @staticmethod + def _serialize_cell(cell, conn=None): + """ + Returns the SQL literal of the cell as a string. + + :param cell: The cell to insert into the table + :type cell: object + :param conn: The database connection + :type conn: connection object + :return: The serialized cell + :rtype: str + """ + + if cell is None: + return None + if isinstance(cell, datetime): + return cell.isoformat() + return str(cell) + + def bulk_dump(self, table, tmp_file): + """ + Dumps a database table into a tab-delimited file + + :param table: The name of the source table + :type table: str + :param tmp_file: The path of the target file + :type tmp_file: str + """ + raise NotImplementedError() + + def bulk_load(self, table, tmp_file): + """ + Loads a tab-delimited file into a database table + + :param table: The name of the target table + :type table: str + :param tmp_file: The path of the file to load into the table + :type tmp_file: str + """ + raise NotImplementedError() diff --git a/airflow/hooks/druid_hook.py b/airflow/hooks/druid_hook.py new file mode 100644 index 0000000000000..655f66688d16b --- /dev/null +++ b/airflow/hooks/druid_hook.py @@ -0,0 +1,92 @@ +# -*- coding: utf-8 -*- +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from __future__ import print_function + +import requests +import time + +from airflow.exceptions import AirflowException +from airflow.hooks.base_hook import BaseHook + + +class DruidHook(BaseHook): + """ + Connection to Druid + + :param druid_ingest_conn_id: The connection id to the Druid overlord machine which accepts index jobs + :type druid_ingest_conn_id: string + :param timeout: The interval between polling the Druid job for the status of the ingestion job + :type timeout: int + :param max_ingestion_time: The maximum ingestion time before assuming the job failed + :type max_ingestion_time: int + """ + def __init__( + self, + druid_ingest_conn_id='druid_ingest_default', + timeout=1, + max_ingestion_time=18000): + + self.druid_ingest_conn_id = druid_ingest_conn_id + self.timeout = timeout + self.max_ingestion_time = max_ingestion_time + self.header = {'content-type': 'application/json'} + + def get_conn_url(self): + conn = self.get_connection(self.druid_ingest_conn_id) + host = conn.host + port = conn.port + schema = conn.extra_dejson.get('schema', 'http') + endpoint = conn.extra_dejson.get('endpoint', '') + return "http://{host}:{port}/{endpoint}".format(**locals()) + + def submit_indexing_job(self, json_index_spec): + url = self.get_conn_url() + + req_index = requests.post(url, data=json_index_spec, headers=self.header) + if (req_index.status_code != 200): + raise AirflowException("Did not get 200 when submitting the Druid job to {}".format(url)) + + req_json = req_index.json() + # Wait until the job is completed + druid_task_id = req_json['task'] + + running = True + + sec = 0 + while running: + req_status = requests.get("{0}/{1}/status".format(url, druid_task_id)) + + self.log.info("Job still running for %s seconds...", sec) + + sec = sec + 1 + + if sec > self.max_ingestion_time: + # ensure that the job gets killed if the max ingestion time is exceeded + requests.post("{0}/{1}/shutdown".format(url, druid_task_id)) + raise AirflowException('Druid ingestion took more than %s seconds', self.max_ingestion_time) + + time.sleep(self.timeout) + + status = req_status.json()['status']['status'] + if status == 'RUNNING': + running = True + elif status == 'SUCCESS': + running = False # Great success! + elif status == 'FAILED': + raise AirflowException('Druid indexing job failed, check console for more info') + else: + raise AirflowException('Could not get status of the job, got %s', status) + + self.log.info('Successful index') diff --git a/airflow/hooks/hdfs_hook.py b/airflow/hooks/hdfs_hook.py new file mode 100644 index 0000000000000..549b60973be3e --- /dev/null +++ b/airflow/hooks/hdfs_hook.py @@ -0,0 +1,95 @@ +# -*- coding: utf-8 -*- +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from airflow.hooks.base_hook import BaseHook +from airflow import configuration + +try: + snakebite_imported = True + from snakebite.client import Client, HAClient, Namenode, AutoConfigClient +except ImportError: + snakebite_imported = False + +from airflow.exceptions import AirflowException + + +class HDFSHookException(AirflowException): + pass + + +class HDFSHook(BaseHook): + """ + Interact with HDFS. This class is a wrapper around the snakebite library. + + :param hdfs_conn_id: Connection id to fetch connection info + :type conn_id: string + :param proxy_user: effective user for HDFS operations + :type proxy_user: string + :param autoconfig: use snakebite's automatically configured client + :type autoconfig: bool + """ + def __init__(self, hdfs_conn_id='hdfs_default', proxy_user=None, + autoconfig=False): + if not snakebite_imported: + raise ImportError( + 'This HDFSHook implementation requires snakebite, but ' + 'snakebite is not compatible with Python 3 ' + '(as of August 2015). Please use Python 2 if you require ' + 'this hook -- or help by submitting a PR!') + self.hdfs_conn_id = hdfs_conn_id + self.proxy_user = proxy_user + self.autoconfig = autoconfig + + def get_conn(self): + """ + Returns a snakebite HDFSClient object. + """ + # When using HAClient, proxy_user must be the same, so is ok to always + # take the first. + effective_user = self.proxy_user + autoconfig = self.autoconfig + use_sasl = configuration.get('core', 'security') == 'kerberos' + + try: + connections = self.get_connections(self.hdfs_conn_id) + + if not effective_user: + effective_user = connections[0].login + if not autoconfig: + autoconfig = connections[0].extra_dejson.get('autoconfig', + False) + hdfs_namenode_principal = connections[0].extra_dejson.get( + 'hdfs_namenode_principal') + except AirflowException: + if not autoconfig: + raise + + if autoconfig: + # will read config info from $HADOOP_HOME conf files + client = AutoConfigClient(effective_user=effective_user, + use_sasl=use_sasl) + elif len(connections) == 1: + client = Client(connections[0].host, connections[0].port, + effective_user=effective_user, use_sasl=use_sasl, + hdfs_namenode_principal=hdfs_namenode_principal) + elif len(connections) > 1: + nn = [Namenode(conn.host, conn.port) for conn in connections] + client = HAClient(nn, effective_user=effective_user, + use_sasl=use_sasl, + hdfs_namenode_principal=hdfs_namenode_principal) + else: + raise HDFSHookException("conn_id doesn't exist in the repository " + "and autoconfig is not specified") + + return client diff --git a/airflow/hooks/hive_hooks.py b/airflow/hooks/hive_hooks.py new file mode 100644 index 0000000000000..a8f9c8ff6f194 --- /dev/null +++ b/airflow/hooks/hive_hooks.py @@ -0,0 +1,763 @@ +# -*- coding: utf-8 -*- +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +from __future__ import print_function +from six.moves import zip +from past.builtins import basestring + +import unicodecsv as csv +import itertools +import re +import subprocess +import time +from tempfile import NamedTemporaryFile +import hive_metastore + +from airflow.exceptions import AirflowException +from airflow.hooks.base_hook import BaseHook +from airflow.utils.helpers import as_flattened_list +from airflow.utils.file import TemporaryDirectory +from airflow import configuration +import airflow.security.utils as utils + +HIVE_QUEUE_PRIORITIES = ['VERY_HIGH', 'HIGH', 'NORMAL', 'LOW', 'VERY_LOW'] + + +class HiveCliHook(BaseHook): + """Simple wrapper around the hive CLI. + + It also supports the ``beeline`` + a lighter CLI that runs JDBC and is replacing the heavier + traditional CLI. To enable ``beeline``, set the use_beeline param in the + extra field of your connection as in ``{ "use_beeline": true }`` + + Note that you can also set default hive CLI parameters using the + ``hive_cli_params`` to be used in your connection as in + ``{"hive_cli_params": "-hiveconf mapred.job.tracker=some.jobtracker:444"}`` + Parameters passed here can be overridden by run_cli's hive_conf param + + The extra connection parameter ``auth`` gets passed as in the ``jdbc`` + connection string as is. + + :param mapred_queue: queue used by the Hadoop Scheduler (Capacity or Fair) + :type mapred_queue: string + :param mapred_queue_priority: priority within the job queue. + Possible settings include: VERY_HIGH, HIGH, NORMAL, LOW, VERY_LOW + :type mapred_queue_priority: string + :param mapred_job_name: This name will appear in the jobtracker. + This can make monitoring easier. + :type mapred_job_name: string + """ + + def __init__( + self, + hive_cli_conn_id="hive_cli_default", + run_as=None, + mapred_queue=None, + mapred_queue_priority=None, + mapred_job_name=None): + conn = self.get_connection(hive_cli_conn_id) + self.hive_cli_params = conn.extra_dejson.get('hive_cli_params', '') + self.use_beeline = conn.extra_dejson.get('use_beeline', False) + self.auth = conn.extra_dejson.get('auth', 'noSasl') + self.conn = conn + self.run_as = run_as + + if mapred_queue_priority: + mapred_queue_priority = mapred_queue_priority.upper() + if mapred_queue_priority not in HIVE_QUEUE_PRIORITIES: + raise AirflowException( + "Invalid Mapred Queue Priority. Valid values are: " + "{}".format(', '.join(HIVE_QUEUE_PRIORITIES))) + + self.mapred_queue = mapred_queue + self.mapred_queue_priority = mapred_queue_priority + self.mapred_job_name = mapred_job_name + + def _prepare_cli_cmd(self): + """ + This function creates the command list from available information + """ + conn = self.conn + hive_bin = 'hive' + cmd_extra = [] + + if self.use_beeline: + hive_bin = 'beeline' + jdbc_url = "jdbc:hive2://{conn.host}:{conn.port}/{conn.schema}" + if configuration.get('core', 'security') == 'kerberos': + template = conn.extra_dejson.get( + 'principal', "hive/_HOST@EXAMPLE.COM") + if "_HOST" in template: + template = utils.replace_hostname_pattern( + utils.get_components(template)) + + proxy_user = "" # noqa + if conn.extra_dejson.get('proxy_user') == "login" and conn.login: + proxy_user = "hive.server2.proxy.user={0}".format(conn.login) + elif conn.extra_dejson.get('proxy_user') == "owner" and self.run_as: + proxy_user = "hive.server2.proxy.user={0}".format(self.run_as) + + jdbc_url += ";principal={template};{proxy_user}" + elif self.auth: + jdbc_url += ";auth=" + self.auth + + jdbc_url = jdbc_url.format(**locals()) + + cmd_extra += ['-u', jdbc_url] + if conn.login: + cmd_extra += ['-n', conn.login] + if conn.password: + cmd_extra += ['-p', conn.password] + + hive_params_list = self.hive_cli_params.split() + + return [hive_bin] + cmd_extra + hive_params_list + + def _prepare_hiveconf(self, d): + """ + This function prepares a list of hiveconf params + from a dictionary of key value pairs. + + :param d: + :type d: dict + + >>> hh = HiveCliHook() + >>> hive_conf = {"hive.exec.dynamic.partition": "true", + ... "hive.exec.dynamic.partition.mode": "nonstrict"} + >>> hh._prepare_hiveconf(hive_conf) + ["-hiveconf", "hive.exec.dynamic.partition=true",\ + "-hiveconf", "hive.exec.dynamic.partition.mode=nonstrict"] + """ + if not d: + return [] + return as_flattened_list( + zip(["-hiveconf"] * len(d), + ["{}={}".format(k, v) for k, v in d.items()]) + ) + + def run_cli(self, hql, schema=None, verbose=True, hive_conf=None): + """ + Run an hql statement using the hive cli. If hive_conf is specified + it should be a dict and the entries will be set as key/value pairs + in HiveConf + + + :param hive_conf: if specified these key value pairs will be passed + to hive as ``-hiveconf "key"="value"``. Note that they will be + passed after the ``hive_cli_params`` and thus will override + whatever values are specified in the database. + :type hive_conf: dict + + >>> hh = HiveCliHook() + >>> result = hh.run_cli("USE airflow;") + >>> ("OK" in result) + True + """ + conn = self.conn + schema = schema or conn.schema + if schema: + hql = "USE {schema};\n{hql}".format(**locals()) + + with TemporaryDirectory(prefix='airflow_hiveop_') as tmp_dir: + with NamedTemporaryFile(dir=tmp_dir) as f: + f.write(hql.encode('UTF-8')) + f.flush() + hive_cmd = self._prepare_cli_cmd() + hive_conf_params = self._prepare_hiveconf(hive_conf) + if self.mapred_queue: + hive_conf_params.extend( + ['-hiveconf', + 'mapreduce.job.queuename={}' + .format(self.mapred_queue)]) + + if self.mapred_queue_priority: + hive_conf_params.extend( + ['-hiveconf', + 'mapreduce.job.priority={}' + .format(self.mapred_queue_priority)]) + + if self.mapred_job_name: + hive_conf_params.extend( + ['-hiveconf', + 'mapred.job.name={}' + .format(self.mapred_job_name)]) + + hive_cmd.extend(hive_conf_params) + hive_cmd.extend(['-f', f.name]) + + if verbose: + self.log.info(" ".join(hive_cmd)) + sp = subprocess.Popen( + hive_cmd, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + cwd=tmp_dir) + self.sp = sp + stdout = '' + while True: + line = sp.stdout.readline() + if not line: + break + stdout += line.decode('UTF-8') + if verbose: + self.log.info(line.decode('UTF-8').strip()) + sp.wait() + + if sp.returncode: + raise AirflowException(stdout) + + return stdout + + def test_hql(self, hql): + """ + Test an hql statement using the hive cli and EXPLAIN + + """ + create, insert, other = [], [], [] + for query in hql.split(';'): # naive + query_original = query + query = query.lower().strip() + + if query.startswith('create table'): + create.append(query_original) + elif query.startswith(('set ', + 'add jar ', + 'create temporary function')): + other.append(query_original) + elif query.startswith('insert'): + insert.append(query_original) + other = ';'.join(other) + for query_set in [create, insert]: + for query in query_set: + + query_preview = ' '.join(query.split())[:50] + self.log.info("Testing HQL [%s (...)]", query_preview) + if query_set == insert: + query = other + '; explain ' + query + else: + query = 'explain ' + query + try: + self.run_cli(query, verbose=False) + except AirflowException as e: + message = e.args[0].split('\n')[-2] + self.log.info(message) + error_loc = re.search('(\d+):(\d+)', message) + if error_loc and error_loc.group(1).isdigit(): + l = int(error_loc.group(1)) + begin = max(l-2, 0) + end = min(l+3, len(query.split('\n'))) + context = '\n'.join(query.split('\n')[begin:end]) + self.log.info("Context :\n %s", context) + else: + self.log.info("SUCCESS") + + def load_df( + self, + df, + table, + create=True, + recreate=False, + field_dict=None, + delimiter=',', + encoding='utf8', + pandas_kwargs=None, **kwargs): + """ + Loads a pandas DataFrame into hive. + + Hive data types will be inferred if not passed but column names will + not be sanitized. + + :param table: target Hive table, use dot notation to target a + specific database + :type table: str + :param create: whether to create the table if it doesn't exist + :type create: bool + :param recreate: whether to drop and recreate the table at every + execution + :type recreate: bool + :param field_dict: mapping from column name to hive data type + :type field_dict: dict + :param encoding: string encoding to use when writing DataFrame to file + :type encoding: str + :param pandas_kwargs: passed to DataFrame.to_csv + :type pandas_kwargs: dict + :param kwargs: passed to self.load_file + """ + + def _infer_field_types_from_df(df): + DTYPE_KIND_HIVE_TYPE = { + 'b': 'BOOLEAN', # boolean + 'i': 'BIGINT', # signed integer + 'u': 'BIGINT', # unsigned integer + 'f': 'DOUBLE', # floating-point + 'c': 'STRING', # complex floating-point + 'O': 'STRING', # object + 'S': 'STRING', # (byte-)string + 'U': 'STRING', # Unicode + 'V': 'STRING' # void + } + + return dict((col, DTYPE_KIND_HIVE_TYPE[dtype.kind]) for col, dtype in df.dtypes.iteritems()) + + if pandas_kwargs is None: + pandas_kwargs = {} + + with TemporaryDirectory(prefix='airflow_hiveop_') as tmp_dir: + with NamedTemporaryFile(dir=tmp_dir) as f: + + if field_dict is None and (create or recreate): + field_dict = _infer_field_types_from_df(df) + + df.to_csv(f, sep=delimiter, **pandas_kwargs) + + return self.load_file(filepath=f.name, + table=table, + delimiter=delimiter, + field_dict=field_dict, + **kwargs) + + def load_file( + self, + filepath, + table, + delimiter=",", + field_dict=None, + create=True, + overwrite=True, + partition=None, + recreate=False, + tblproperties=None): + """ + Loads a local file into Hive + + Note that the table generated in Hive uses ``STORED AS textfile`` + which isn't the most efficient serialization format. If a + large amount of data is loaded and/or if the tables gets + queried considerably, you may want to use this operator only to + stage the data into a temporary table before loading it into its + final destination using a ``HiveOperator``. + + :param filepath: local filepath of the file to load + :type filepath: str + :param table: target Hive table, use dot notation to target a + specific database + :type table: str + :param delimiter: field delimiter in the file + :type delimiter: str + :param field_dict: A dictionary of the fields name in the file + as keys and their Hive types as values + :type field_dict: dict + :param create: whether to create the table if it doesn't exist + :type create: bool + :param overwrite: whether to overwrite the data in table or partition + :type overwrite: bool + :param partition: target partition as a dict of partition columns + and values + :type partition: dict + :param recreate: whether to drop and recreate the table at every + execution + :type recreate: bool + :param tblproperties: TBLPROPERTIES of the hive table being created + :type tblproperties: dict + """ + hql = '' + if recreate: + hql += "DROP TABLE IF EXISTS {table};\n" + if create or recreate: + if field_dict is None: + raise ValueError("Must provide a field dict when creating a table") + fields = ",\n ".join( + [k + ' ' + v for k, v in field_dict.items()]) + hql += "CREATE TABLE IF NOT EXISTS {table} (\n{fields})\n" + if partition: + pfields = ",\n ".join( + [p + " STRING" for p in partition]) + hql += "PARTITIONED BY ({pfields})\n" + hql += "ROW FORMAT DELIMITED\n" + hql += "FIELDS TERMINATED BY '{delimiter}'\n" + hql += "STORED AS textfile\n" + if tblproperties is not None: + tprops = ", ".join( + ["'{0}'='{1}'".format(k, v) for k, v in tblproperties.items()]) + hql += "TBLPROPERTIES({tprops})\n" + hql += ";" + hql = hql.format(**locals()) + self.log.info(hql) + self.run_cli(hql) + hql = "LOAD DATA LOCAL INPATH '{filepath}' " + if overwrite: + hql += "OVERWRITE " + hql += "INTO TABLE {table} " + if partition: + pvals = ", ".join( + ["{0}='{1}'".format(k, v) for k, v in partition.items()]) + hql += "PARTITION ({pvals});" + hql = hql.format(**locals()) + self.log.info(hql) + self.run_cli(hql) + + def kill(self): + if hasattr(self, 'sp'): + if self.sp.poll() is None: + print("Killing the Hive job") + self.sp.terminate() + time.sleep(60) + self.sp.kill() + + +class HiveMetastoreHook(BaseHook): + + """ Wrapper to interact with the Hive Metastore""" + + def __init__(self, metastore_conn_id='metastore_default'): + self.metastore_conn = self.get_connection(metastore_conn_id) + self.metastore = self.get_metastore_client() + + def __getstate__(self): + # This is for pickling to work despite the thirft hive client not + # being pickable + d = dict(self.__dict__) + del d['metastore'] + return d + + def __setstate__(self, d): + self.__dict__.update(d) + self.__dict__['metastore'] = self.get_metastore_client() + + def get_metastore_client(self): + """ + Returns a Hive thrift client. + """ + from thrift.transport import TSocket, TTransport + from thrift.protocol import TBinaryProtocol + from hive_service import ThriftHive + ms = self.metastore_conn + auth_mechanism = ms.extra_dejson.get('authMechanism', 'NOSASL') + if configuration.get('core', 'security') == 'kerberos': + auth_mechanism = ms.extra_dejson.get('authMechanism', 'GSSAPI') + kerberos_service_name = ms.extra_dejson.get('kerberos_service_name', 'hive') + + socket = TSocket.TSocket(ms.host, ms.port) + if configuration.get('core', 'security') == 'kerberos' and auth_mechanism == 'GSSAPI': + try: + import saslwrapper as sasl + except ImportError: + import sasl + + def sasl_factory(): + sasl_client = sasl.Client() + sasl_client.setAttr("host", ms.host) + sasl_client.setAttr("service", kerberos_service_name) + sasl_client.init() + return sasl_client + + from thrift_sasl import TSaslClientTransport + transport = TSaslClientTransport(sasl_factory, "GSSAPI", socket) + else: + transport = TTransport.TBufferedTransport(socket) + + protocol = TBinaryProtocol.TBinaryProtocol(transport) + + return ThriftHive.Client(protocol) + + def get_conn(self): + return self.metastore + + def check_for_partition(self, schema, table, partition): + """ + Checks whether a partition exists + + :param schema: Name of hive schema (database) @table belongs to + :type schema: string + :param table: Name of hive table @partition belongs to + :type schema: string + :partition: Expression that matches the partitions to check for + (eg `a = 'b' AND c = 'd'`) + :type schema: string + :rtype: boolean + + >>> hh = HiveMetastoreHook() + >>> t = 'static_babynames_partitioned' + >>> hh.check_for_partition('airflow', t, "ds='2015-01-01'") + True + """ + self.metastore._oprot.trans.open() + partitions = self.metastore.get_partitions_by_filter( + schema, table, partition, 1) + self.metastore._oprot.trans.close() + if partitions: + return True + else: + return False + + def check_for_named_partition(self, schema, table, partition_name): + """ + Checks whether a partition with a given name exists + + :param schema: Name of hive schema (database) @table belongs to + :type schema: string + :param table: Name of hive table @partition belongs to + :type schema: string + :partition: Name of the partitions to check for (eg `a=b/c=d`) + :type schema: string + :rtype: boolean + + >>> hh = HiveMetastoreHook() + >>> t = 'static_babynames_partitioned' + >>> hh.check_for_named_partition('airflow', t, "ds=2015-01-01") + True + >>> hh.check_for_named_partition('airflow', t, "ds=xxx") + False + """ + self.metastore._oprot.trans.open() + try: + self.metastore.get_partition_by_name( + schema, table, partition_name) + return True + except hive_metastore.ttypes.NoSuchObjectException: + return False + finally: + self.metastore._oprot.trans.close() + + def get_table(self, table_name, db='default'): + """Get a metastore table object + + >>> hh = HiveMetastoreHook() + >>> t = hh.get_table(db='airflow', table_name='static_babynames') + >>> t.tableName + 'static_babynames' + >>> [col.name for col in t.sd.cols] + ['state', 'year', 'name', 'gender', 'num'] + """ + self.metastore._oprot.trans.open() + if db == 'default' and '.' in table_name: + db, table_name = table_name.split('.')[:2] + table = self.metastore.get_table(dbname=db, tbl_name=table_name) + self.metastore._oprot.trans.close() + return table + + def get_tables(self, db, pattern='*'): + """ + Get a metastore table object + """ + self.metastore._oprot.trans.open() + tables = self.metastore.get_tables(db_name=db, pattern=pattern) + objs = self.metastore.get_table_objects_by_name(db, tables) + self.metastore._oprot.trans.close() + return objs + + def get_databases(self, pattern='*'): + """ + Get a metastore table object + """ + self.metastore._oprot.trans.open() + dbs = self.metastore.get_databases(pattern) + self.metastore._oprot.trans.close() + return dbs + + def get_partitions( + self, schema, table_name, filter=None): + """ + Returns a list of all partitions in a table. Works only + for tables with less than 32767 (java short max val). + For subpartitioned table, the number might easily exceed this. + + >>> hh = HiveMetastoreHook() + >>> t = 'static_babynames_partitioned' + >>> parts = hh.get_partitions(schema='airflow', table_name=t) + >>> len(parts) + 1 + >>> parts + [{'ds': '2015-01-01'}] + """ + self.metastore._oprot.trans.open() + table = self.metastore.get_table(dbname=schema, tbl_name=table_name) + if len(table.partitionKeys) == 0: + raise AirflowException("The table isn't partitioned") + else: + if filter: + parts = self.metastore.get_partitions_by_filter( + db_name=schema, tbl_name=table_name, + filter=filter, max_parts=32767) + else: + parts = self.metastore.get_partitions( + db_name=schema, tbl_name=table_name, max_parts=32767) + + self.metastore._oprot.trans.close() + pnames = [p.name for p in table.partitionKeys] + return [dict(zip(pnames, p.values)) for p in parts] + + def max_partition(self, schema, table_name, field=None, filter=None): + """ + Returns the maximum value for all partitions in a table. Works only + for tables that have a single partition key. For subpartitioned + table, we recommend using signal tables. + + >>> hh = HiveMetastoreHook() + >>> t = 'static_babynames_partitioned' + >>> hh.max_partition(schema='airflow', table_name=t) + '2015-01-01' + """ + parts = self.get_partitions(schema, table_name, filter) + if not parts: + return None + elif len(parts[0]) == 1: + field = list(parts[0].keys())[0] + elif not field: + raise AirflowException( + "Please specify the field you want the max " + "value for") + + return max([p[field] for p in parts]) + + def table_exists(self, table_name, db='default'): + """ + Check if table exists + + >>> hh = HiveMetastoreHook() + >>> hh.table_exists(db='airflow', table_name='static_babynames') + True + >>> hh.table_exists(db='airflow', table_name='does_not_exist') + False + """ + try: + t = self.get_table(table_name, db) + return True + except Exception as e: + return False + + +class HiveServer2Hook(BaseHook): + """ + Wrapper around the impyla library + + Note that the default authMechanism is PLAIN, to override it you + can specify it in the ``extra`` of your connection in the UI as in + """ + def __init__(self, hiveserver2_conn_id='hiveserver2_default'): + self.hiveserver2_conn_id = hiveserver2_conn_id + + def get_conn(self, schema=None): + db = self.get_connection(self.hiveserver2_conn_id) + auth_mechanism = db.extra_dejson.get('authMechanism', 'PLAIN') + kerberos_service_name = None + if configuration.get('core', 'security') == 'kerberos': + auth_mechanism = db.extra_dejson.get('authMechanism', 'GSSAPI') + kerberos_service_name = db.extra_dejson.get('kerberos_service_name', 'hive') + + # impyla uses GSSAPI instead of KERBEROS as a auth_mechanism identifier + if auth_mechanism == 'KERBEROS': + self.log.warning( + "Detected deprecated 'KERBEROS' for authMechanism for %s. Please use 'GSSAPI' instead", + self.hiveserver2_conn_id + ) + auth_mechanism = 'GSSAPI' + + from impala.dbapi import connect + return connect( + host=db.host, + port=db.port, + auth_mechanism=auth_mechanism, + kerberos_service_name=kerberos_service_name, + user=db.login, + database=schema or db.schema or 'default') + + def get_results(self, hql, schema='default', arraysize=1000): + from impala.error import ProgrammingError + with self.get_conn(schema) as conn: + if isinstance(hql, basestring): + hql = [hql] + results = { + 'data': [], + 'header': [], + } + cur = conn.cursor() + for statement in hql: + cur.execute(statement) + records = [] + try: + # impala Lib raises when no results are returned + # we're silencing here as some statements in the list + # may be `SET` or DDL + records = cur.fetchall() + except ProgrammingError: + self.log.debug("get_results returned no records") + if records: + results = { + 'data': records, + 'header': cur.description, + } + return results + + def to_csv( + self, + hql, + csv_filepath, + schema='default', + delimiter=',', + lineterminator='\r\n', + output_header=True, + fetch_size=1000): + schema = schema or 'default' + with self.get_conn(schema) as conn: + with conn.cursor() as cur: + self.log.info("Running query: %s", hql) + cur.execute(hql) + schema = cur.description + with open(csv_filepath, 'wb') as f: + writer = csv.writer(f, + delimiter=delimiter, + lineterminator=lineterminator, + encoding='utf-8') + if output_header: + writer.writerow([c[0] for c in cur.description]) + i = 0 + while True: + rows = [row for row in cur.fetchmany(fetch_size) if row] + if not rows: + break + + writer.writerows(rows) + i += len(rows) + self.log.info("Written %s rows so far.", i) + self.log.info("Done. Loaded a total of %s rows.", i) + + def get_records(self, hql, schema='default'): + """ + Get a set of records from a Hive query. + + >>> hh = HiveServer2Hook() + >>> sql = "SELECT * FROM airflow.static_babynames LIMIT 100" + >>> len(hh.get_records(sql)) + 100 + """ + return self.get_results(hql, schema=schema)['data'] + + def get_pandas_df(self, hql, schema='default'): + """ + Get a pandas dataframe from a Hive query + + >>> hh = HiveServer2Hook() + >>> sql = "SELECT * FROM airflow.static_babynames LIMIT 100" + >>> df = hh.get_pandas_df(sql) + >>> len(df.index) + 100 + """ + import pandas as pd + res = self.get_results(hql, schema=schema) + df = pd.DataFrame(res['data']) + df.columns = [c[0] for c in res['header']] + return df diff --git a/airflow/hooks/http_hook.py b/airflow/hooks/http_hook.py new file mode 100644 index 0000000000000..b8075a04a9e2b --- /dev/null +++ b/airflow/hooks/http_hook.py @@ -0,0 +1,118 @@ +# -*- coding: utf-8 -*- +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from builtins import str + +import requests + +from airflow.hooks.base_hook import BaseHook +from airflow.exceptions import AirflowException + + +class HttpHook(BaseHook): + """ + Interact with HTTP servers. + """ + + def __init__(self, method='POST', http_conn_id='http_default'): + self.http_conn_id = http_conn_id + self.method = method + + # headers is required to make it required + def get_conn(self, headers): + """ + Returns http session for use with requests + """ + conn = self.get_connection(self.http_conn_id) + session = requests.Session() + + if "://" in conn.host: + self.base_url = conn.host + else: + # schema defaults to HTTP + schema = conn.schema if conn.schema else "http" + self.base_url = schema + "://" + conn.host + + if conn.port: + self.base_url = self.base_url + ":" + str(conn.port) + "/" + if conn.login: + session.auth = (conn.login, conn.password) + if headers: + session.headers.update(headers) + + return session + + def run(self, endpoint, data=None, headers=None, extra_options=None): + """ + Performs the request + """ + extra_options = extra_options or {} + + session = self.get_conn(headers) + + url = self.base_url + endpoint + req = None + if self.method == 'GET': + # GET uses params + req = requests.Request(self.method, + url, + params=data, + headers=headers) + elif self.method == 'HEAD': + # HEAD doesn't use params + req = requests.Request(self.method, + url, + headers=headers) + else: + # Others use data + req = requests.Request(self.method, + url, + data=data, + headers=headers) + + prepped_request = session.prepare_request(req) + self.log.info("Sending '%s' to url: %s", self.method, url) + return self.run_and_check(session, prepped_request, extra_options) + + def run_and_check(self, session, prepped_request, extra_options): + """ + Grabs extra options like timeout and actually runs the request, + checking for the result + """ + extra_options = extra_options or {} + + response = session.send( + prepped_request, + stream=extra_options.get("stream", False), + verify=extra_options.get("verify", False), + proxies=extra_options.get("proxies", {}), + cert=extra_options.get("cert"), + timeout=extra_options.get("timeout"), + allow_redirects=extra_options.get("allow_redirects", True)) + + try: + response.raise_for_status() + except requests.exceptions.HTTPError: + # Tried rewrapping, but not supported. This way, it's possible + # to get reason and code for failure by checking first 3 chars + # for the code, or do a split on ':' + self.log.error("HTTP error: %s", response.reason) + if self.method not in ('GET', 'HEAD'): + # The sensor uses GET, so this prevents filling up the log + # with the body every time the GET 'misses'. + # That's ok to do, because GETs should be repeatable and + # all data should be visible in the log (no post data) + self.log.error(response.text) + raise AirflowException(str(response.status_code)+":"+response.reason) + return response diff --git a/airflow/hooks/jdbc_hook.py b/airflow/hooks/jdbc_hook.py new file mode 100644 index 0000000000000..dcc6cd2332820 --- /dev/null +++ b/airflow/hooks/jdbc_hook.py @@ -0,0 +1,68 @@ +# -*- coding: utf-8 -*- +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from builtins import str + +import jaydebeapi + +from airflow.hooks.dbapi_hook import DbApiHook + + +class JdbcHook(DbApiHook): + """ + General hook for jdbc db access. + + If a connection id is specified, host, port, schema, username and password will be taken from the predefined connection. + Raises an airflow error if the given connection id doesn't exist. + Otherwise host, port, schema, username and password can be specified on the fly. + + :param jdbc_url: jdbc connection url + :type jdbc_url: string + :param jdbc_driver_name: jdbc driver name + :type jdbc_driver_name: string + :param jdbc_driver_loc: path to jdbc driver + :type jdbc_driver_loc: string + :param conn_id: reference to a predefined database + :type conn_id: string + :param sql: the sql code to be executed + :type sql: string or string pointing to a template file. File must have + a '.sql' extensions. + """ + + conn_name_attr = 'jdbc_conn_id' + default_conn_name = 'jdbc_default' + supports_autocommit = True + + def get_conn(self): + conn = self.get_connection(getattr(self, self.conn_name_attr)) + host = conn.host + login = conn.login + psw = conn.password + jdbc_driver_loc = conn.extra_dejson.get('extra__jdbc__drv_path') + jdbc_driver_name = conn.extra_dejson.get('extra__jdbc__drv_clsname') + + conn = jaydebeapi.connect(jclassname=jdbc_driver_name, + url=str(host), + driver_args=[str(login), str(psw)], + jars=jdbc_driver_loc.split(",")) + return conn + + def set_autocommit(self, conn, autocommit): + """ + Enable or disable autocommit for the given connection. + + :param conn: The connection + :return: + """ + conn.jconn.autocommit = autocommit diff --git a/airflow/hooks/mssql_hook.py b/airflow/hooks/mssql_hook.py new file mode 100644 index 0000000000000..99a4c82f7730d --- /dev/null +++ b/airflow/hooks/mssql_hook.py @@ -0,0 +1,47 @@ +# -*- coding: utf-8 -*- +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import pymssql + +from airflow.hooks.dbapi_hook import DbApiHook + + +class MsSqlHook(DbApiHook): + """ + Interact with Microsoft SQL Server. + """ + + conn_name_attr = 'mssql_conn_id' + default_conn_name = 'mssql_default' + supports_autocommit = True + + def __init__(self, *args, **kwargs): + super(MsSqlHook, self).__init__(*args, **kwargs) + self.schema = kwargs.pop("schema", None) + + def get_conn(self): + """ + Returns a mssql connection object + """ + conn = self.get_connection(self.mssql_conn_id) + conn = pymssql.connect( + server=conn.host, + user=conn.login, + password=conn.password, + database=self.schema or conn.schema, + port=conn.port) + return conn + + def set_autocommit(self, conn, autocommit): + conn.autocommit(autocommit) diff --git a/airflow/hooks/mysql_hook.py b/airflow/hooks/mysql_hook.py new file mode 100644 index 0000000000000..a0f62b5b8dd1c --- /dev/null +++ b/airflow/hooks/mysql_hook.py @@ -0,0 +1,101 @@ +# -*- coding: utf-8 -*- +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import MySQLdb +import MySQLdb.cursors + +from airflow.hooks.dbapi_hook import DbApiHook + + +class MySqlHook(DbApiHook): + """ + Interact with MySQL. + + You can specify charset in the extra field of your connection + as ``{"charset": "utf8"}``. Also you can choose cursor as + ``{"cursor": "SSCursor"}``. Refer to the MySQLdb.cursors for more details. + """ + + conn_name_attr = 'mysql_conn_id' + default_conn_name = 'mysql_default' + supports_autocommit = True + + def __init__(self, *args, **kwargs): + super(MySqlHook, self).__init__(*args, **kwargs) + self.schema = kwargs.pop("schema", None) + + def get_conn(self): + """ + Returns a mysql connection object + """ + conn = self.get_connection(self.mysql_conn_id) + conn_config = { + "user": conn.login, + "passwd": conn.password or '', + "host": conn.host or 'localhost', + "db": self.schema or conn.schema or '' + } + + if not conn.port: + conn_config["port"] = 3306 + else: + conn_config["port"] = int(conn.port) + + if conn.extra_dejson.get('charset', False): + conn_config["charset"] = conn.extra_dejson["charset"] + if (conn_config["charset"]).lower() == 'utf8' or\ + (conn_config["charset"]).lower() == 'utf-8': + conn_config["use_unicode"] = True + if conn.extra_dejson.get('cursor', False): + if (conn.extra_dejson["cursor"]).lower() == 'sscursor': + conn_config["cursorclass"] = MySQLdb.cursors.SSCursor + elif (conn.extra_dejson["cursor"]).lower() == 'dictcursor': + conn_config["cursorclass"] = MySQLdb.cursors.DictCursor + elif (conn.extra_dejson["cursor"]).lower() == 'ssdictcursor': + conn_config["cursorclass"] = MySQLdb.cursors.SSDictCursor + local_infile = conn.extra_dejson.get('local_infile',False) + if conn.extra_dejson.get('ssl', False): + conn_config['ssl'] = conn.extra_dejson['ssl'] + if local_infile: + conn_config["local_infile"] = 1 + conn = MySQLdb.connect(**conn_config) + return conn + + def bulk_load(self, table, tmp_file): + """ + Loads a tab-delimited file into a database table + """ + conn = self.get_conn() + cur = conn.cursor() + cur.execute(""" + LOAD DATA LOCAL INFILE '{tmp_file}' + INTO TABLE {table} + """.format(**locals())) + conn.commit() + + @staticmethod + def _serialize_cell(cell, conn): + """ + MySQLdb converts an argument to a literal when passing those seperately to execute. + Hence, this method does nothing. + + :param cell: The cell to insert into the table + :type cell: object + :param conn: The database connection + :type conn: connection object + :return: The same cell + :rtype: object + """ + + return cell diff --git a/airflow/hooks/oracle_hook.py b/airflow/hooks/oracle_hook.py new file mode 100644 index 0000000000000..71c67e0f59fbd --- /dev/null +++ b/airflow/hooks/oracle_hook.py @@ -0,0 +1,141 @@ +# -*- coding: utf-8 -*- +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import cx_Oracle + +from airflow.hooks.dbapi_hook import DbApiHook +from builtins import str +from past.builtins import basestring +from datetime import datetime +import numpy + + +class OracleHook(DbApiHook): + """ + Interact with Oracle SQL. + """ + conn_name_attr = 'oracle_conn_id' + default_conn_name = 'oracle_default' + supports_autocommit = False + + def get_conn(self): + """ + Returns a oracle connection object + Optional parameters for using a custom DSN connection (instead of using a server alias from tnsnames.ora) + The dsn (data source name) is the TNS entry (from the Oracle names server or tnsnames.ora file) + or is a string like the one returned from makedsn(). + :param dsn: the host address for the Oracle server + :param service_name: the db_unique_name of the database that you are connecting to (CONNECT_DATA part of TNS) + You can set these parameters in the extra fields of your connection + as in ``{ "dsn":"some.host.address" , "service_name":"some.service.name" }`` + """ + conn = self.get_connection(self.oracle_conn_id) + dsn = conn.extra_dejson.get('dsn', None) + sid = conn.extra_dejson.get('sid', None) + mod = conn.extra_dejson.get('module', None) + + service_name = conn.extra_dejson.get('service_name', None) + if dsn and sid and not service_name: + dsn = cx_Oracle.makedsn(dsn, conn.port, sid) + conn = cx_Oracle.connect(conn.login, conn.password, dsn=dsn) + elif dsn and service_name and not sid: + dsn = cx_Oracle.makedsn(dsn, conn.port, service_name=service_name) + conn = cx_Oracle.connect(conn.login, conn.password, dsn=dsn) + else: + conn = cx_Oracle.connect(conn.login, conn.password, conn.host) + + if mod is not None: + conn.module = mod + + return conn + + def insert_rows(self, table, rows, target_fields=None, commit_every=1000): + """ + A generic way to insert a set of tuples into a table, + the whole set of inserts is treated as one transaction + Changes from standard DbApiHook implementation: + - Oracle SQL queries in cx_Oracle can not be terminated with a semicolon (';') + - Replace NaN values with NULL using numpy.nan_to_num (not using is_nan() because of input types error for strings) + - Coerce datetime cells to Oracle DATETIME format during insert + """ + if target_fields: + target_fields = ', '.join(target_fields) + target_fields = '({})'.format(target_fields) + else: + target_fields = '' + conn = self.get_conn() + cur = conn.cursor() + if self.supports_autocommit: + cur.execute('SET autocommit = 0') + conn.commit() + i = 0 + for row in rows: + i += 1 + l = [] + for cell in row: + if isinstance(cell, basestring): + l.append("'" + str(cell).replace("'", "''") + "'") + elif cell is None: + l.append('NULL') + elif type(cell) == float and numpy.isnan(cell): # coerce numpy NaN to NULL + l.append('NULL') + elif isinstance(cell, numpy.datetime64): + l.append("'" + str(cell) + "'") + elif isinstance(cell, datetime): + l.append("to_date('" + cell.strftime('%Y-%m-%d %H:%M:%S') + "','YYYY-MM-DD HH24:MI:SS')") + else: + l.append(str(cell)) + values = tuple(l) + sql = 'INSERT /*+ APPEND */ INTO {0} {1} VALUES ({2})'.format(table, target_fields, ','.join(values)) + cur.execute(sql) + if i % commit_every == 0: + conn.commit() + self.log.info('Loaded {i} into {table} rows so far'.format(**locals())) + conn.commit() + cur.close() + conn.close() + self.log.info('Done loading. Loaded a total of {i} rows'.format(**locals())) + + def bulk_insert_rows(self, table, rows, target_fields=None, commit_every=5000): + """A performant bulk insert for cx_Oracle that uses prepared statements via `executemany()`. + For best performance, pass in `rows` as an iterator. + """ + conn = self.get_conn() + cursor = conn.cursor() + values = ', '.join(':%s' % i for i in range(1, len(target_fields) + 1)) + prepared_stm = 'insert into {tablename} ({columns}) values ({values})'.format( + tablename=table, + columns=', '.join(target_fields), + values=values, + ) + row_count = 0 + # Chunk the rows + row_chunk = [] + for row in rows: + row_chunk.append(row) + row_count += 1 + if row_count % commit_every == 0: + cursor.prepare(prepared_stm) + cursor.executemany(None, row_chunk) + conn.commit() + self.log.info('[%s] inserted %s rows', table, row_count) + # Empty chunk + row_chunk = [] + # Commit the leftover chunk + cursor.prepare(prepared_stm) + cursor.executemany(None, row_chunk) + conn.commit() + self.log.info('[%s] inserted %s rows', table, row_count) + cursor.close() + conn.close() diff --git a/airflow/hooks/pig_hook.py b/airflow/hooks/pig_hook.py new file mode 100644 index 0000000000000..276b37accf6ff --- /dev/null +++ b/airflow/hooks/pig_hook.py @@ -0,0 +1,88 @@ +# -*- coding: utf-8 -*- +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from __future__ import print_function +import subprocess +from tempfile import NamedTemporaryFile + +from airflow.exceptions import AirflowException +from airflow.hooks.base_hook import BaseHook +from airflow.utils.file import TemporaryDirectory + + +class PigCliHook(BaseHook): + """ + Simple wrapper around the pig CLI. + + Note that you can also set default pig CLI properties using the + ``pig_properties`` to be used in your connection as in + ``{"pig_properties": "-Dpig.tmpfilecompression=true"}`` + + """ + + def __init__( + self, + pig_cli_conn_id="pig_cli_default"): + conn = self.get_connection(pig_cli_conn_id) + self.pig_properties = conn.extra_dejson.get('pig_properties', '') + self.conn = conn + + def run_cli(self, pig, verbose=True): + """ + Run an pig script using the pig cli + + >>> ph = PigCliHook() + >>> result = ph.run_cli("ls /;") + >>> ("hdfs://" in result) + True + """ + + with TemporaryDirectory(prefix='airflow_pigop_') as tmp_dir: + with NamedTemporaryFile(dir=tmp_dir) as f: + f.write(pig) + f.flush() + fname = f.name + pig_bin = 'pig' + cmd_extra = [] + + pig_cmd = [pig_bin, '-f', fname] + cmd_extra + + if self.pig_properties: + pig_properties_list = self.pig_properties.split() + pig_cmd.extend(pig_properties_list) + if verbose: + self.log.info(" ".join(pig_cmd)) + sp = subprocess.Popen( + pig_cmd, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + cwd=tmp_dir) + self.sp = sp + stdout = '' + for line in iter(sp.stdout.readline, ''): + stdout += line + if verbose: + self.log.info(line.strip()) + sp.wait() + + if sp.returncode: + raise AirflowException(stdout) + + return stdout + + def kill(self): + if hasattr(self, 'sp'): + if self.sp.poll() is None: + print("Killing the Pig job") + self.sp.kill() diff --git a/airflow/hooks/postgres_hook.py b/airflow/hooks/postgres_hook.py new file mode 100644 index 0000000000000..81e10d7aa43a6 --- /dev/null +++ b/airflow/hooks/postgres_hook.py @@ -0,0 +1,83 @@ +# -*- coding: utf-8 -*- +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import psycopg2 +import psycopg2.extensions +from contextlib import closing + +from airflow.hooks.dbapi_hook import DbApiHook + + +class PostgresHook(DbApiHook): + """ + Interact with Postgres. + You can specify ssl parameters in the extra field of your connection + as ``{"sslmode": "require", "sslcert": "/path/to/cert.pem", etc}``. + + Note: For Redshift, use keepalives_idle in the extra connection parameters + and set it to less than 300 seconds. + """ + conn_name_attr = 'postgres_conn_id' + default_conn_name = 'postgres_default' + supports_autocommit = True + + def __init__(self, *args, **kwargs): + super(PostgresHook, self).__init__(*args, **kwargs) + self.schema = kwargs.pop("schema", None) + + def get_conn(self): + conn = self.get_connection(self.postgres_conn_id) + conn_args = dict( + host=conn.host, + user=conn.login, + password=conn.password, + dbname=self.schema or conn.schema, + port=conn.port) + # check for ssl parameters in conn.extra + for arg_name, arg_val in conn.extra_dejson.items(): + if arg_name in ['sslmode', 'sslcert', 'sslkey', + 'sslrootcert', 'sslcrl', 'application_name', + 'keepalives_idle']: + conn_args[arg_name] = arg_val + + psycopg2_conn = psycopg2.connect(**conn_args) + return psycopg2_conn + + def copy_expert(self, sql, filename, open=open): + ''' + Executes SQL using psycopg2 copy_expert method + Necessary to execute COPY command without access to a superuser + ''' + f = open(filename, 'w') + with closing(self.get_conn()) as conn: + with closing(conn.cursor()) as cur: + cur.copy_expert(sql, f) + + @staticmethod + def _serialize_cell(cell, conn): + """ + Postgresql will adapt all arguments to the execute() method internally, + hence we return cell without any conversion. + + See http://initd.org/psycopg/docs/advanced.html#adapting-new-types for + more information. + + :param cell: The cell to insert into the table + :type cell: object + :param conn: The database connection + :type conn: connection object + :return: The cell + :rtype: object + """ + return cell diff --git a/airflow/hooks/presto_hook.py b/airflow/hooks/presto_hook.py new file mode 100644 index 0000000000000..b9f30a6737ef1 --- /dev/null +++ b/airflow/hooks/presto_hook.py @@ -0,0 +1,113 @@ +# -*- coding: utf-8 -*- +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from builtins import str + +from pyhive import presto +from pyhive.exc import DatabaseError + +from airflow.hooks.dbapi_hook import DbApiHook + +class PrestoException(Exception): + pass + + +class PrestoHook(DbApiHook): + """ + Interact with Presto through PyHive! + + >>> ph = PrestoHook() + >>> sql = "SELECT count(1) AS num FROM airflow.static_babynames" + >>> ph.get_records(sql) + [[340698]] + """ + + conn_name_attr = 'presto_conn_id' + default_conn_name = 'presto_default' + + def get_conn(self): + """Returns a connection object""" + db = self.get_connection(self.presto_conn_id) + return presto.connect( + host=db.host, + port=db.port, + username=db.login, + catalog=db.extra_dejson.get('catalog', 'hive'), + schema=db.schema) + + @staticmethod + def _strip_sql(sql): + return sql.strip().rstrip(';') + + def _get_pretty_exception_message(self, e): + """ + Parses some DatabaseError to provide a better error message + """ + if (hasattr(e, 'message') + and 'errorName' in e.message + and 'message' in e.message): + return ('{name}: {message}'.format( + name=e.message['errorName'], + message=e.message['message'])) + else: + return str(e) + + def get_records(self, hql, parameters=None): + """ + Get a set of records from Presto + """ + try: + return super(PrestoHook, self).get_records( + self._strip_sql(hql), parameters) + except DatabaseError as e: + raise PrestoException(self._parse_exception_message(e)) + + def get_first(self, hql, parameters=None): + """ + Returns only the first row, regardless of how many rows the query + returns. + """ + try: + return super(PrestoHook, self).get_first( + self._strip_sql(hql), parameters) + except DatabaseError as e: + raise PrestoException(self._parse_exception_message(e)) + + def get_pandas_df(self, hql, parameters=None): + """ + Get a pandas dataframe from a sql query. + """ + import pandas + cursor = self.get_cursor() + try: + cursor.execute(self._strip_sql(hql), parameters) + data = cursor.fetchall() + except DatabaseError as e: + raise PrestoException(self._parse_exception_message(e)) + column_descriptions = cursor.description + if data: + df = pandas.DataFrame(data) + df.columns = [c[0] for c in column_descriptions] + else: + df = pandas.DataFrame() + return df + + def run(self, hql, parameters=None): + """ + Execute the statement against Presto. Can be used to create views. + """ + return super(PrestoHook, self).run(self._strip_sql(hql), parameters) + + def insert_rows(self): + raise NotImplementedError() diff --git a/airflow/hooks/samba_hook.py b/airflow/hooks/samba_hook.py new file mode 100644 index 0000000000000..6a29982ceee47 --- /dev/null +++ b/airflow/hooks/samba_hook.py @@ -0,0 +1,47 @@ +# -*- coding: utf-8 -*- +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from smbclient import SambaClient +import os + +from airflow.hooks.base_hook import BaseHook + + +class SambaHook(BaseHook): + ''' + Allows for interaction with an samba server. + ''' + + def __init__(self, samba_conn_id): + self.conn = self.get_connection(samba_conn_id) + + def get_conn(self): + samba = SambaClient( + server=self.conn.host, + share=self.conn.schema, + username=self.conn.login, + ip=self.conn.host, + password=self.conn.password) + return samba + + def push_from_local(self, destination_filepath, local_filepath): + samba = self.get_conn() + if samba.exists(destination_filepath): + if samba.isfile(destination_filepath): + samba.remove(destination_filepath) + else: + folder = os.path.dirname(destination_filepath) + if not samba.exists(folder): + samba.mkdir(folder) + samba.upload(local_filepath, destination_filepath) diff --git a/airflow/hooks/sqlite_hook.py b/airflow/hooks/sqlite_hook.py new file mode 100644 index 0000000000000..c241c2ddef03c --- /dev/null +++ b/airflow/hooks/sqlite_hook.py @@ -0,0 +1,36 @@ +# -*- coding: utf-8 -*- +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import sqlite3 + +from airflow.hooks.dbapi_hook import DbApiHook + + +class SqliteHook(DbApiHook): + + """ + Interact with SQLite. + """ + + conn_name_attr = 'sqlite_conn_id' + default_conn_name = 'sqlite_default' + supports_autocommit = False + + def get_conn(self): + """ + Returns a sqlite connection object + """ + conn = self.get_connection(self.sqlite_conn_id) + conn = sqlite3.connect(conn.host) + return conn diff --git a/airflow/hooks/webhdfs_hook.py b/airflow/hooks/webhdfs_hook.py new file mode 100644 index 0000000000000..4510d29bcddd0 --- /dev/null +++ b/airflow/hooks/webhdfs_hook.py @@ -0,0 +1,104 @@ +# -*- coding: utf-8 -*- +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from airflow.hooks.base_hook import BaseHook +from airflow import configuration + +from hdfs import InsecureClient, HdfsError + +from airflow.utils.log.logging_mixin import LoggingMixin + +_kerberos_security_mode = configuration.get("core", "security") == "kerberos" +if _kerberos_security_mode: + try: + from hdfs.ext.kerberos import KerberosClient + except ImportError: + log = LoggingMixin().log + log.error("Could not load the Kerberos extension for the WebHDFSHook.") + raise +from airflow.exceptions import AirflowException + + +class AirflowWebHDFSHookException(AirflowException): + pass + + +class WebHDFSHook(BaseHook): + """ + Interact with HDFS. This class is a wrapper around the hdfscli library. + """ + def __init__(self, webhdfs_conn_id='webhdfs_default', proxy_user=None): + self.webhdfs_conn_id = webhdfs_conn_id + self.proxy_user = proxy_user + + def get_conn(self): + """ + Returns a hdfscli InsecureClient object. + """ + nn_connections = self.get_connections(self.webhdfs_conn_id) + for nn in nn_connections: + try: + self.log.debug('Trying namenode %s', nn.host) + connection_str = 'http://{nn.host}:{nn.port}'.format(nn=nn) + if _kerberos_security_mode: + client = KerberosClient(connection_str) + else: + proxy_user = self.proxy_user or nn.login + client = InsecureClient(connection_str, user=proxy_user) + client.status('/') + self.log.debug('Using namenode %s for hook', nn.host) + return client + except HdfsError as e: + self.log.debug( + "Read operation on namenode {nn.host} failed witg error: {e.message}".format(**locals()) + ) + nn_hosts = [c.host for c in nn_connections] + no_nn_error = "Read operations failed on the namenodes below:\n{}".format("\n".join(nn_hosts)) + raise AirflowWebHDFSHookException(no_nn_error) + + def check_for_path(self, hdfs_path): + """ + Check for the existence of a path in HDFS by querying FileStatus. + """ + c = self.get_conn() + return bool(c.status(hdfs_path, strict=False)) + + def load_file(self, source, destination, overwrite=True, parallelism=1, + **kwargs): + """ + Uploads a file to HDFS + + :param source: Local path to file or folder. If a folder, all the files + inside of it will be uploaded (note that this implies that folders empty + of files will not be created remotely). + :type source: str + :param destination: PTarget HDFS path. If it already exists and is a + directory, files will be uploaded inside. + :type destination: str + :param overwrite: Overwrite any existing file or directory. + :type overwrite: bool + :param parallelism: Number of threads to use for parallelization. A value of + `0` (or negative) uses as many threads as there are files. + :type parallelism: int + :param \*\*kwargs: Keyword arguments forwarded to :meth:`upload`. + + + """ + c = self.get_conn() + c.upload(hdfs_path=destination, + local_path=source, + overwrite=overwrite, + n_threads=parallelism, + **kwargs) + self.log.debug("Uploaded file %s to %s", source, destination) diff --git a/airflow/hooks/zendesk_hook.py b/airflow/hooks/zendesk_hook.py new file mode 100644 index 0000000000000..533e9d0f49a2d --- /dev/null +++ b/airflow/hooks/zendesk_hook.py @@ -0,0 +1,98 @@ +# -*- coding: utf-8 -*- +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +import time +from zdesk import Zendesk, RateLimitError, ZendeskError +from airflow.hooks.base_hook import BaseHook + + +class ZendeskHook(BaseHook): + """ + A hook to talk to Zendesk + """ + def __init__(self, zendesk_conn_id): + self.__zendesk_conn_id = zendesk_conn_id + self.__url = None + + def get_conn(self): + conn = self.get_connection(self.__zendesk_conn_id) + self.__url = "https://" + conn.host + return Zendesk(self.__url, conn.login, conn.password, True) + + def __handle_rate_limit_exception(self, rate_limit_exception): + """ + Sleep for the time specified in the exception. If not specified, wait + for 60 seconds. + """ + retry_after = int( + rate_limit_exception.response.headers.get('Retry-After', 60)) + self.log.info( + "Hit Zendesk API rate limit. Pausing for %s seconds", + retry_after + ) + time.sleep(retry_after) + + def call(self, path, query=None, get_all_pages=True): + """ + Call Zendesk API and return results + + :param path: The Zendesk API to call + :param query: Query parameters + :param get_all_pages: Accumulate results over all pages before + returning. Due to strict rate limiting, this can often timeout. + Waits for recommended period between tries after a timeout. + """ + zendesk = self.get_conn() + first_request_successful = False + + while not first_request_successful: + try: + results = zendesk.call(path, query) + first_request_successful = True + except RateLimitError as rle: + self.__handle_rate_limit_exception(rle) + + # Find the key with the results + key = path.split("/")[-1].split(".json")[0] + next_page = results['next_page'] + results = results[key] + + if get_all_pages: + while next_page is not None: + try: + # Need to split because the next page URL has + # `github.zendesk...` + # in it, but the call function needs it removed. + next_url = next_page.split(self.__url)[1] + self.log.info("Calling %s", next_url) + more_res = zendesk.call(next_url) + results.extend(more_res[key]) + if next_page == more_res['next_page']: + # Unfortunately zdesk doesn't always throw ZendeskError + # when we are done getting all the data. Sometimes the + # next just refers to the current set of results. Hence, + # need to deal with this special case + break + else: + next_page = more_res['next_page'] + except RateLimitError as rle: + self.__handle_rate_limit_exception(rle) + except ZendeskError as ze: + if b"Use a start_time older than 5 minutes" in ze.msg: + # We have pretty up to date data + break + else: + raise ze + + return results diff --git a/airflow/jobs.py b/airflow/jobs.py new file mode 100644 index 0000000000000..2675bd3167d9b --- /dev/null +++ b/airflow/jobs.py @@ -0,0 +1,2580 @@ +# -*- coding: utf-8 -*- +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +from __future__ import absolute_import +from __future__ import division +from __future__ import print_function +from __future__ import unicode_literals + +import getpass +import multiprocessing +import os +import psutil +import signal +import six +import socket +import sys +import threading +import time +from collections import defaultdict +from datetime import datetime +from past.builtins import basestring +from sqlalchemy import ( + Column, Integer, String, DateTime, func, Index, or_, and_, not_) +from sqlalchemy.exc import OperationalError +from sqlalchemy.orm.session import make_transient +from tabulate import tabulate +from time import sleep + +from airflow import configuration as conf +from airflow import executors, models, settings +from airflow.exceptions import AirflowException +from airflow.logging_config import configure_logging +from airflow.models import DAG, DagRun +from airflow.settings import Stats +from airflow.task_runner import get_task_runner +from airflow.ti_deps.dep_context import DepContext, QUEUE_DEPS, RUN_DEPS +from airflow.utils import asciiart +from airflow.utils.dag_processing import (AbstractDagFileProcessor, + DagFileProcessorManager, + SimpleDag, + SimpleDagBag, + list_py_file_paths) +from airflow.utils.db import provide_session, pessimistic_connection_handling +from airflow.utils.email import send_email +from airflow.utils.log.logging_mixin import LoggingMixin +from airflow.utils.state import State + +Base = models.Base +ID_LEN = models.ID_LEN + + +class BaseJob(Base, LoggingMixin): + """ + Abstract class to be derived for jobs. Jobs are processing items with state + and duration that aren't task instances. For instance a BackfillJob is + a collection of task instance runs, but should have it's own state, start + and end time. + """ + + __tablename__ = "job" + + id = Column(Integer, primary_key=True) + dag_id = Column(String(ID_LEN),) + state = Column(String(20)) + job_type = Column(String(30)) + start_date = Column(DateTime()) + end_date = Column(DateTime()) + latest_heartbeat = Column(DateTime()) + executor_class = Column(String(500)) + hostname = Column(String(500)) + unixname = Column(String(1000)) + + __mapper_args__ = { + 'polymorphic_on': job_type, + 'polymorphic_identity': 'BaseJob' + } + + __table_args__ = ( + Index('job_type_heart', job_type, latest_heartbeat), + ) + + def __init__( + self, + executor=executors.GetDefaultExecutor(), + heartrate=conf.getfloat('scheduler', 'JOB_HEARTBEAT_SEC'), + *args, **kwargs): + self.hostname = socket.getfqdn() + self.executor = executor + self.executor_class = executor.__class__.__name__ + self.start_date = datetime.utcnow() + self.latest_heartbeat = datetime.utcnow() + self.heartrate = heartrate + self.unixname = getpass.getuser() + super(BaseJob, self).__init__(*args, **kwargs) + + def is_alive(self): + return ( + (datetime.utcnow() - self.latest_heartbeat).seconds < + (conf.getint('scheduler', 'JOB_HEARTBEAT_SEC') * 2.1) + ) + + def kill(self): + session = settings.Session() + job = session.query(BaseJob).filter(BaseJob.id == self.id).first() + job.end_date = datetime.utcnow() + try: + self.on_kill() + except: + self.log.error('on_kill() method failed') + session.merge(job) + session.commit() + session.close() + raise AirflowException("Job shut down externally.") + + def on_kill(self): + ''' + Will be called when an external kill command is received + ''' + pass + + def heartbeat_callback(self, session=None): + pass + + def heartbeat(self): + ''' + Heartbeats update the job's entry in the database with a timestamp + for the latest_heartbeat and allows for the job to be killed + externally. This allows at the system level to monitor what is + actually active. + + For instance, an old heartbeat for SchedulerJob would mean something + is wrong. + + This also allows for any job to be killed externally, regardless + of who is running it or on which machine it is running. + + Note that if your heartbeat is set to 60 seconds and you call this + method after 10 seconds of processing since the last heartbeat, it + will sleep 50 seconds to complete the 60 seconds and keep a steady + heart rate. If you go over 60 seconds before calling it, it won't + sleep at all. + ''' + session = settings.Session() + job = session.query(BaseJob).filter_by(id=self.id).one() + make_transient(job) + session.commit() + session.close() + + if job.state == State.SHUTDOWN: + self.kill() + + # Figure out how long to sleep for + sleep_for = 0 + if job.latest_heartbeat: + sleep_for = max( + 0, + self.heartrate - (datetime.utcnow() - job.latest_heartbeat).total_seconds()) + + # Don't keep session open while sleeping as it leaves a connection open + session.close() + sleep(sleep_for) + + # Update last heartbeat time + session = settings.Session() + job = session.query(BaseJob).filter(BaseJob.id == self.id).first() + job.latest_heartbeat = datetime.utcnow() + session.merge(job) + session.commit() + + self.heartbeat_callback(session=session) + session.close() + self.log.debug('[heart] Boom.') + + def run(self): + Stats.incr(self.__class__.__name__.lower() + '_start', 1, 1) + # Adding an entry in the DB + session = settings.Session() + self.state = State.RUNNING + session.add(self) + session.commit() + id_ = self.id + make_transient(self) + self.id = id_ + + # Run + self._execute() + + # Marking the success in the DB + self.end_date = datetime.utcnow() + self.state = State.SUCCESS + session.merge(self) + session.commit() + session.close() + + Stats.incr(self.__class__.__name__.lower() + '_end', 1, 1) + + def _execute(self): + raise NotImplementedError("This method needs to be overridden") + + @provide_session + def reset_state_for_orphaned_tasks(self, filter_by_dag_run=None, session=None): + """ + This function checks if there are any tasks in the dagrun (or all) + that have a scheduled state but are not known by the + executor. If it finds those it will reset the state to None + so they will get picked up again. + The batch option is for performance reasons as the queries are made in + sequence. + + :param filter_by_dag_run: the dag_run we want to process, None if all + :type filter_by_dag_run: models.DagRun + :return: the TIs reset (in expired SQLAlchemy state) + :rtype: List(TaskInstance) + """ + queued_tis = self.executor.queued_tasks + # also consider running as the state might not have changed in the db yet + running_tis = self.executor.running + + resettable_states = [State.SCHEDULED, State.QUEUED] + TI = models.TaskInstance + DR = models.DagRun + if filter_by_dag_run is None: + resettable_tis = ( + session + .query(TI) + .join( + DR, + and_( + TI.dag_id == DR.dag_id, + TI.execution_date == DR.execution_date)) + .filter( + DR.state == State.RUNNING, + DR.external_trigger.is_(False), + DR.run_id.notlike(BackfillJob.ID_PREFIX + '%'), + TI.state.in_(resettable_states))).all() + else: + resettable_tis = filter_by_dag_run.get_task_instances(state=resettable_states, + session=session) + tis_to_reset = [] + # Can't use an update here since it doesn't support joins + for ti in resettable_tis: + if ti.key not in queued_tis and ti.key not in running_tis: + tis_to_reset.append(ti) + + filter_for_tis = ([and_(TI.dag_id == ti.dag_id, + TI.task_id == ti.task_id, + TI.execution_date == ti.execution_date) + for ti in tis_to_reset]) + if len(tis_to_reset) == 0: + return [] + reset_tis = ( + session + .query(TI) + .filter(or_(*filter_for_tis), TI.state.in_(resettable_states)) + .with_for_update() + .all()) + for ti in reset_tis: + ti.state = State.NONE + session.merge(ti) + task_instance_str = '\n\t'.join( + ["{}".format(x) for x in reset_tis]) + session.commit() + + self.log.info( + "Reset the following %s TaskInstances:\n\t%s", + len(reset_tis), task_instance_str + ) + return reset_tis + + +class DagFileProcessor(AbstractDagFileProcessor, LoggingMixin): + """Helps call SchedulerJob.process_file() in a separate process.""" + + # Counter that increments everytime an instance of this class is created + class_creation_counter = 0 + + def __init__(self, file_path, pickle_dags, dag_id_white_list, log_file): + """ + :param file_path: a Python file containing Airflow DAG definitions + :type file_path: unicode + :param pickle_dags: whether to serialize the DAG objects to the DB + :type pickle_dags: bool + :param dag_id_whitelist: If specified, only look at these DAG ID's + :type dag_id_whitelist: list[unicode] + :param log_file: the path to the file where log lines should be output + :type log_file: unicode + """ + self._file_path = file_path + self._log_file = log_file + # Queue that's used to pass results from the child process. + self._result_queue = multiprocessing.Queue() + # The process that was launched to process the given . + self._process = None + self._dag_id_white_list = dag_id_white_list + self._pickle_dags = pickle_dags + # The result of Scheduler.process_file(file_path). + self._result = None + # Whether the process is done running. + self._done = False + # When the process started. + self._start_time = None + # This ID is use to uniquely name the process / thread that's launched + # by this processor instance + self._instance_id = DagFileProcessor.class_creation_counter + DagFileProcessor.class_creation_counter += 1 + + @property + def file_path(self): + return self._file_path + + @property + def log_file(self): + return self._log_file + + @staticmethod + def _launch_process(result_queue, + file_path, + pickle_dags, + dag_id_white_list, + thread_name, + log_file): + """ + Launch a process to process the given file. + + :param result_queue: the queue to use for passing back the result + :type result_queue: multiprocessing.Queue + :param file_path: the file to process + :type file_path: unicode + :param pickle_dags: whether to pickle the DAGs found in the file and + save them to the DB + :type pickle_dags: bool + :param dag_id_white_list: if specified, only examine DAG ID's that are + in this list + :type dag_id_white_list: list[unicode] + :param thread_name: the name to use for the process that is launched + :type thread_name: unicode + :param log_file: the logging output for the process should be directed + to this file + :type log_file: unicode + :return: the process that was launched + :rtype: multiprocessing.Process + """ + def helper(): + # This helper runs in the newly created process + + # Re-direct stdout and stderr to a separate log file. Otherwise, + # the main log becomes too hard to read. No buffering to enable + # responsive file tailing + parent_dir, _ = os.path.split(log_file) + + _log = LoggingMixin().log + + # Create the parent directory for the log file if necessary. + if not os.path.isdir(parent_dir): + os.makedirs(parent_dir) + + f = open(log_file, "a") + original_stdout = sys.stdout + original_stderr = sys.stderr + + sys.stdout = f + sys.stderr = f + + try: + configure_logging() + # Re-configure the ORM engine as there are issues with multiple processes + settings.configure_orm() + + # Change the thread name to differentiate log lines. This is + # really a separate process, but changing the name of the + # process doesn't work, so changing the thread name instead. + threading.current_thread().name = thread_name + start_time = time.time() + + _log.info("Started process (PID=%s) to work on %s", + os.getpid(), + file_path) + scheduler_job = SchedulerJob(dag_ids=dag_id_white_list) + result = scheduler_job.process_file(file_path, + pickle_dags) + result_queue.put(result) + end_time = time.time() + _log.info( + "Processing %s took %.3f seconds", + file_path, end_time - start_time + ) + except: + # Log exceptions through the logging framework. + _log.exception("Got an exception! Propagating...") + raise + finally: + sys.stdout = original_stdout + sys.stderr = original_stderr + f.close() + + p = multiprocessing.Process(target=helper, + args=(), + name="{}-Process".format(thread_name)) + p.start() + return p + + def start(self): + """ + Launch the process and start processing the DAG. + """ + self._process = DagFileProcessor._launch_process( + self._result_queue, + self.file_path, + self._pickle_dags, + self._dag_id_white_list, + "DagFileProcessor{}".format(self._instance_id), + self.log_file) + self._start_time = datetime.utcnow() + + def terminate(self, sigkill=False): + """ + Terminate (and then kill) the process launched to process the file. + :param sigkill: whether to issue a SIGKILL if SIGTERM doesn't work. + :type sigkill: bool + """ + if self._process is None: + raise AirflowException("Tried to call stop before starting!") + # The queue will likely get corrupted, so remove the reference + self._result_queue = None + self._process.terminate() + # Arbitrarily wait 5s for the process to die + self._process.join(5) + if sigkill and self._process.is_alive(): + self.log.warning("Killing PID %s", self._process.pid) + os.kill(self._process.pid, signal.SIGKILL) + + @property + def pid(self): + """ + :return: the PID of the process launched to process the given file + :rtype: int + """ + if self._process is None: + raise AirflowException("Tried to get PID before starting!") + return self._process.pid + + @property + def exit_code(self): + """ + After the process is finished, this can be called to get the return code + :return: the exit code of the process + :rtype: int + """ + if not self._done: + raise AirflowException("Tried to call retcode before process was finished!") + return self._process.exitcode + + @property + def done(self): + """ + Check if the process launched to process this file is done. + :return: whether the process is finished running + :rtype: bool + """ + if self._process is None: + raise AirflowException("Tried to see if it's done before starting!") + + if self._done: + return True + + if not self._result_queue.empty(): + self._result = self._result_queue.get_nowait() + self._done = True + self.log.debug("Waiting for %s", self._process) + self._process.join() + return True + + # Potential error case when process dies + if not self._process.is_alive(): + self._done = True + # Get the object from the queue or else join() can hang. + if not self._result_queue.empty(): + self._result = self._result_queue.get_nowait() + self.log.debug("Waiting for %s", self._process) + self._process.join() + return True + + return False + + @property + def result(self): + """ + :return: result of running SchedulerJob.process_file() + :rtype: SimpleDag + """ + if not self.done: + raise AirflowException("Tried to get the result before it's done!") + return self._result + + @property + def start_time(self): + """ + :return: when this started to process the file + :rtype: datetime + """ + if self._start_time is None: + raise AirflowException("Tried to get start time before it started!") + return self._start_time + + +class SchedulerJob(BaseJob): + """ + This SchedulerJob runs for a specific time interval and schedules the jobs + that are ready to run. It figures out the latest runs for each + task and sees if the dependencies for the next schedules are met. + If so, it creates appropriate TaskInstances and sends run commands to the + executor. It does this for each task in each DAG and repeats. + """ + + __mapper_args__ = { + 'polymorphic_identity': 'SchedulerJob' + } + + def __init__( + self, + dag_id=None, + dag_ids=None, + subdir=settings.DAGS_FOLDER, + num_runs=-1, + file_process_interval=conf.getint('scheduler', + 'min_file_process_interval'), + processor_poll_interval=1.0, + run_duration=None, + do_pickle=False, + *args, **kwargs): + """ + :param dag_id: if specified, only schedule tasks with this DAG ID + :type dag_id: unicode + :param dag_ids: if specified, only schedule tasks with these DAG IDs + :type dag_ids: list[unicode] + :param subdir: directory containing Python files with Airflow DAG + definitions, or a specific path to a file + :type subdir: unicode + :param num_runs: The number of times to try to schedule each DAG file. + -1 for unlimited within the run_duration. + :param processor_poll_interval: The number of seconds to wait between + polls of running processors + :param run_duration: how long to run (in seconds) before exiting + :type run_duration: int + :param do_pickle: once a DAG object is obtained by executing the Python + file, whether to serialize the DAG object to the DB + :type do_pickle: bool + """ + # for BaseJob compatibility + self.dag_id = dag_id + self.dag_ids = [dag_id] if dag_id else [] + if dag_ids: + self.dag_ids.extend(dag_ids) + + self.subdir = subdir + + self.num_runs = num_runs + self.run_duration = run_duration + self._processor_poll_interval = processor_poll_interval + + self.do_pickle = do_pickle + super(SchedulerJob, self).__init__(*args, **kwargs) + + self.heartrate = conf.getint('scheduler', 'SCHEDULER_HEARTBEAT_SEC') + self.max_threads = conf.getint('scheduler', 'max_threads') + self.using_sqlite = False + if 'sqlite' in conf.get('core', 'sql_alchemy_conn'): + if self.max_threads > 1: + self.log.error("Cannot use more than 1 thread when using sqlite. Setting max_threads to 1") + self.max_threads = 1 + self.using_sqlite = True + + # How often to scan the DAGs directory for new files. Default to 5 minutes. + self.dag_dir_list_interval = conf.getint('scheduler', + 'dag_dir_list_interval') + # How often to print out DAG file processing stats to the log. Default to + # 30 seconds. + self.print_stats_interval = conf.getint('scheduler', + 'print_stats_interval') + # Parse and schedule each file no faster than this interval. Default + # to 3 minutes. + self.file_process_interval = file_process_interval + # Directory where log files for the processes that scheduled the DAGs reside + self.child_process_log_directory = conf.get('scheduler', + 'child_process_log_directory') + self.max_tis_per_query = conf.getint('scheduler', 'max_tis_per_query') + if run_duration is None: + self.run_duration = conf.getint('scheduler', + 'run_duration') + + @provide_session + def manage_slas(self, dag, session=None): + """ + Finding all tasks that have SLAs defined, and sending alert emails + where needed. New SLA misses are also recorded in the database. + + Where assuming that the scheduler runs often, so we only check for + tasks that should have succeeded in the past hour. + """ + if not any([ti.sla for ti in dag.tasks]): + self.log.info( + "Skipping SLA check for %s because no tasks in DAG have SLAs", + dag + ) + return + + TI = models.TaskInstance + sq = ( + session + .query( + TI.task_id, + func.max(TI.execution_date).label('max_ti')) + .with_hint(TI, 'USE INDEX (PRIMARY)', dialect_name='mysql') + .filter(TI.dag_id == dag.dag_id) + .filter(TI.state == State.SUCCESS) + .filter(TI.task_id.in_(dag.task_ids)) + .group_by(TI.task_id).subquery('sq') + ) + + max_tis = session.query(TI).filter( + TI.dag_id == dag.dag_id, + TI.task_id == sq.c.task_id, + TI.execution_date == sq.c.max_ti, + ).all() + + ts = datetime.utcnow() + SlaMiss = models.SlaMiss + for ti in max_tis: + task = dag.get_task(ti.task_id) + dttm = ti.execution_date + if task.sla: + dttm = dag.following_schedule(dttm) + while dttm < datetime.utcnow(): + following_schedule = dag.following_schedule(dttm) + if following_schedule + task.sla < datetime.utcnow(): + session.merge(models.SlaMiss( + task_id=ti.task_id, + dag_id=ti.dag_id, + execution_date=dttm, + timestamp=ts)) + dttm = dag.following_schedule(dttm) + session.commit() + + slas = ( + session + .query(SlaMiss) + .filter(SlaMiss.notification_sent == False) + .filter(SlaMiss.dag_id == dag.dag_id) + .all() + ) + + if slas: + sla_dates = [sla.execution_date for sla in slas] + qry = ( + session + .query(TI) + .filter(TI.state != State.SUCCESS) + .filter(TI.execution_date.in_(sla_dates)) + .filter(TI.dag_id == dag.dag_id) + .all() + ) + blocking_tis = [] + for ti in qry: + if ti.task_id in dag.task_ids: + ti.task = dag.get_task(ti.task_id) + blocking_tis.append(ti) + else: + session.delete(ti) + session.commit() + + task_list = "\n".join([ + sla.task_id + ' on ' + sla.execution_date.isoformat() + for sla in slas]) + blocking_task_list = "\n".join([ + ti.task_id + ' on ' + ti.execution_date.isoformat() + for ti in blocking_tis]) + # Track whether email or any alert notification sent + # We consider email or the alert callback as notifications + email_sent = False + notification_sent = False + if dag.sla_miss_callback: + # Execute the alert callback + self.log.info(' --------------> ABOUT TO CALL SLA MISS CALL BACK ') + dag.sla_miss_callback(dag, task_list, blocking_task_list, slas, blocking_tis) + notification_sent = True + email_content = """\ + Here's a list of tasks that missed their SLAs: +
{task_list}\n
+ Blocking tasks: +
{blocking_task_list}\n{bug}
+ """.format(bug=asciiart.bug, **locals()) + emails = [] + for t in dag.tasks: + if t.email: + if isinstance(t.email, basestring): + l = [t.email] + elif isinstance(t.email, (list, tuple)): + l = t.email + for email in l: + if email not in emails: + emails.append(email) + if emails and len(slas): + send_email( + emails, + "[airflow] SLA miss on DAG=" + dag.dag_id, + email_content) + email_sent = True + notification_sent = True + # If we sent any notification, update the sla_miss table + if notification_sent: + for sla in slas: + if email_sent: + sla.email_sent = True + sla.notification_sent = True + session.merge(sla) + session.commit() + session.close() + + @staticmethod + @provide_session + def clear_nonexistent_import_errors(session, known_file_paths): + """ + Clears import errors for files that no longer exist. + + :param session: session for ORM operations + :type session: sqlalchemy.orm.session.Session + :param known_file_paths: The list of existing files that are parsed for DAGs + :type known_file_paths: list[unicode] + """ + query = session.query(models.ImportError) + if known_file_paths: + query = query.filter( + ~models.ImportError.filename.in_(known_file_paths) + ) + query.delete(synchronize_session='fetch') + session.commit() + + @staticmethod + def update_import_errors(session, dagbag): + """ + For the DAGs in the given DagBag, record any associated import errors and clears + errors for files that no longer have them. These are usually displayed through the + Airflow UI so that users know that there are issues parsing DAGs. + + :param session: session for ORM operations + :type session: sqlalchemy.orm.session.Session + :param dagbag: DagBag containing DAGs with import errors + :type dagbag: models.Dagbag + """ + # Clear the errors of the processed files + for dagbag_file in dagbag.file_last_changed: + session.query(models.ImportError).filter( + models.ImportError.filename == dagbag_file + ).delete() + + # Add the errors of the processed files + for filename, stacktrace in six.iteritems(dagbag.import_errors): + session.add(models.ImportError( + filename=filename, + stacktrace=stacktrace)) + session.commit() + + @provide_session + def create_dag_run(self, dag, session=None): + """ + This method checks whether a new DagRun needs to be created + for a DAG based on scheduling interval + Returns DagRun if one is scheduled. Otherwise returns None. + """ + if dag.schedule_interval: + active_runs = DagRun.find( + dag_id=dag.dag_id, + state=State.RUNNING, + external_trigger=False, + session=session + ) + # return if already reached maximum active runs and no timeout setting + if len(active_runs) >= dag.max_active_runs and not dag.dagrun_timeout: + return + timedout_runs = 0 + for dr in active_runs: + if ( + dr.start_date and dag.dagrun_timeout and + dr.start_date < datetime.utcnow() - dag.dagrun_timeout): + dr.state = State.FAILED + dr.end_date = datetime.utcnow() + timedout_runs += 1 + session.commit() + if len(active_runs) - timedout_runs >= dag.max_active_runs: + return + + # this query should be replaced by find dagrun + qry = ( + session.query(func.max(DagRun.execution_date)) + .filter_by(dag_id=dag.dag_id) + .filter(or_( + DagRun.external_trigger == False, + # add % as a wildcard for the like query + DagRun.run_id.like(DagRun.ID_PREFIX + '%') + )) + ) + last_scheduled_run = qry.scalar() + + # don't schedule @once again + if dag.schedule_interval == '@once' and last_scheduled_run: + return None + + # don't do scheduler catchup for dag's that don't have dag.catchup = True + if not dag.catchup: + # The logic is that we move start_date up until + # one period before, so that datetime.utcnow() is AFTER + # the period end, and the job can be created... + now = datetime.utcnow() + next_start = dag.following_schedule(now) + last_start = dag.previous_schedule(now) + if next_start <= now: + new_start = last_start + else: + new_start = dag.previous_schedule(last_start) + + if dag.start_date: + if new_start >= dag.start_date: + dag.start_date = new_start + else: + dag.start_date = new_start + + next_run_date = None + if not last_scheduled_run: + # First run + task_start_dates = [t.start_date for t in dag.tasks] + if task_start_dates: + next_run_date = dag.normalize_schedule(min(task_start_dates)) + self.log.debug( + "Next run date based on tasks %s", + next_run_date + ) + else: + next_run_date = dag.following_schedule(last_scheduled_run) + + # make sure backfills are also considered + last_run = dag.get_last_dagrun(session=session) + if last_run and next_run_date: + while next_run_date <= last_run.execution_date: + next_run_date = dag.following_schedule(next_run_date) + + # don't ever schedule prior to the dag's start_date + if dag.start_date: + next_run_date = (dag.start_date if not next_run_date + else max(next_run_date, dag.start_date)) + if next_run_date == dag.start_date: + next_run_date = dag.normalize_schedule(dag.start_date) + + self.log.debug( + "Dag start date: %s. Next run date: %s", + dag.start_date, next_run_date + ) + + # don't ever schedule in the future + if next_run_date > datetime.utcnow(): + return + + # this structure is necessary to avoid a TypeError from concatenating + # NoneType + if dag.schedule_interval == '@once': + period_end = next_run_date + elif next_run_date: + period_end = dag.following_schedule(next_run_date) + + # Don't schedule a dag beyond its end_date (as specified by the dag param) + if next_run_date and dag.end_date and next_run_date > dag.end_date: + return + + # Don't schedule a dag beyond its end_date (as specified by the task params) + # Get the min task end date, which may come from the dag.default_args + min_task_end_date = [] + task_end_dates = [t.end_date for t in dag.tasks if t.end_date] + if task_end_dates: + min_task_end_date = min(task_end_dates) + if next_run_date and min_task_end_date and next_run_date > min_task_end_date: + return + + if next_run_date and period_end and period_end <= datetime.utcnow(): + next_run = dag.create_dagrun( + run_id=DagRun.ID_PREFIX + next_run_date.isoformat(), + execution_date=next_run_date, + start_date=datetime.utcnow(), + state=State.RUNNING, + external_trigger=False + ) + return next_run + + def _process_task_instances(self, dag, queue): + """ + This method schedules the tasks for a single DAG by looking at the + active DAG runs and adding task instances that should run to the + queue. + """ + session = settings.Session() + + # update the state of the previously active dag runs + dag_runs = DagRun.find(dag_id=dag.dag_id, state=State.RUNNING, session=session) + active_dag_runs = [] + for run in dag_runs: + self.log.info("Examining DAG run %s", run) + # don't consider runs that are executed in the future + if run.execution_date > datetime.utcnow(): + self.log.error( + "Execution date is in future: %s", + run.execution_date + ) + continue + + if len(active_dag_runs) >= dag.max_active_runs: + self.log.info("Active dag runs > max_active_run.") + continue + + # skip backfill dagruns for now as long as they are not really scheduled + if run.is_backfill: + continue + + # todo: run.dag is transient but needs to be set + run.dag = dag + # todo: preferably the integrity check happens at dag collection time + run.verify_integrity(session=session) + run.update_state(session=session) + if run.state == State.RUNNING: + make_transient(run) + active_dag_runs.append(run) + + for run in active_dag_runs: + self.log.debug("Examining active DAG run: %s", run) + # this needs a fresh session sometimes tis get detached + tis = run.get_task_instances(state=(State.NONE, + State.UP_FOR_RETRY)) + + # this loop is quite slow as it uses are_dependencies_met for + # every task (in ti.is_runnable). This is also called in + # update_state above which has already checked these tasks + for ti in tis: + task = dag.get_task(ti.task_id) + + # fixme: ti.task is transient but needs to be set + ti.task = task + + # future: remove adhoc + if task.adhoc: + continue + + if ti.are_dependencies_met( + dep_context=DepContext(flag_upstream_failed=True), + session=session): + self.log.debug('Queuing task: %s', ti) + queue.append(ti.key) + + session.close() + + @provide_session + def _change_state_for_tis_without_dagrun(self, + simple_dag_bag, + old_states, + new_state, + session=None): + """ + For all DAG IDs in the SimpleDagBag, look for task instances in the + old_states and set them to new_state if the corresponding DagRun + exists but is not in the running state. This normally should not + happen, but it can if the state of DagRuns are changed manually. + + :param old_states: examine TaskInstances in this state + :type old_state: list[State] + :param new_state: set TaskInstances to this state + :type new_state: State + :param simple_dag_bag: TaskInstances associated with DAGs in the + simple_dag_bag and with states in the old_state will be examined + :type simple_dag_bag: SimpleDagBag + """ + tis_changed = 0 + if self.using_sqlite: + tis_to_change = ( + session + .query(models.TaskInstance) + .filter(models.TaskInstance.dag_id.in_(simple_dag_bag.dag_ids)) + .filter(models.TaskInstance.state.in_(old_states)) + .filter(and_( + models.DagRun.dag_id == models.TaskInstance.dag_id, + models.DagRun.execution_date == models.TaskInstance.execution_date, + models.DagRun.state != State.RUNNING)) + .with_for_update() + .all() + ) + for ti in tis_to_change: + ti.set_state(new_state, session=session) + tis_changed += 1 + else: + tis_changed = ( + session + .query(models.TaskInstance) + .filter(models.TaskInstance.dag_id.in_(simple_dag_bag.dag_ids)) + .filter(models.TaskInstance.state.in_(old_states)) + .filter(and_( + models.DagRun.dag_id == models.TaskInstance.dag_id, + models.DagRun.execution_date == models.TaskInstance.execution_date, + models.DagRun.state != State.RUNNING)) + .update({models.TaskInstance.state: new_state}, + synchronize_session=False) + ) + session.commit() + + if tis_changed > 0: + self.log.warning( + "Set %s task instances to state=%s as their associated DagRun was not in RUNNING state", + tis_changed, new_state + ) + + @provide_session + def __get_task_concurrency_map(self, states, session=None): + """ + Returns a map from tasks to number in the states list given. + + :param states: List of states to query for + :type states: List[State] + :return: A map from (dag_id, task_id) to count of tasks in states + :rtype: Dict[[String, String], Int] + + """ + TI = models.TaskInstance + ti_concurrency_query = ( + session + .query(TI.task_id, TI.dag_id, func.count('*')) + .filter(TI.state.in_(states)) + .group_by(TI.task_id, TI.dag_id) + ).all() + task_map = defaultdict(int) + for result in ti_concurrency_query: + task_id, dag_id, count = result + task_map[(dag_id, task_id)] = count + return task_map + + @provide_session + def _find_executable_task_instances(self, simple_dag_bag, states, session=None): + """ + Finds TIs that are ready for execution with respect to pool limits, + dag concurrency, executor state, and priority. + + :param simple_dag_bag: TaskInstances associated with DAGs in the + simple_dag_bag will be fetched from the DB and executed + :type simple_dag_bag: SimpleDagBag + :param executor: the executor that runs task instances + :type executor: BaseExecutor + :param states: Execute TaskInstances in these states + :type states: Tuple[State] + :return: List[TaskInstance] + """ + # TODO(saguziel): Change this to include QUEUED, for concurrency + # purposes we may want to count queued tasks + states_to_count_as_running = [State.RUNNING] + executable_tis = [] + + # Get all the queued task instances from associated with scheduled + # DagRuns which are not backfilled, in the given states, + # and the dag is not paused + TI = models.TaskInstance + DR = models.DagRun + DM = models.DagModel + ti_query = ( + session + .query(TI) + .filter(TI.dag_id.in_(simple_dag_bag.dag_ids)) + .outerjoin(DR, + and_(DR.dag_id == TI.dag_id, + DR.execution_date == TI.execution_date)) + .filter(or_(DR.run_id == None, + not_(DR.run_id.like(BackfillJob.ID_PREFIX + '%')))) + .outerjoin(DM, DM.dag_id==TI.dag_id) + .filter(or_(DM.dag_id == None, + not_(DM.is_paused))) + ) + if None in states: + ti_query = ti_query.filter(or_(TI.state == None, TI.state.in_(states))) + else: + ti_query = ti_query.filter(TI.state.in_(states)) + + task_instances_to_examine = ti_query.all() + + if len(task_instances_to_examine) == 0: + self.log.info("No tasks to consider for execution.") + return executable_tis + + # Put one task instance on each line + task_instance_str = "\n\t".join( + ["{}".format(x) for x in task_instances_to_examine]) + self.log.info("Tasks up for execution:\n\t%s", task_instance_str) + + # Get the pool settings + pools = {p.pool: p for p in session.query(models.Pool).all()} + + pool_to_task_instances = defaultdict(list) + for task_instance in task_instances_to_examine: + pool_to_task_instances[task_instance.pool].append(task_instance) + + task_concurrency_map = self.__get_task_concurrency_map(states=states_to_count_as_running, session=session) + + # Go through each pool, and queue up a task for execution if there are + # any open slots in the pool. + for pool, task_instances in pool_to_task_instances.items(): + if not pool: + # Arbitrary: + # If queued outside of a pool, trigger no more than + # non_pooled_task_slot_count per run + open_slots = conf.getint('core', 'non_pooled_task_slot_count') + else: + open_slots = pools[pool].open_slots(session=session) + + num_queued = len(task_instances) + self.log.info( + "Figuring out tasks to run in Pool(name={pool}) with {open_slots} " + "open slots and {num_queued} task instances in queue".format( + **locals() + ) + ) + + priority_sorted_task_instances = sorted( + task_instances, key=lambda ti: (-ti.priority_weight, ti.execution_date)) + + # DAG IDs with running tasks that equal the concurrency limit of the dag + dag_id_to_possibly_running_task_count = {} + + for task_instance in priority_sorted_task_instances: + if open_slots <= 0: + self.log.info( + "Not scheduling since there are %s open slots in pool %s", + open_slots, pool + ) + # Can't schedule any more since there are no more open slots. + break + + # Check to make sure that the task concurrency of the DAG hasn't been + # reached. + dag_id = task_instance.dag_id + simple_dag = simple_dag_bag.get_dag(dag_id) + + if dag_id not in dag_id_to_possibly_running_task_count: + # TODO(saguziel): also check against QUEUED state, see AIRFLOW-1104 + dag_id_to_possibly_running_task_count[dag_id] = \ + DAG.get_num_task_instances( + dag_id, + simple_dag_bag.get_dag(dag_id).task_ids, + states=states_to_count_as_running, + session=session) + + current_task_concurrency = dag_id_to_possibly_running_task_count[dag_id] + task_concurrency_limit = simple_dag_bag.get_dag(dag_id).concurrency + self.log.info( + "DAG %s has %s/%s running and queued tasks", + dag_id, current_task_concurrency, task_concurrency_limit + ) + if current_task_concurrency >= task_concurrency_limit: + self.log.info( + "Not executing %s since the number of tasks running or queued from DAG %s" + " is >= to the DAG's task concurrency limit of %s", + task_instance, dag_id, task_concurrency_limit + ) + continue + + task_concurrency = simple_dag.get_task_special_arg(task_instance.task_id, 'task_concurrency') + if task_concurrency is not None: + num_running = task_concurrency_map[((task_instance.dag_id, task_instance.task_id))] + if num_running >= task_concurrency: + self.logger.info("Not executing %s since the task concurrency for this task" + " has been reached.", task_instance) + continue + else: + task_concurrency_map[(task_instance.dag_id, task_instance.task_id)] += 1 + + if self.executor.has_task(task_instance): + self.log.debug( + "Not handling task %s as the executor reports it is running", + task_instance.key + ) + continue + executable_tis.append(task_instance) + open_slots -= 1 + dag_id_to_possibly_running_task_count[dag_id] += 1 + + task_instance_str = "\n\t".join( + ["{}".format(x) for x in executable_tis]) + self.log.info("Setting the follow tasks to queued state:\n\t%s", task_instance_str) + # so these dont expire on commit + for ti in executable_tis: + copy_dag_id = ti.dag_id + copy_execution_date = ti.execution_date + copy_task_id = ti.task_id + make_transient(ti) + ti.dag_id = copy_dag_id + ti.execution_date = copy_execution_date + ti.task_id = copy_task_id + return executable_tis + + @provide_session + def _change_state_for_executable_task_instances(self, task_instances, + acceptable_states, session=None): + """ + Changes the state of task instances in the list with one of the given states + to QUEUED atomically, and returns the TIs changed. + + :param task_instances: TaskInstances to change the state of + :type task_instances: List[TaskInstance] + :param acceptable_states: Filters the TaskInstances updated to be in these states + :type acceptable_states: Iterable[State] + :return: List[TaskInstance] + """ + if len(task_instances) == 0: + session.commit() + return [] + + TI = models.TaskInstance + filter_for_ti_state_change = ( + [and_( + TI.dag_id == ti.dag_id, + TI.task_id == ti.task_id, + TI.execution_date == ti.execution_date) + for ti in task_instances]) + ti_query = ( + session + .query(TI) + .filter(or_(*filter_for_ti_state_change))) + + if None in acceptable_states: + ti_query = ti_query.filter(or_(TI.state == None, TI.state.in_(acceptable_states))) + else: + ti_query = ti_query.filter(TI.state.in_(acceptable_states)) + + tis_to_set_to_queued = ( + ti_query + .with_for_update() + .all()) + if len(tis_to_set_to_queued) == 0: + self.log.info("No tasks were able to have their state changed to queued.") + session.commit() + return [] + + # set TIs to queued state + for task_instance in tis_to_set_to_queued: + task_instance.state = State.QUEUED + task_instance.queued_dttm = (datetime.utcnow() + if not task_instance.queued_dttm + else task_instance.queued_dttm) + session.merge(task_instance) + + # save which TIs we set before session expires them + filter_for_ti_enqueue = ([and_(TI.dag_id == ti.dag_id, + TI.task_id == ti.task_id, + TI.execution_date == ti.execution_date) + for ti in tis_to_set_to_queued]) + session.commit() + + # requery in batch since above was expired by commit + tis_to_be_queued = ( + session + .query(TI) + .filter(or_(*filter_for_ti_enqueue)) + .all()) + + task_instance_str = "\n\t".join( + ["{}".format(x) for x in tis_to_be_queued]) + self.log.info("Setting the follow tasks to queued state:\n\t%s", task_instance_str) + return tis_to_be_queued + + def _enqueue_task_instances_with_queued_state(self, simple_dag_bag, task_instances): + """ + Takes task_instances, which should have been set to queued, and enqueues them + with the executor. + + :param task_instances: TaskInstances to enqueue + :type task_instances: List[TaskInstance] + :param simple_dag_bag: Should contains all of the task_instances' dags + :type simple_dag_bag: SimpleDagBag + """ + TI = models.TaskInstance + # actually enqueue them + for task_instance in task_instances: + command = " ".join(TI.generate_command( + task_instance.dag_id, + task_instance.task_id, + task_instance.execution_date, + local=True, + mark_success=False, + ignore_all_deps=False, + ignore_depends_on_past=False, + ignore_task_deps=False, + ignore_ti_state=False, + pool=task_instance.pool, + file_path=simple_dag_bag.get_dag(task_instance.dag_id).full_filepath, + pickle_id=simple_dag_bag.get_dag(task_instance.dag_id).pickle_id)) + + priority = task_instance.priority_weight + queue = task_instance.queue + self.log.info( + "Sending %s to executor with priority %s and queue %s", + task_instance.key, priority, queue + ) + + # save attributes so sqlalchemy doesnt expire them + copy_dag_id = task_instance.dag_id + copy_task_id = task_instance.task_id + copy_execution_date = task_instance.execution_date + make_transient(task_instance) + task_instance.dag_id = copy_dag_id + task_instance.task_id = copy_task_id + task_instance.execution_date = copy_execution_date + + self.executor.queue_command( + task_instance, + command, + priority=priority, + queue=queue) + + @provide_session + def _execute_task_instances(self, + simple_dag_bag, + states, + session=None): + """ + Attempts to execute TaskInstances that should be executed by the scheduler. + + There are three steps: + 1. Pick TIs by priority with the constraint that they are in the expected states + and that we do exceed max_active_runs or pool limits. + 2. Change the state for the TIs above atomically. + 3. Enqueue the TIs in the executor. + + :param simple_dag_bag: TaskInstances associated with DAGs in the + simple_dag_bag will be fetched from the DB and executed + :type simple_dag_bag: SimpleDagBag + :param states: Execute TaskInstances in these states + :type states: Tuple[State] + :return: None + """ + executable_tis = self._find_executable_task_instances(simple_dag_bag, states, + session=session) + if self.max_tis_per_query == 0: + tis_with_state_changed = self._change_state_for_executable_task_instances( + executable_tis, + states, + session=session) + self._enqueue_task_instances_with_queued_state( + simple_dag_bag, + tis_with_state_changed) + session.commit() + return len(tis_with_state_changed) + else: + # makes chunks of max_tis_per_query size + chunks = ([executable_tis[i:i + self.max_tis_per_query] + for i in range(0, len(executable_tis), self.max_tis_per_query)]) + total_tis_queued = 0 + for chunk in chunks: + tis_with_state_changed = self._change_state_for_executable_task_instances( + chunk, + states, + session=session) + self._enqueue_task_instances_with_queued_state( + simple_dag_bag, + tis_with_state_changed) + session.commit() + total_tis_queued += len(tis_with_state_changed) + return total_tis_queued + + def _process_dags(self, dagbag, dags, tis_out): + """ + Iterates over the dags and processes them. Processing includes: + + 1. Create appropriate DagRun(s) in the DB. + 2. Create appropriate TaskInstance(s) in the DB. + 3. Send emails for tasks that have missed SLAs. + + :param dagbag: a collection of DAGs to process + :type dagbag: models.DagBag + :param dags: the DAGs from the DagBag to process + :type dags: DAG + :param tis_out: A queue to add generated TaskInstance objects + :type tis_out: multiprocessing.Queue[TaskInstance] + :return: None + """ + for dag in dags: + dag = dagbag.get_dag(dag.dag_id) + if dag.is_paused: + self.log.info("Not processing DAG %s since it's paused", dag.dag_id) + continue + + if not dag: + self.log.error("DAG ID %s was not found in the DagBag", dag.dag_id) + continue + + self.log.info("Processing %s", dag.dag_id) + + dag_run = self.create_dag_run(dag) + if dag_run: + self.log.info("Created %s", dag_run) + self._process_task_instances(dag, tis_out) + self.manage_slas(dag) + + models.DagStat.update([d.dag_id for d in dags]) + + def _process_executor_events(self): + """ + Respond to executor events. + + :param executor: the executor that's running the task instances + :type executor: BaseExecutor + :return: None + """ + for key, executor_state in list(self.executor.get_event_buffer().items()): + dag_id, task_id, execution_date = key + self.log.info( + "Executor reports %s.%s execution_date=%s as %s", + dag_id, task_id, execution_date, executor_state + ) + + def _log_file_processing_stats(self, + known_file_paths, + processor_manager): + """ + Print out stats about how files are getting processed. + + :param known_file_paths: a list of file paths that may contain Airflow + DAG definitions + :type known_file_paths: list[unicode] + :param processor_manager: manager for the file processors + :type stats: DagFileProcessorManager + :return: None + """ + + # File Path: Path to the file containing the DAG definition + # PID: PID associated with the process that's processing the file. May + # be empty. + # Runtime: If the process is currently running, how long it's been + # running for in seconds. + # Last Runtime: If the process ran before, how long did it take to + # finish in seconds + # Last Run: When the file finished processing in the previous run. + headers = ["File Path", + "PID", + "Runtime", + "Last Runtime", + "Last Run"] + + rows = [] + for file_path in known_file_paths: + last_runtime = processor_manager.get_last_runtime(file_path) + processor_pid = processor_manager.get_pid(file_path) + processor_start_time = processor_manager.get_start_time(file_path) + runtime = ((datetime.utcnow() - processor_start_time).total_seconds() + if processor_start_time else None) + last_run = processor_manager.get_last_finish_time(file_path) + + rows.append((file_path, + processor_pid, + runtime, + last_runtime, + last_run)) + + # Sort by longest last runtime. (Can't sort None values in python3) + rows = sorted(rows, key=lambda x: x[3] or 0.0) + + formatted_rows = [] + for file_path, pid, runtime, last_runtime, last_run in rows: + formatted_rows.append((file_path, + pid, + "{:.2f}s".format(runtime) + if runtime else None, + "{:.2f}s".format(last_runtime) + if last_runtime else None, + last_run.strftime("%Y-%m-%dT%H:%M:%S") + if last_run else None)) + log_str = ("\n" + + "=" * 80 + + "\n" + + "DAG File Processing Stats\n\n" + + tabulate(formatted_rows, headers=headers) + + "\n" + + "=" * 80) + + self.log.info(log_str) + + def _execute(self): + self.log.info("Starting the scheduler") + pessimistic_connection_handling() + + # DAGs can be pickled for easier remote execution by some executors + pickle_dags = False + if self.do_pickle and self.executor.__class__ not in \ + (executors.LocalExecutor, executors.SequentialExecutor): + pickle_dags = True + + # Use multiple processes to parse and generate tasks for the + # DAGs in parallel. By processing them in separate processes, + # we can get parallelism and isolation from potentially harmful + # user code. + self.log.info("Processing files using up to %s processes at a time", self.max_threads) + self.log.info("Running execute loop for %s seconds", self.run_duration) + self.log.info("Processing each file at most %s times", self.num_runs) + self.log.info("Process each file at most once every %s seconds", self.file_process_interval) + self.log.info("Checking for new files in %s every %s seconds", self.subdir, self.dag_dir_list_interval) + + # Build up a list of Python files that could contain DAGs + self.log.info("Searching for files in %s", self.subdir) + known_file_paths = list_py_file_paths(self.subdir) + self.log.info("There are %s files in %s", len(known_file_paths), self.subdir) + + def processor_factory(file_path, log_file_path): + return DagFileProcessor(file_path, + pickle_dags, + self.dag_ids, + log_file_path) + + processor_manager = DagFileProcessorManager(self.subdir, + known_file_paths, + self.max_threads, + self.file_process_interval, + self.child_process_log_directory, + self.num_runs, + processor_factory) + + try: + self._execute_helper(processor_manager) + finally: + self.log.info("Exited execute loop") + + # Kill all child processes on exit since we don't want to leave + # them as orphaned. + pids_to_kill = processor_manager.get_all_pids() + if len(pids_to_kill) > 0: + # First try SIGTERM + this_process = psutil.Process(os.getpid()) + # Only check child processes to ensure that we don't have a case + # where we kill the wrong process because a child process died + # but the PID got reused. + child_processes = [x for x in this_process.children(recursive=True) + if x.is_running() and x.pid in pids_to_kill] + for child in child_processes: + self.log.info("Terminating child PID: %s", child.pid) + child.terminate() + # TODO: Remove magic number + timeout = 5 + self.log.info("Waiting up to %s seconds for processes to exit...", timeout) + try: + psutil.wait_procs(child_processes, timeout) + except psutil.TimeoutExpired: + self.log.debug("Ran out of time while waiting for processes to exit") + + # Then SIGKILL + child_processes = [x for x in this_process.children(recursive=True) + if x.is_running() and x.pid in pids_to_kill] + if len(child_processes) > 0: + for child in child_processes: + self.log.info("Killing child PID: %s", child.pid) + child.kill() + child.wait() + + def _execute_helper(self, processor_manager): + """ + :param processor_manager: manager to use + :type processor_manager: DagFileProcessorManager + :return: None + """ + self.executor.start() + + session = settings.Session() + self.log.info("Resetting orphaned tasks for active dag runs") + self.reset_state_for_orphaned_tasks(session=session) + session.close() + + execute_start_time = datetime.utcnow() + + # Last time stats were printed + last_stat_print_time = datetime(2000, 1, 1) + # Last time that self.heartbeat() was called. + last_self_heartbeat_time = datetime.utcnow() + # Last time that the DAG dir was traversed to look for files + last_dag_dir_refresh_time = datetime.utcnow() + + # Use this value initially + known_file_paths = processor_manager.file_paths + + # For the execute duration, parse and schedule DAGs + while (datetime.utcnow() - execute_start_time).total_seconds() < \ + self.run_duration or self.run_duration < 0: + self.log.debug("Starting Loop...") + loop_start_time = time.time() + + # Traverse the DAG directory for Python files containing DAGs + # periodically + elapsed_time_since_refresh = (datetime.utcnow() - + last_dag_dir_refresh_time).total_seconds() + + if elapsed_time_since_refresh > self.dag_dir_list_interval: + # Build up a list of Python files that could contain DAGs + self.log.info("Searching for files in %s", self.subdir) + known_file_paths = list_py_file_paths(self.subdir) + last_dag_dir_refresh_time = datetime.utcnow() + self.log.info("There are %s files in %s", len(known_file_paths), self.subdir) + processor_manager.set_file_paths(known_file_paths) + + self.log.debug("Removing old import errors") + self.clear_nonexistent_import_errors(known_file_paths=known_file_paths) + + # Kick of new processes and collect results from finished ones + self.log.info("Heartbeating the process manager") + simple_dags = processor_manager.heartbeat() + + if self.using_sqlite: + # For the sqlite case w/ 1 thread, wait until the processor + # is finished to avoid concurrent access to the DB. + self.log.debug("Waiting for processors to finish since we're using sqlite") + processor_manager.wait_until_finished() + + # Send tasks for execution if available + if len(simple_dags) > 0: + simple_dag_bag = SimpleDagBag(simple_dags) + + # Handle cases where a DAG run state is set (perhaps manually) to + # a non-running state. Handle task instances that belong to + # DAG runs in those states + + # If a task instance is up for retry but the corresponding DAG run + # isn't running, mark the task instance as FAILED so we don't try + # to re-run it. + self._change_state_for_tis_without_dagrun(simple_dag_bag, + [State.UP_FOR_RETRY], + State.FAILED) + # If a task instance is scheduled or queued, but the corresponding + # DAG run isn't running, set the state to NONE so we don't try to + # re-run it. + self._change_state_for_tis_without_dagrun(simple_dag_bag, + [State.QUEUED, + State.SCHEDULED], + State.NONE) + + self._execute_task_instances(simple_dag_bag, + (State.SCHEDULED,)) + + # Call heartbeats + self.log.info("Heartbeating the executor") + self.executor.heartbeat() + + # Process events from the executor + self._process_executor_events() + + # Heartbeat the scheduler periodically + time_since_last_heartbeat = (datetime.utcnow() - + last_self_heartbeat_time).total_seconds() + if time_since_last_heartbeat > self.heartrate: + self.log.info("Heartbeating the scheduler") + self.heartbeat() + last_self_heartbeat_time = datetime.utcnow() + + # Occasionally print out stats about how fast the files are getting processed + if ((datetime.utcnow() - last_stat_print_time).total_seconds() > + self.print_stats_interval): + if len(known_file_paths) > 0: + self._log_file_processing_stats(known_file_paths, + processor_manager) + last_stat_print_time = datetime.utcnow() + + loop_end_time = time.time() + self.log.debug("Ran scheduling loop in %.2f seconds", loop_end_time - loop_start_time) + self.log.debug("Sleeping for %.2f seconds", self._processor_poll_interval) + time.sleep(self._processor_poll_interval) + + # Exit early for a test mode + if processor_manager.max_runs_reached(): + self.log.info("Exiting loop as all files have been processed %s times", self.num_runs) + break + + # Stop any processors + processor_manager.terminate() + + # Verify that all files were processed, and if so, deactivate DAGs that + # haven't been touched by the scheduler as they likely have been + # deleted. + all_files_processed = True + for file_path in known_file_paths: + if processor_manager.get_last_finish_time(file_path) is None: + all_files_processed = False + break + if all_files_processed: + self.log.info( + "Deactivating DAGs that haven't been touched since %s", + execute_start_time.isoformat() + ) + models.DAG.deactivate_stale_dags(execute_start_time) + + self.executor.end() + + settings.Session.remove() + + @provide_session + def process_file(self, file_path, pickle_dags=False, session=None): + """ + Process a Python file containing Airflow DAGs. + + This includes: + + 1. Execute the file and look for DAG objects in the namespace. + 2. Pickle the DAG and save it to the DB (if necessary). + 3. For each DAG, see what tasks should run and create appropriate task + instances in the DB. + 4. Record any errors importing the file into ORM + 5. Kill (in ORM) any task instances belonging to the DAGs that haven't + issued a heartbeat in a while. + + Returns a list of SimpleDag objects that represent the DAGs found in + the file + + :param file_path: the path to the Python file that should be executed + :type file_path: unicode + :param pickle_dags: whether serialize the DAGs found in the file and + save them to the db + :type pickle_dags: bool + :return: a list of SimpleDags made from the Dags found in the file + :rtype: list[SimpleDag] + """ + self.log.info("Processing file %s for tasks to queue", file_path) + # As DAGs are parsed from this file, they will be converted into SimpleDags + simple_dags = [] + + try: + dagbag = models.DagBag(file_path) + except Exception: + self.log.exception("Failed at reloading the DAG file %s", file_path) + Stats.incr('dag_file_refresh_error', 1, 1) + return [] + + if len(dagbag.dags) > 0: + self.log.info("DAG(s) %s retrieved from %s", dagbag.dags.keys(), file_path) + else: + self.log.warning("No viable dags retrieved from %s", file_path) + self.update_import_errors(session, dagbag) + return [] + + # Save individual DAGs in the ORM and update DagModel.last_scheduled_time + for dag in dagbag.dags.values(): + dag.sync_to_db() + + paused_dag_ids = [dag.dag_id for dag in dagbag.dags.values() + if dag.is_paused] + + # Pickle the DAGs (if necessary) and put them into a SimpleDag + for dag_id in dagbag.dags: + dag = dagbag.get_dag(dag_id) + pickle_id = None + if pickle_dags: + pickle_id = dag.pickle(session).id + + # Only return DAGs that are not paused + if dag_id not in paused_dag_ids: + simple_dags.append(SimpleDag(dag, pickle_id=pickle_id)) + + if len(self.dag_ids) > 0: + dags = [dag for dag in dagbag.dags.values() + if dag.dag_id in self.dag_ids and + dag.dag_id not in paused_dag_ids] + else: + dags = [dag for dag in dagbag.dags.values() + if not dag.parent_dag and + dag.dag_id not in paused_dag_ids] + + # Not using multiprocessing.Queue() since it's no longer a separate + # process and due to some unusual behavior. (empty() incorrectly + # returns true?) + ti_keys_to_schedule = [] + + self._process_dags(dagbag, dags, ti_keys_to_schedule) + + for ti_key in ti_keys_to_schedule: + dag = dagbag.dags[ti_key[0]] + task = dag.get_task(ti_key[1]) + ti = models.TaskInstance(task, ti_key[2]) + + ti.refresh_from_db(session=session, lock_for_update=True) + # We can defer checking the task dependency checks to the worker themselves + # since they can be expensive to run in the scheduler. + dep_context = DepContext(deps=QUEUE_DEPS, ignore_task_deps=True) + + # Only schedule tasks that have their dependencies met, e.g. to avoid + # a task that recently got it's state changed to RUNNING from somewhere + # other than the scheduler from getting it's state overwritten. + # TODO(aoen): It's not great that we have to check all the task instance + # dependencies twice; once to get the task scheduled, and again to actually + # run the task. We should try to come up with a way to only check them once. + if ti.are_dependencies_met( + dep_context=dep_context, + session=session, + verbose=True): + # Task starts out in the scheduled state. All tasks in the + # scheduled state will be sent to the executor + ti.state = State.SCHEDULED + + # Also save this task instance to the DB. + self.log.info("Creating / updating %s in ORM", ti) + session.merge(ti) + session.commit() + + # Record import errors into the ORM + try: + self.update_import_errors(session, dagbag) + except Exception: + self.log.exception("Error logging import errors!") + try: + dagbag.kill_zombies() + except Exception: + self.log.exception("Error killing zombies!") + + return simple_dags + + @provide_session + def heartbeat_callback(self, session=None): + Stats.gauge('scheduler_heartbeat', 1, 1) + + +class BackfillJob(BaseJob): + """ + A backfill job consists of a dag or subdag for a specific time range. It + triggers a set of task instance runs, in the right order and lasts for + as long as it takes for the set of task instance to be completed. + """ + ID_PREFIX = 'backfill_' + ID_FORMAT_PREFIX = ID_PREFIX + '{0}' + + __mapper_args__ = { + 'polymorphic_identity': 'BackfillJob' + } + + class _DagRunTaskStatus(object): + """ + Internal status of the backfill job. This class is intended to be instantiated + only within a BackfillJob instance and will track the execution of tasks, + e.g. started, skipped, succeeded, failed, etc. Information about the dag runs + related to the backfill job are also being tracked in this structure, + .e.g finished runs, etc. Any other status related information related to the + execution of dag runs / tasks can be included in this structure since it makes + it easier to pass it around. + """ + # TODO(edgarRd): AIRFLOW-1444: Add consistency check on counts + def __init__(self, + to_run=None, + started=None, + skipped=None, + succeeded=None, + failed=None, + not_ready=None, + deadlocked=None, + active_runs=None, + executed_dag_run_dates=None, + finished_runs=0, + total_runs=0, + ): + """ + :param to_run: Tasks to run in the backfill + :type to_run: dict[Tuple[String, String, DateTime], TaskInstance] + :param started: Maps started task instance key to task instance object + :type started: dict[Tuple[String, String, DateTime], TaskInstance] + :param skipped: Tasks that have been skipped + :type skipped: set[Tuple[String, String, DateTime]] + :param succeeded: Tasks that have succeeded so far + :type succeeded: set[Tuple[String, String, DateTime]] + :param failed: Tasks that have failed + :type failed: set[Tuple[String, String, DateTime]] + :param not_ready: Tasks not ready for execution + :type not_ready: set[Tuple[String, String, DateTime]] + :param deadlocked: Deadlocked tasks + :type deadlocked: set[Tuple[String, String, DateTime]] + :param active_runs: Active dag runs at a certain point in time + :type active_runs: list[DagRun] + :param executed_dag_run_dates: Datetime objects for the executed dag runs + :type executed_dag_run_dates: set[Datetime] + :param finished_runs: Number of finished runs so far + :type finished_runs: int + :param total_runs: Number of total dag runs able to run + :type total_runs: int + """ + self.to_run = to_run or dict() + self.started = started or dict() + self.skipped = skipped or set() + self.succeeded = succeeded or set() + self.failed = failed or set() + self.not_ready = not_ready or set() + self.deadlocked = deadlocked or set() + self.active_runs = active_runs or list() + self.executed_dag_run_dates = executed_dag_run_dates or set() + self.finished_runs = finished_runs + self.total_runs = total_runs + + def __init__( + self, + dag, + start_date=None, + end_date=None, + mark_success=False, + include_adhoc=False, + donot_pickle=False, + ignore_first_depends_on_past=False, + ignore_task_deps=False, + pool=None, + delay_on_limit_secs=1.0, + *args, **kwargs): + self.dag = dag + self.dag_id = dag.dag_id + self.bf_start_date = start_date + self.bf_end_date = end_date + self.mark_success = mark_success + self.include_adhoc = include_adhoc + self.donot_pickle = donot_pickle + self.ignore_first_depends_on_past = ignore_first_depends_on_past + self.ignore_task_deps = ignore_task_deps + self.pool = pool + self.delay_on_limit_secs = delay_on_limit_secs + super(BackfillJob, self).__init__(*args, **kwargs) + + def _update_counters(self, ti_status): + """ + Updates the counters per state of the tasks that were running. Can re-add + to tasks to run in case required. + :param ti_status: the internal status of the backfill job tasks + :type ti_status: BackfillJob._DagRunTaskStatus + """ + for key, ti in list(ti_status.started.items()): + ti.refresh_from_db() + if ti.state == State.SUCCESS: + ti_status.succeeded.add(key) + self.log.debug("Task instance %s succeeded. Don't rerun.", ti) + ti_status.started.pop(key) + continue + elif ti.state == State.SKIPPED: + ti_status.skipped.add(key) + self.log.debug("Task instance %s skipped. Don't rerun.", ti) + ti_status.started.pop(key) + continue + elif ti.state == State.FAILED: + self.log.error("Task instance %s failed", ti) + ti_status.failed.add(key) + ti_status.started.pop(key) + continue + # special case: if the task needs to run again put it back + elif ti.state == State.UP_FOR_RETRY: + self.log.warning("Task instance %s is up for retry", ti) + ti_status.started.pop(key) + ti_status.to_run[key] = ti + # special case: The state of the task can be set to NONE by the task itself + # when it reaches concurrency limits. It could also happen when the state + # is changed externally, e.g. by clearing tasks from the ui. We need to cover + # for that as otherwise those tasks would fall outside of the scope of + # the backfill suddenly. + elif ti.state == State.NONE: + self.log.warning( + "FIXME: task instance %s state was set to none externally or " + "reaching concurrency limits. Re-adding task to queue.", + ti + ) + session = settings.Session() + ti.set_state(State.SCHEDULED, session=session) + session.close() + ti_status.started.pop(key) + ti_status.to_run[key] = ti + + def _manage_executor_state(self, started): + """ + Checks if the executor agrees with the state of task instances + that are running + :param started: dict of key, task to verify + """ + executor = self.executor + + for key, state in list(executor.get_event_buffer().items()): + if key not in started: + self.log.warning( + "%s state %s not in started=%s", + key, state, started.values() + ) + continue + + ti = started[key] + ti.refresh_from_db() + + self.log.debug("Executor state: %s task %s", state, ti) + + if state == State.FAILED or state == State.SUCCESS: + if ti.state == State.RUNNING or ti.state == State.QUEUED: + msg = ("Executor reports task instance {} finished ({}) " + "although the task says its {}. Was the task " + "killed externally?".format(ti, state, ti.state)) + self.log.error(msg) + ti.handle_failure(msg) + + @provide_session + def _get_dag_run(self, run_date, session=None): + """ + Returns a dag run for the given run date, which will be matched to an existing + dag run if available or create a new dag run otherwise. If the max_active_runs + limit is reached, this function will return None. + :param run_date: the execution date for the dag run + :type run_date: datetime + :param session: the database session object + :type session: Session + :return: a DagRun in state RUNNING or None + """ + run_id = BackfillJob.ID_FORMAT_PREFIX.format(run_date.isoformat()) + + # consider max_active_runs but ignore when running subdags + respect_dag_max_active_limit = (True + if (self.dag.schedule_interval and + not self.dag.is_subdag) + else False) + + current_active_dag_count = self.dag.get_num_active_runs(external_trigger=False) + + # check if we are scheduling on top of a already existing dag_run + # we could find a "scheduled" run instead of a "backfill" + run = DagRun.find(dag_id=self.dag.dag_id, + execution_date=run_date, + session=session) + + if run is not None and len(run) > 0: + run = run[0] + if run.state == State.RUNNING: + respect_dag_max_active_limit = False + else: + run = None + + # enforce max_active_runs limit for dag, special cases already + # handled by respect_dag_max_active_limit + if (respect_dag_max_active_limit and + current_active_dag_count >= self.dag.max_active_runs): + return None + + run = run or self.dag.create_dagrun( + run_id=run_id, + execution_date=run_date, + start_date=datetime.utcnow(), + state=State.RUNNING, + external_trigger=False, + session=session + ) + + # set required transient field + run.dag = self.dag + + # explicitly mark as backfill and running + run.state = State.RUNNING + run.run_id = run_id + run.verify_integrity(session=session) + return run + + @provide_session + def _task_instances_for_dag_run(self, dag_run, session=None): + """ + Returns a map of task instance key to task instance object for the tasks to + run in the given dag run. + :param dag_run: the dag run to get the tasks from + :type dag_run: models.DagRun + :param session: the database session object + :type session: Session + """ + tasks_to_run = {} + + if dag_run is None: + return tasks_to_run + + # check if we have orphaned tasks + self.reset_state_for_orphaned_tasks(filter_by_dag_run=dag_run, session=session) + + # for some reason if we don't refresh the reference to run is lost + dag_run.refresh_from_db() + make_transient(dag_run) + + # TODO(edgarRd): AIRFLOW-1464 change to batch query to improve perf + for ti in dag_run.get_task_instances(): + # all tasks part of the backfill are scheduled to run + if ti.state == State.NONE: + ti.set_state(State.SCHEDULED, session=session) + tasks_to_run[ti.key] = ti + + return tasks_to_run + + def _log_progress(self, ti_status): + msg = ' | '.join([ + "[backfill progress]", + "finished run {0} of {1}", + "tasks waiting: {2}", + "succeeded: {3}", + "kicked_off: {4}", + "failed: {5}", + "skipped: {6}", + "deadlocked: {7}", + "not ready: {8}" + ]).format( + ti_status.finished_runs, + ti_status.total_runs, + len(ti_status.to_run), + len(ti_status.succeeded), + len(ti_status.started), + len(ti_status.failed), + len(ti_status.skipped), + len(ti_status.deadlocked), + len(ti_status.not_ready)) + self.log.info(msg) + + self.log.debug( + "Finished dag run loop iteration. Remaining tasks %s", + ti_status.to_run.values() + ) + + @provide_session + def _process_backfill_task_instances(self, + ti_status, + executor, + pickle_id, + start_date=None, session=None): + """ + Process a set of task instances from a set of dag runs. Special handling is done + to account for different task instance states that could be present when running + them in a backfill process. + :param ti_status: the internal status of the job + :type ti_status: BackfillJob._DagRunTaskStatus + :param executor: the executor to run the task instances + :type executor: BaseExecutor + :param pickle_id: the pickle_id if dag is pickled, None otherwise + :type pickle_id: int + :param start_date: the start date of the backfill job + :type start_date: datetime + :param session: the current session object + :type session: Session + :return: the list of execution_dates for the finished dag runs + :rtype: list + """ + + executed_run_dates = [] + + while ((len(ti_status.to_run) > 0 or len(ti_status.started) > 0) and + len(ti_status.deadlocked) == 0): + self.log.debug("*** Clearing out not_ready list ***") + ti_status.not_ready.clear() + + # we need to execute the tasks bottom to top + # or leaf to root, as otherwise tasks might be + # determined deadlocked while they are actually + # waiting for their upstream to finish + for task in self.dag.topological_sort(): + for key, ti in list(ti_status.to_run.items()): + if task.task_id != ti.task_id: + continue + + ti.refresh_from_db() + + task = self.dag.get_task(ti.task_id) + ti.task = task + + ignore_depends_on_past = ( + self.ignore_first_depends_on_past and + ti.execution_date == (start_date or ti.start_date)) + self.log.debug("Task instance to run %s state %s", ti, ti.state) + + # guard against externally modified tasks instances or + # in case max concurrency has been reached at task runtime + if ti.state == State.NONE: + self.log.warning( + "FIXME: task instance {} state was set to None externally. This should not happen" + ) + ti.set_state(State.SCHEDULED, session=session) + + # The task was already marked successful or skipped by a + # different Job. Don't rerun it. + if ti.state == State.SUCCESS: + ti_status.succeeded.add(key) + self.log.debug("Task instance %s succeeded. Don't rerun.", ti) + ti_status.to_run.pop(key) + if key in ti_status.started: + ti_status.started.pop(key) + continue + elif ti.state == State.SKIPPED: + ti_status.skipped.add(key) + self.log.debug("Task instance %s skipped. Don't rerun.", ti) + ti_status.to_run.pop(key) + if key in ti_status.started: + ti_status.started.pop(key) + continue + elif ti.state == State.FAILED: + self.log.error("Task instance %s failed", ti) + ti_status.failed.add(key) + ti_status.to_run.pop(key) + if key in ti_status.started: + ti_status.started.pop(key) + continue + elif ti.state == State.UPSTREAM_FAILED: + self.log.error("Task instance %s upstream failed", ti) + ti_status.failed.add(key) + ti_status.to_run.pop(key) + if key in ti_status.started: + ti_status.started.pop(key) + continue + + backfill_context = DepContext( + deps=RUN_DEPS, + ignore_depends_on_past=ignore_depends_on_past, + ignore_task_deps=self.ignore_task_deps, + flag_upstream_failed=True) + + # Is the task runnable? -- then run it + # the dependency checker can change states of tis + if ti.are_dependencies_met( + dep_context=backfill_context, + session=session, + verbose=True): + ti.refresh_from_db(lock_for_update=True, session=session) + if ti.state == State.SCHEDULED or ti.state == State.UP_FOR_RETRY: + if executor.has_task(ti): + self.log.debug( + "Task Instance %s already in executor waiting for queue to clear", + ti + ) + else: + self.log.debug('Sending %s to executor', ti) + # Skip scheduled state, we are executing immediately + ti.state = State.QUEUED + session.merge(ti) + executor.queue_task_instance( + ti, + mark_success=self.mark_success, + pickle_id=pickle_id, + ignore_task_deps=self.ignore_task_deps, + ignore_depends_on_past=ignore_depends_on_past, + pool=self.pool) + ti_status.started[key] = ti + ti_status.to_run.pop(key) + session.commit() + continue + + if ti.state == State.UPSTREAM_FAILED: + self.log.error("Task instance %s upstream failed", ti) + ti_status.failed.add(key) + ti_status.to_run.pop(key) + if key in ti_status.started: + ti_status.started.pop(key) + continue + + # special case + if ti.state == State.UP_FOR_RETRY: + self.log.debug("Task instance %s retry period not expired yet", ti) + if key in ti_status.started: + ti_status.started.pop(key) + ti_status.to_run[key] = ti + continue + + # all remaining tasks + self.log.debug('Adding %s to not_ready', ti) + ti_status.not_ready.add(key) + + # execute the tasks in the queue + self.heartbeat() + executor.heartbeat() + + # If the set of tasks that aren't ready ever equals the set of + # tasks to run and there are no running tasks then the backfill + # is deadlocked + if (ti_status.not_ready and + ti_status.not_ready == set(ti_status.to_run) and + len(ti_status.started) == 0): + self.log.warning( + "Deadlock discovered for ti_status.to_run=%s", + ti_status.to_run.values() + ) + ti_status.deadlocked.update(ti_status.to_run.values()) + ti_status.to_run.clear() + + # check executor state + self._manage_executor_state(ti_status.started) + + # update the task counters + self._update_counters(ti_status=ti_status) + + # update dag run state + _dag_runs = ti_status.active_runs[:] + for run in _dag_runs: + run.update_state(session=session) + if run.state in State.finished(): + ti_status.finished_runs += 1 + ti_status.active_runs.remove(run) + executed_run_dates.append(run.execution_date) + + if run.dag.is_paused: + models.DagStat.update([run.dag_id], session=session) + + self._log_progress(ti_status) + + # return updated status + return executed_run_dates + + @provide_session + def _collect_errors(self, ti_status, session=None): + err = '' + if ti_status.failed: + err += ( + "---------------------------------------------------\n" + "Some task instances failed:\n%s\n".format(ti_status.failed)) + if ti_status.deadlocked: + err += ( + '---------------------------------------------------\n' + 'BackfillJob is deadlocked.') + deadlocked_depends_on_past = any( + t.are_dependencies_met( + dep_context=DepContext(ignore_depends_on_past=False), + session=session, + verbose=True) != + t.are_dependencies_met( + dep_context=DepContext(ignore_depends_on_past=True), + session=session, + verbose=True) + for t in ti_status.deadlocked) + if deadlocked_depends_on_past: + err += ( + 'Some of the deadlocked tasks were unable to run because ' + 'of "depends_on_past" relationships. Try running the ' + 'backfill with the option ' + '"ignore_first_depends_on_past=True" or passing "-I" at ' + 'the command line.') + err += ' These tasks have succeeded:\n{}\n'.format(ti_status.succeeded) + err += ' These tasks have started:\n{}\n'.format(ti_status.started) + err += ' These tasks have failed:\n{}\n'.format(ti_status.failed) + err += ' These tasks are skipped:\n{}\n'.format(ti_status.skipped) + err += ' These tasks are deadlocked:\n{}\n'.format(ti_status.deadlocked) + + return err + + @provide_session + def _execute_for_run_dates(self, run_dates, ti_status, executor, pickle_id, + start_date, session=None): + """ + Computes the dag runs and their respective task instances for + the given run dates and executes the task instances. + Returns a list of execution dates of the dag runs that were executed. + :param run_dates: Execution dates for dag runs + :type run_dates: list + :param ti_status: internal BackfillJob status structure to tis track progress + :type ti_status: BackfillJob._DagRunTaskStatus + :param executor: the executor to use, it must be previously started + :type executor: BaseExecutor + :param pickle_id: numeric id of the pickled dag, None if not pickled + :type pickle_id: int + :param start_date: backfill start date + :type start_date: datetime + :param session: the current session object + :type session: Session + """ + for next_run_date in run_dates: + dag_run = self._get_dag_run(next_run_date, session=session) + tis_map = self._task_instances_for_dag_run(dag_run, + session=session) + if dag_run is None: + continue + + ti_status.active_runs.append(dag_run) + ti_status.to_run.update(tis_map or {}) + + processed_dag_run_dates = self._process_backfill_task_instances( + ti_status=ti_status, + executor=executor, + pickle_id=pickle_id, + start_date=start_date, + session=session) + + ti_status.executed_dag_run_dates.update(processed_dag_run_dates) + + def _execute(self): + """ + Initializes all components required to run a dag for a specified date range and + calls helper method to execute the tasks. + """ + session = settings.Session() + ti_status = BackfillJob._DagRunTaskStatus() + + start_date = self.bf_start_date + + # Get intervals between the start/end dates, which will turn into dag runs + run_dates = self.dag.get_run_dates(start_date=start_date, + end_date=self.bf_end_date) + if len(run_dates) == 0: + self.log.info("No run dates were found for the given dates and dag interval.") + return + + # picklin' + pickle_id = None + if not self.donot_pickle and self.executor.__class__ not in ( + executors.LocalExecutor, executors.SequentialExecutor): + pickle = models.DagPickle(self.dag) + session.add(pickle) + session.commit() + pickle_id = pickle.id + + executor = self.executor + executor.start() + + ti_status.total_runs = len(run_dates) # total dag runs in backfill + + try: + remaining_dates = ti_status.total_runs + while remaining_dates > 0: + dates_to_process = [run_date for run_date in run_dates + if run_date not in ti_status.executed_dag_run_dates] + + self._execute_for_run_dates(run_dates=dates_to_process, + ti_status=ti_status, + executor=executor, + pickle_id=pickle_id, + start_date=start_date, + session=session) + + remaining_dates = ( + ti_status.total_runs - len(ti_status.executed_dag_run_dates) + ) + err = self._collect_errors(ti_status=ti_status, session=session) + if err: + raise AirflowException(err) + + if remaining_dates > 0: + self.log.info( + "max_active_runs limit for dag %s has been reached " + " - waiting for other dag runs to finish", + self.dag_id + ) + time.sleep(self.delay_on_limit_secs) + finally: + executor.end() + session.commit() + session.close() + + self.log.info("Backfill done. Exiting.") + + +class LocalTaskJob(BaseJob): + + __mapper_args__ = { + 'polymorphic_identity': 'LocalTaskJob' + } + + def __init__( + self, + task_instance, + ignore_all_deps=False, + ignore_depends_on_past=False, + ignore_task_deps=False, + ignore_ti_state=False, + mark_success=False, + pickle_id=None, + pool=None, + *args, **kwargs): + self.task_instance = task_instance + self.ignore_all_deps = ignore_all_deps + self.ignore_depends_on_past = ignore_depends_on_past + self.ignore_task_deps = ignore_task_deps + self.ignore_ti_state = ignore_ti_state + self.pool = pool + self.pickle_id = pickle_id + self.mark_success = mark_success + + # terminating state is used so that a job don't try to + # terminate multiple times + self.terminating = False + + super(LocalTaskJob, self).__init__(*args, **kwargs) + + def _execute(self): + self.task_runner = get_task_runner(self) + + def signal_handler(signum, frame): + """Setting kill signal handler""" + self.log.error("Killing subprocess") + self.on_kill() + raise AirflowException("LocalTaskJob received SIGTERM signal") + signal.signal(signal.SIGTERM, signal_handler) + + if not self.task_instance._check_and_change_state_before_execution( + mark_success=self.mark_success, + ignore_all_deps=self.ignore_all_deps, + ignore_depends_on_past=self.ignore_depends_on_past, + ignore_task_deps=self.ignore_task_deps, + ignore_ti_state=self.ignore_ti_state, + job_id=self.id, + pool=self.pool): + self.log.info("Task is not able to be run") + return + + try: + self.task_runner.start() + + last_heartbeat_time = time.time() + heartbeat_time_limit = conf.getint('scheduler', + 'scheduler_zombie_task_threshold') + while True: + # Monitor the task to see if it's done + return_code = self.task_runner.return_code() + if return_code is not None: + self.log.info("Task exited with return code %s", return_code) + return + + # Periodically heartbeat so that the scheduler doesn't think this + # is a zombie + try: + self.heartbeat() + last_heartbeat_time = time.time() + except OperationalError: + Stats.incr('local_task_job_heartbeat_failure', 1, 1) + self.log.exception( + "Exception while trying to heartbeat! Sleeping for %s seconds", + self.heartrate + ) + time.sleep(self.heartrate) + + # If it's been too long since we've heartbeat, then it's possible that + # the scheduler rescheduled this task, so kill launched processes. + time_since_last_heartbeat = time.time() - last_heartbeat_time + if time_since_last_heartbeat > heartbeat_time_limit: + Stats.incr('local_task_job_prolonged_heartbeat_failure', 1, 1) + self.log.error("Heartbeat time limited exceeded!") + raise AirflowException("Time since last heartbeat({:.2f}s) " + "exceeded limit ({}s)." + .format(time_since_last_heartbeat, + heartbeat_time_limit)) + finally: + self.on_kill() + + def on_kill(self): + self.task_runner.terminate() + self.task_runner.on_finish() + + @provide_session + def heartbeat_callback(self, session=None): + """Self destruct task if state has been moved away from running externally""" + + if self.terminating: + # ensure termination if processes are created later + self.task_runner.terminate() + return + + self.task_instance.refresh_from_db() + ti = self.task_instance + + fqdn = socket.getfqdn() + same_hostname = fqdn == ti.hostname + same_process = ti.pid == os.getpid() + + if ti.state == State.RUNNING: + if not same_hostname: + self.log.warning("The recorded hostname {ti.hostname} " + "does not match this instance's hostname " + "{fqdn}".format(**locals())) + raise AirflowException("Hostname of job runner does not match") + elif not same_process: + current_pid = os.getpid() + self.log.warning("Recorded pid {ti.pid} does not match the current pid " + "{current_pid}".format(**locals())) + raise AirflowException("PID of job runner does not match") + elif (self.task_runner.return_code() is None + and hasattr(self.task_runner, 'process')): + self.log.warning( + "State of this instance has been externally set to %s. Taking the poison pill.", + ti.state + ) + self.task_runner.terminate() + self.terminating = True diff --git a/airflow/logging_config.py b/airflow/logging_config.py new file mode 100644 index 0000000000000..d8c288adc0380 --- /dev/null +++ b/airflow/logging_config.py @@ -0,0 +1,64 @@ +# -*- coding: utf-8 -*- +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +import logging +import sys +from logging.config import dictConfig + +from airflow import configuration as conf +from airflow.exceptions import AirflowConfigException +from airflow.utils.module_loading import import_string + +log = logging.getLogger(__name__) + + +def configure_logging(): + logging_class_path = '' + try: + logging_class_path = conf.get('core', 'logging_config_class') + except AirflowConfigException: + log.debug('Could not find key logging_config_class in config') + + if logging_class_path: + try: + logging_config = import_string(logging_class_path) + + # Make sure that the variable is in scope + assert (isinstance(logging_config, dict)) + + log.info( + 'Successfully imported user-defined logging config from %s', + logging_class_path + ) + except Exception: + # Import default logging configurations. + raise ImportError( + 'Unable to load custom logging from {}'.format(logging_class_path) + ) + else: + from airflow.config_templates.airflow_local_settings import ( + DEFAULT_LOGGING_CONFIG as logging_config + ) + log.debug('Unable to load custom logging, using default config instead') + + try: + # Try to init logging + dictConfig(logging_config) + except ValueError as e: + log.warning('Unable to load the config, contains a configuration error.') + # When there is an error in the config, escalate the exception + # otherwise Airflow would silently fall back on the default config + raise e + + return logging_config diff --git a/airflow/macros/__init__.py b/airflow/macros/__init__.py new file mode 100644 index 0000000000000..6f80c04c5e8e6 --- /dev/null +++ b/airflow/macros/__init__.py @@ -0,0 +1,87 @@ +# -*- coding: utf-8 -*- +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from __future__ import absolute_import +from datetime import datetime, timedelta +import dateutil +import time +from . import hive +import uuid + + +def ds_add(ds, days): + """ + Add or subtract days from a YYYY-MM-DD + + :param ds: anchor date in ``YYYY-MM-DD`` format to add to + :type ds: str + :param days: number of days to add to the ds, you can use negative values + :type days: int + + >>> ds_add('2015-01-01', 5) + '2015-01-06' + >>> ds_add('2015-01-06', -5) + '2015-01-01' + """ + + ds = datetime.strptime(ds, '%Y-%m-%d') + if days: + ds = ds + timedelta(days) + return ds.isoformat()[:10] + + +def ds_format(ds, input_format, output_format): + """ + Takes an input string and outputs another string + as specified in the output format + + :param ds: input string which contains a date + :type ds: str + :param input_format: input string format. E.g. %Y-%m-%d + :type input_format: str + :param output_format: output string format E.g. %Y-%m-%d + :type output_format: str + + >>> ds_format('2015-01-01', "%Y-%m-%d", "%m-%d-%y") + '01-01-15' + >>> ds_format('1/5/2015', "%m/%d/%Y", "%Y-%m-%d") + '2015-01-05' + """ + return datetime.strptime(ds, input_format).strftime(output_format) + + +def _integrate_plugins(): + """Integrate plugins to the context""" + import sys + from airflow.plugins_manager import macros_modules + for macros_module in macros_modules: + sys.modules[macros_module.__name__] = macros_module + globals()[macros_module._name] = macros_module + + ########################################################## + # TODO FIXME Remove in Airflow 2.0 + + import os as _os + if not _os.environ.get('AIRFLOW_USE_NEW_IMPORTS', False): + from zope.deprecation import deprecated as _deprecated + for _macro in macros_module._objects: + macro_name = _macro.__name__ + globals()[macro_name] = _macro + _deprecated( + macro_name, + "Importing plugin macro '{i}' directly from " + "'airflow.macros' has been deprecated. Please " + "import from 'airflow.macros.[plugin_module]' " + "instead. Support for direct imports will be dropped " + "entirely in Airflow 2.0.".format(i=macro_name)) diff --git a/airflow/macros/hive.py b/airflow/macros/hive.py new file mode 100644 index 0000000000000..c68c2939c7c96 --- /dev/null +++ b/airflow/macros/hive.py @@ -0,0 +1,110 @@ +# -*- coding: utf-8 -*- +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import datetime + + +def max_partition( + table, schema="default", field=None, filter=None, + metastore_conn_id='metastore_default'): + ''' + Gets the max partition for a table. + + :param schema: The hive schema the table lives in + :type schema: string + :param table: The hive table you are interested in, supports the dot + notation as in "my_database.my_table", if a dot is found, + the schema param is disregarded + :type table: string + :param hive_conn_id: The hive connection you are interested in. + If your default is set you don't need to use this parameter. + :type hive_conn_id: string + :param filter: filter on a subset of partition as in + `sub_part='specific_value'` + :type filter: string + :param field: the field to get the max value from. If there's only + one partition field, this will be inferred + + >>> max_partition('airflow.static_babynames_partitioned') + '2015-01-01' + ''' + from airflow.hooks.hive_hooks import HiveMetastoreHook + if '.' in table: + schema, table = table.split('.') + hh = HiveMetastoreHook(metastore_conn_id=metastore_conn_id) + return hh.max_partition( + schema=schema, table_name=table, field=field, filter=filter) + + +def _closest_date(target_dt, date_list, before_target=None): + ''' + This function finds the date in a list closest to the target date. + An optional parameter can be given to get the closest before or after. + + :param target_dt: The target date + :type target_dt: datetime.date + :param date_list: The list of dates to search + :type date_list: datetime.date list + :param before_target: closest before or after the target + :type before_target: bool or None + :returns: The closest date + :rtype: datetime.date or None + ''' + fb = lambda d: target_dt - d if d <= target_dt else datetime.timedelta.max + fa = lambda d: d - target_dt if d >= target_dt else datetime.timedelta.max + fnone = lambda d: target_dt - d if d < target_dt else d - target_dt + if before_target is None: + return min(date_list, key=fnone).date() + if before_target: + return min(date_list, key=fb).date() + else: + return min(date_list, key=fa).date() + + +def closest_ds_partition( + table, ds, before=True, schema="default", + metastore_conn_id='metastore_default'): + ''' + This function finds the date in a list closest to the target date. + An optional parameter can be given to get the closest before or after. + + :param table: A hive table name + :type table: str + :param ds: A datestamp ``%Y-%m-%d`` e.g. ``yyyy-mm-dd`` + :type ds: datetime.date list + :param before: closest before (True), after (False) or either side of ds + :type before: bool or None + :returns: The closest date + :rtype: str or None + + >>> tbl = 'airflow.static_babynames_partitioned' + >>> closest_ds_partition(tbl, '2015-01-02') + '2015-01-01' + ''' + from airflow.hooks.hive_hooks import HiveMetastoreHook + if '.' in table: + schema, table = table.split('.') + hh = HiveMetastoreHook(metastore_conn_id=metastore_conn_id) + partitions = hh.get_partitions(schema=schema, table_name=table) + if not partitions: + return None + part_vals = [list(p.values())[0] for p in partitions] + if ds in part_vals: + return ds + else: + parts = [datetime.datetime.strptime(pv, '%Y-%m-%d') + for pv in part_vals] + target_dt = datetime.datetime.strptime(ds, '%Y-%m-%d') + closest_ds = _closest_date(target_dt, parts, before_target=before) + return closest_ds.isoformat() diff --git a/airflow/migrations/__init__.py b/airflow/migrations/__init__.py new file mode 100644 index 0000000000000..c82f5790fe906 --- /dev/null +++ b/airflow/migrations/__init__.py @@ -0,0 +1,14 @@ +# -*- coding: utf-8 -*- +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + diff --git a/airflow/migrations/env.py b/airflow/migrations/env.py new file mode 100644 index 0000000000000..8d5e55e98a312 --- /dev/null +++ b/airflow/migrations/env.py @@ -0,0 +1,86 @@ +# -*- coding: utf-8 -*- +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from __future__ import with_statement +from alembic import context +from logging.config import fileConfig + +from airflow import settings +from airflow.jobs import models + +# this is the Alembic Config object, which provides +# access to the values within the .ini file in use. +config = context.config + +# Interpret the config file for Python logging. +# This line sets up loggers basically. +fileConfig(config.config_file_name) + +# add your model's MetaData object here +# for 'autogenerate' support +# from myapp import mymodel +# target_metadata = mymodel.Base.metadata +target_metadata = models.Base.metadata + +# other values from the config, defined by the needs of env.py, +# can be acquired: +# my_important_option = config.get_main_option("my_important_option") +# ... etc. + +COMPARE_TYPE = False + + +def run_migrations_offline(): + """Run migrations in 'offline' mode. + + This configures the context with just a URL + and not an Engine, though an Engine is acceptable + here as well. By skipping the Engine creation + we don't even need a DBAPI to be available. + + Calls to context.execute() here emit the given string to the + script output. + + """ + context.configure( + url=settings.SQL_ALCHEMY_CONN, target_metadata=target_metadata, + literal_binds=True, compare_type=COMPARE_TYPE) + + with context.begin_transaction(): + context.run_migrations() + + +def run_migrations_online(): + """Run migrations in 'online' mode. + + In this scenario we need to create an Engine + and associate a connection with the context. + + """ + connectable = settings.engine + + with connectable.connect() as connection: + context.configure( + connection=connection, + target_metadata=target_metadata, + compare_type=COMPARE_TYPE, + ) + + with context.begin_transaction(): + context.run_migrations() + +if context.is_offline_mode(): + run_migrations_offline() +else: + run_migrations_online() diff --git a/airflow/migrations/script.py.mako b/airflow/migrations/script.py.mako new file mode 100644 index 0000000000000..d0fba891acdd2 --- /dev/null +++ b/airflow/migrations/script.py.mako @@ -0,0 +1,37 @@ +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""${message} + +Revision ID: ${up_revision} +Revises: ${down_revision | comma,n} +Create Date: ${create_date} + +""" + +# revision identifiers, used by Alembic. +revision = ${repr(up_revision)} +down_revision = ${repr(down_revision)} +branch_labels = ${repr(branch_labels)} +depends_on = ${repr(depends_on)} + +from alembic import op +import sqlalchemy as sa +${imports if imports else ""} + +def upgrade(): + ${upgrades if upgrades else "pass"} + + +def downgrade(): + ${downgrades if downgrades else "pass"} diff --git a/airflow/migrations/versions/127d2bf2dfa7_add_dag_id_state_index_on_dag_run_table.py b/airflow/migrations/versions/127d2bf2dfa7_add_dag_id_state_index_on_dag_run_table.py new file mode 100644 index 0000000000000..a456daa7b0eab --- /dev/null +++ b/airflow/migrations/versions/127d2bf2dfa7_add_dag_id_state_index_on_dag_run_table.py @@ -0,0 +1,37 @@ +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Add dag_id/state index on dag_run table + +Revision ID: 127d2bf2dfa7 +Revises: 5e7d17757c7a +Create Date: 2017-01-25 11:43:51.635667 + +""" + +# revision identifiers, used by Alembic. +revision = '127d2bf2dfa7' +down_revision = '5e7d17757c7a' +branch_labels = None +depends_on = None + +from alembic import op +import sqlalchemy as sa + +def upgrade(): + op.create_index('dag_id_state', 'dag_run', ['dag_id', 'state'], unique=False) + + +def downgrade(): + op.drop_index('dag_id_state', table_name='dag_run') + diff --git a/airflow/migrations/versions/13eb55f81627_for_compatibility.py b/airflow/migrations/versions/13eb55f81627_for_compatibility.py new file mode 100644 index 0000000000000..f0ac907123e6d --- /dev/null +++ b/airflow/migrations/versions/13eb55f81627_for_compatibility.py @@ -0,0 +1,35 @@ +# -*- coding: utf-8 -*- +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""maintain history for compatibility with earlier migrations + +Revision ID: 13eb55f81627 +Revises: 1507a7289a2f +Create Date: 2015-08-23 05:12:49.732174 + +""" + +# revision identifiers, used by Alembic. +revision = '13eb55f81627' +down_revision = '1507a7289a2f' +branch_labels = None +depends_on = None + + +def upgrade(): + pass + + +def downgrade(): + pass diff --git a/airflow/migrations/versions/1507a7289a2f_create_is_encrypted.py b/airflow/migrations/versions/1507a7289a2f_create_is_encrypted.py new file mode 100644 index 0000000000000..213ceacc408ae --- /dev/null +++ b/airflow/migrations/versions/1507a7289a2f_create_is_encrypted.py @@ -0,0 +1,66 @@ +# -*- coding: utf-8 -*- +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""create is_encrypted + +Revision ID: 1507a7289a2f +Revises: e3a246e0dc1 +Create Date: 2015-08-18 18:57:51.927315 + +""" + +# revision identifiers, used by Alembic. +revision = '1507a7289a2f' +down_revision = 'e3a246e0dc1' +branch_labels = None +depends_on = None + +from alembic import op +import sqlalchemy as sa +from sqlalchemy.engine.reflection import Inspector + +connectionhelper = sa.Table( + 'connection', + sa.MetaData(), + sa.Column('id', sa.Integer, primary_key=True), + sa.Column('is_encrypted') +) + + +def upgrade(): + # first check if the user already has this done. This should only be + # true for users who are upgrading from a previous version of Airflow + # that predates Alembic integration + conn = op.get_bind() + inspector = Inspector.from_engine(conn) + + # this will only be true if 'connection' already exists in the db, + # but not if alembic created it in a previous migration + if 'connection' in inspector.get_table_names(): + col_names = [c['name'] for c in inspector.get_columns('connection')] + if 'is_encrypted' in col_names: + return + + op.add_column( + 'connection', + sa.Column('is_encrypted', sa.Boolean, unique=False, default=False)) + + conn = op.get_bind() + conn.execute( + connectionhelper.update().values(is_encrypted=False) + ) + + +def downgrade(): + op.drop_column('connection', 'is_encrypted') diff --git a/airflow/migrations/versions/1968acfc09e3_add_is_encrypted_column_to_variable_.py b/airflow/migrations/versions/1968acfc09e3_add_is_encrypted_column_to_variable_.py new file mode 100644 index 0000000000000..c99434ecb593c --- /dev/null +++ b/airflow/migrations/versions/1968acfc09e3_add_is_encrypted_column_to_variable_.py @@ -0,0 +1,38 @@ +# -*- coding: utf-8 -*- +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""add is_encrypted column to variable table + +Revision ID: 1968acfc09e3 +Revises: bba5a7cfc896 +Create Date: 2016-02-02 17:20:55.692295 + +""" + +# revision identifiers, used by Alembic. +revision = '1968acfc09e3' +down_revision = 'bba5a7cfc896' +branch_labels = None +depends_on = None + +from alembic import op +import sqlalchemy as sa + + +def upgrade(): + op.add_column('variable', sa.Column('is_encrypted', sa.Boolean,default=False)) + + +def downgrade(): + op.drop_column('variable', 'is_encrypted') diff --git a/airflow/migrations/versions/1b38cef5b76e_add_dagrun.py b/airflow/migrations/versions/1b38cef5b76e_add_dagrun.py new file mode 100644 index 0000000000000..7da9ee7f9893e --- /dev/null +++ b/airflow/migrations/versions/1b38cef5b76e_add_dagrun.py @@ -0,0 +1,49 @@ +# -*- coding: utf-8 -*- +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""add dagrun + +Revision ID: 1b38cef5b76e +Revises: 52d714495f0 +Create Date: 2015-10-27 08:31:48.475140 + +""" + +# revision identifiers, used by Alembic. +revision = '1b38cef5b76e' +down_revision = '502898887f84' +branch_labels = None +depends_on = None + +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects import mysql + + +def upgrade(): + op.create_table('dag_run', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('dag_id', sa.String(length=250), nullable=True), + sa.Column('execution_date', sa.DateTime(), nullable=True), + sa.Column('state', sa.String(length=50), nullable=True), + sa.Column('run_id', sa.String(length=250), nullable=True), + sa.Column('external_trigger', sa.Boolean(), nullable=True), + sa.PrimaryKeyConstraint('id'), + sa.UniqueConstraint('dag_id', 'execution_date'), + sa.UniqueConstraint('dag_id', 'run_id'), + ) + + +def downgrade(): + op.drop_table('dag_run') diff --git a/airflow/migrations/versions/211e584da130_add_ti_state_index.py b/airflow/migrations/versions/211e584da130_add_ti_state_index.py new file mode 100644 index 0000000000000..ef6c39519490f --- /dev/null +++ b/airflow/migrations/versions/211e584da130_add_ti_state_index.py @@ -0,0 +1,38 @@ +# -*- coding: utf-8 -*- +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""add TI state index + +Revision ID: 211e584da130 +Revises: 2e82aab8ef20 +Create Date: 2016-06-30 10:54:24.323588 + +""" + +# revision identifiers, used by Alembic. +revision = '211e584da130' +down_revision = '2e82aab8ef20' +branch_labels = None +depends_on = None + +from alembic import op +import sqlalchemy as sa + + +def upgrade(): + op.create_index('ti_state', 'task_instance', ['state'], unique=False) + + +def downgrade(): + op.drop_index('ti_state', table_name='task_instance') diff --git a/airflow/migrations/versions/2e541a1dcfed_task_duration.py b/airflow/migrations/versions/2e541a1dcfed_task_duration.py new file mode 100644 index 0000000000000..c21535cc97553 --- /dev/null +++ b/airflow/migrations/versions/2e541a1dcfed_task_duration.py @@ -0,0 +1,44 @@ +# -*- coding: utf-8 -*- +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""task_duration + +Revision ID: 2e541a1dcfed +Revises: 1b38cef5b76e +Create Date: 2015-10-28 20:38:41.266143 + +""" + +# revision identifiers, used by Alembic. +revision = '2e541a1dcfed' +down_revision = '1b38cef5b76e' +branch_labels = None +depends_on = None + +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects import mysql + + +def upgrade(): + # use batch_alter_table to support SQLite workaround + with op.batch_alter_table("task_instance") as batch_op: + batch_op.alter_column('duration', + existing_type=mysql.INTEGER(display_width=11), + type_=sa.Float(), + existing_nullable=True) + + +def downgrade(): + pass diff --git a/airflow/migrations/versions/2e82aab8ef20_rename_user_table.py b/airflow/migrations/versions/2e82aab8ef20_rename_user_table.py new file mode 100644 index 0000000000000..6fb8bc272d4ab --- /dev/null +++ b/airflow/migrations/versions/2e82aab8ef20_rename_user_table.py @@ -0,0 +1,39 @@ +# -*- coding: utf-8 -*- +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""rename user table + +Revision ID: 2e82aab8ef20 +Revises: 1968acfc09e3 +Create Date: 2016-04-02 19:28:15.211915 + +""" + +# revision identifiers, used by Alembic. +revision = '2e82aab8ef20' +down_revision = '1968acfc09e3' +branch_labels = None +depends_on = None + +from alembic import op +import sqlalchemy as sa + + +def upgrade(): + op.rename_table('user', 'users') + + +def downgrade(): + op.rename_table('users', 'user') + diff --git a/airflow/migrations/versions/338e90f54d61_more_logging_into_task_isntance.py b/airflow/migrations/versions/338e90f54d61_more_logging_into_task_isntance.py new file mode 100644 index 0000000000000..51eef0cd3c9e0 --- /dev/null +++ b/airflow/migrations/versions/338e90f54d61_more_logging_into_task_isntance.py @@ -0,0 +1,44 @@ +# -*- coding: utf-8 -*- +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""More logging into task_isntance + +Revision ID: 338e90f54d61 +Revises: 13eb55f81627 +Create Date: 2015-08-25 06:09:20.460147 + +""" + +# revision identifiers, used by Alembic. +revision = '338e90f54d61' +down_revision = '13eb55f81627' +branch_labels = None +depends_on = None + +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects import mysql + + +def upgrade(): + ### commands auto generated by Alembic - please adjust! ### + op.add_column('task_instance', sa.Column('operator', sa.String(length=1000), nullable=True)) + op.add_column('task_instance', sa.Column('queued_dttm', sa.DateTime(), nullable=True)) + + +def downgrade(): + ### commands auto generated by Alembic - please adjust! ### + op.drop_column('task_instance', 'queued_dttm') + op.drop_column('task_instance', 'operator') + ### end Alembic commands ### diff --git a/airflow/migrations/versions/40e67319e3a9_dagrun_config.py b/airflow/migrations/versions/40e67319e3a9_dagrun_config.py new file mode 100644 index 0000000000000..e01503168ab2b --- /dev/null +++ b/airflow/migrations/versions/40e67319e3a9_dagrun_config.py @@ -0,0 +1,39 @@ +# -*- coding: utf-8 -*- +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""dagrun_config + +Revision ID: 40e67319e3a9 +Revises: 2e541a1dcfed +Create Date: 2015-10-29 08:36:31.726728 + +""" + +# revision identifiers, used by Alembic. +revision = '40e67319e3a9' +down_revision = '2e541a1dcfed' +branch_labels = None +depends_on = None + +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects import mysql + + +def upgrade(): + op.add_column('dag_run', sa.Column('conf', sa.PickleType(), nullable=True)) + + +def downgrade(): + op.drop_column('dag_run', 'conf') diff --git a/airflow/migrations/versions/4446e08588_dagrun_start_end.py b/airflow/migrations/versions/4446e08588_dagrun_start_end.py new file mode 100644 index 0000000000000..0ec143573ab44 --- /dev/null +++ b/airflow/migrations/versions/4446e08588_dagrun_start_end.py @@ -0,0 +1,40 @@ +# -*- coding: utf-8 -*- +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""dagrun start end + +Revision ID: 4446e08588 +Revises: 561833c1c74b +Create Date: 2015-12-10 11:26:18.439223 + +""" + +# revision identifiers, used by Alembic. +revision = '4446e08588' +down_revision = '561833c1c74b' +branch_labels = None +depends_on = None + +from alembic import op +import sqlalchemy as sa + + +def upgrade(): + op.add_column('dag_run', sa.Column('end_date', sa.DateTime(), nullable=True)) + op.add_column('dag_run', sa.Column('start_date', sa.DateTime(), nullable=True)) + + +def downgrade(): + op.drop_column('dag_run', 'start_date') + op.drop_column('dag_run', 'end_date') diff --git a/airflow/migrations/versions/4addfa1236f1_add_fractional_seconds_to_mysql_tables.py b/airflow/migrations/versions/4addfa1236f1_add_fractional_seconds_to_mysql_tables.py new file mode 100644 index 0000000000000..c1c6de3611a8b --- /dev/null +++ b/airflow/migrations/versions/4addfa1236f1_add_fractional_seconds_to_mysql_tables.py @@ -0,0 +1,111 @@ +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Add fractional seconds to mysql tables + +Revision ID: 4addfa1236f1 +Revises: f2ca10b85618 +Create Date: 2016-09-11 13:39:18.592072 + +""" + +# revision identifiers, used by Alembic. +revision = '4addfa1236f1' +down_revision = 'f2ca10b85618' +branch_labels = None +depends_on = None + +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects import mysql +from alembic import context + + +def upgrade(): + if context.config.get_main_option('sqlalchemy.url').startswith('mysql'): + op.alter_column(table_name='dag', column_name='last_scheduler_run', type_=mysql.DATETIME(fsp=6)) + op.alter_column(table_name='dag', column_name='last_pickled', type_=mysql.DATETIME(fsp=6)) + op.alter_column(table_name='dag', column_name='last_expired', type_=mysql.DATETIME(fsp=6)) + + op.alter_column(table_name='dag_pickle', column_name='created_dttm', type_=mysql.DATETIME(fsp=6)) + + op.alter_column(table_name='dag_run', column_name='execution_date', type_=mysql.DATETIME(fsp=6)) + op.alter_column(table_name='dag_run', column_name='start_date', type_=mysql.DATETIME(fsp=6)) + op.alter_column(table_name='dag_run', column_name='end_date', type_=mysql.DATETIME(fsp=6)) + + op.alter_column(table_name='import_error', column_name='timestamp', type_=mysql.DATETIME(fsp=6)) + + op.alter_column(table_name='job', column_name='start_date', type_=mysql.DATETIME(fsp=6)) + op.alter_column(table_name='job', column_name='end_date', type_=mysql.DATETIME(fsp=6)) + op.alter_column(table_name='job', column_name='latest_heartbeat', type_=mysql.DATETIME(fsp=6)) + + op.alter_column(table_name='known_event', column_name='start_date', type_=mysql.DATETIME(fsp=6)) + op.alter_column(table_name='known_event', column_name='end_date', type_=mysql.DATETIME(fsp=6)) + + op.alter_column(table_name='log', column_name='dttm', type_=mysql.DATETIME(fsp=6)) + op.alter_column(table_name='log', column_name='execution_date', type_=mysql.DATETIME(fsp=6)) + + op.alter_column(table_name='sla_miss', column_name='execution_date', type_=mysql.DATETIME(fsp=6), nullable=False) + op.alter_column(table_name='sla_miss', column_name='timestamp', type_=mysql.DATETIME(fsp=6)) + + op.alter_column(table_name='task_fail', column_name='execution_date', type_=mysql.DATETIME(fsp=6)) + op.alter_column(table_name='task_fail', column_name='start_date', type_=mysql.DATETIME(fsp=6)) + op.alter_column(table_name='task_fail', column_name='end_date', type_=mysql.DATETIME(fsp=6)) + + op.alter_column(table_name='task_instance', column_name='execution_date', type_=mysql.DATETIME(fsp=6), nullable=False) + op.alter_column(table_name='task_instance', column_name='start_date', type_=mysql.DATETIME(fsp=6)) + op.alter_column(table_name='task_instance', column_name='end_date', type_=mysql.DATETIME(fsp=6)) + op.alter_column(table_name='task_instance', column_name='queued_dttm', type_=mysql.DATETIME(fsp=6)) + + op.alter_column(table_name='xcom', column_name='timestamp', type_=mysql.DATETIME(fsp=6)) + op.alter_column(table_name='xcom', column_name='execution_date', type_=mysql.DATETIME(fsp=6)) + + +def downgrade(): + if context.config.get_main_option('sqlalchemy.url').startswith('mysql'): + op.alter_column(table_name='dag', column_name='last_scheduler_run', type_=mysql.DATETIME()) + op.alter_column(table_name='dag', column_name='last_pickled', type_=mysql.DATETIME()) + op.alter_column(table_name='dag', column_name='last_expired', type_=mysql.DATETIME()) + + op.alter_column(table_name='dag_pickle', column_name='created_dttm', type_=mysql.DATETIME()) + + op.alter_column(table_name='dag_run', column_name='execution_date', type_=mysql.DATETIME()) + op.alter_column(table_name='dag_run', column_name='start_date', type_=mysql.DATETIME()) + op.alter_column(table_name='dag_run', column_name='end_date', type_=mysql.DATETIME()) + + op.alter_column(table_name='import_error', column_name='timestamp', type_=mysql.DATETIME()) + + op.alter_column(table_name='job', column_name='start_date', type_=mysql.DATETIME()) + op.alter_column(table_name='job', column_name='end_date', type_=mysql.DATETIME()) + op.alter_column(table_name='job', column_name='latest_heartbeat', type_=mysql.DATETIME()) + + op.alter_column(table_name='known_event', column_name='start_date', type_=mysql.DATETIME()) + op.alter_column(table_name='known_event', column_name='end_date', type_=mysql.DATETIME()) + + op.alter_column(table_name='log', column_name='dttm', type_=mysql.DATETIME()) + op.alter_column(table_name='log', column_name='execution_date', type_=mysql.DATETIME()) + + op.alter_column(table_name='sla_miss', column_name='execution_date', type_=mysql.DATETIME(), nullable=False) + op.alter_column(table_name='sla_miss', column_name='timestamp', type_=mysql.DATETIME()) + + op.alter_column(table_name='task_fail', column_name='execution_date', type_=mysql.DATETIME()) + op.alter_column(table_name='task_fail', column_name='start_date', type_=mysql.DATETIME()) + op.alter_column(table_name='task_fail', column_name='end_date', type_=mysql.DATETIME()) + + op.alter_column(table_name='task_instance', column_name='execution_date', type_=mysql.DATETIME(), nullable=False) + op.alter_column(table_name='task_instance', column_name='start_date', type_=mysql.DATETIME()) + op.alter_column(table_name='task_instance', column_name='end_date', type_=mysql.DATETIME()) + op.alter_column(table_name='task_instance', column_name='queued_dttm', type_=mysql.DATETIME()) + + op.alter_column(table_name='xcom', column_name='timestamp', type_=mysql.DATETIME()) + op.alter_column(table_name='xcom', column_name='execution_date', type_=mysql.DATETIME()) diff --git a/airflow/migrations/versions/502898887f84_adding_extra_to_log.py b/airflow/migrations/versions/502898887f84_adding_extra_to_log.py new file mode 100644 index 0000000000000..1715d008aada7 --- /dev/null +++ b/airflow/migrations/versions/502898887f84_adding_extra_to_log.py @@ -0,0 +1,38 @@ +# -*- coding: utf-8 -*- +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Adding extra to Log + +Revision ID: 502898887f84 +Revises: 52d714495f0 +Create Date: 2015-11-03 22:50:49.794097 + +""" + +# revision identifiers, used by Alembic. +revision = '502898887f84' +down_revision = '52d714495f0' +branch_labels = None +depends_on = None + +from alembic import op +import sqlalchemy as sa + + +def upgrade(): + op.add_column('log', sa.Column('extra', sa.Text(), nullable=True)) + + +def downgrade(): + op.drop_column('log', 'extra') diff --git a/airflow/migrations/versions/52d714495f0_job_id_indices.py b/airflow/migrations/versions/52d714495f0_job_id_indices.py new file mode 100644 index 0000000000000..0c418b56746d8 --- /dev/null +++ b/airflow/migrations/versions/52d714495f0_job_id_indices.py @@ -0,0 +1,39 @@ +# -*- coding: utf-8 -*- +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""job_id indices + +Revision ID: 52d714495f0 +Revises: 338e90f54d61 +Create Date: 2015-10-20 03:17:01.962542 + +""" + +# revision identifiers, used by Alembic. +revision = '52d714495f0' +down_revision = '338e90f54d61' +branch_labels = None +depends_on = None + +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects import mysql + + +def upgrade(): + op.create_index('idx_job_state_heartbeat', 'job', ['state', 'latest_heartbeat'], unique=False) + + +def downgrade(): + op.drop_index('idx_job_state_heartbeat', table_name='job') diff --git a/airflow/migrations/versions/561833c1c74b_add_password_column_to_user.py b/airflow/migrations/versions/561833c1c74b_add_password_column_to_user.py new file mode 100644 index 0000000000000..71f9e067b509e --- /dev/null +++ b/airflow/migrations/versions/561833c1c74b_add_password_column_to_user.py @@ -0,0 +1,38 @@ +# -*- coding: utf-8 -*- +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""add password column to user + +Revision ID: 561833c1c74b +Revises: 40e67319e3a9 +Create Date: 2015-11-30 06:51:25.872557 + +""" + +# revision identifiers, used by Alembic. +revision = '561833c1c74b' +down_revision = '40e67319e3a9' +branch_labels = None +depends_on = None + +from alembic import op +import sqlalchemy as sa + + +def upgrade(): + op.add_column('user', sa.Column('password', sa.String(255))) + + +def downgrade(): + op.drop_column('user', 'password') diff --git a/airflow/migrations/versions/5e7d17757c7a_add_pid_field_to_taskinstance.py b/airflow/migrations/versions/5e7d17757c7a_add_pid_field_to_taskinstance.py new file mode 100644 index 0000000000000..71468642da3d1 --- /dev/null +++ b/airflow/migrations/versions/5e7d17757c7a_add_pid_field_to_taskinstance.py @@ -0,0 +1,37 @@ +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""add pid field to TaskInstance + +Revision ID: 5e7d17757c7a +Revises: 8504051e801b +Create Date: 2016-12-07 15:51:37.119478 + +""" + +# revision identifiers, used by Alembic. +revision = '5e7d17757c7a' +down_revision = '8504051e801b' +branch_labels = None +depends_on = None + +from alembic import op +import sqlalchemy as sa + + +def upgrade(): + op.add_column('task_instance', sa.Column('pid', sa.Integer)) + + +def downgrade(): + op.drop_column('task_instance', 'pid') diff --git a/airflow/migrations/versions/64de9cddf6c9_add_task_fails_journal_table.py b/airflow/migrations/versions/64de9cddf6c9_add_task_fails_journal_table.py new file mode 100644 index 0000000000000..3d5c7a42b248a --- /dev/null +++ b/airflow/migrations/versions/64de9cddf6c9_add_task_fails_journal_table.py @@ -0,0 +1,46 @@ +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""add task fails journal table + +Revision ID: 64de9cddf6c9 +Revises: 211e584da130 +Create Date: 2016-08-03 14:02:59.203021 + +""" + +# revision identifiers, used by Alembic. +revision = '64de9cddf6c9' +down_revision = '211e584da130' +branch_labels = None +depends_on = None + +from alembic import op +import sqlalchemy as sa + + +def upgrade(): + op.create_table( + 'task_fail', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('task_id', sa.String(length=250), nullable=False), + sa.Column('dag_id', sa.String(length=250), nullable=False), + sa.Column('execution_date', sa.DateTime(), nullable=False), + sa.Column('start_date', sa.DateTime(), nullable=True), + sa.Column('end_date', sa.DateTime(), nullable=True), + sa.Column('duration', sa.Integer(), nullable=True), + sa.PrimaryKeyConstraint('id'), + ) + +def downgrade(): + op.drop_table('task_fail') diff --git a/airflow/migrations/versions/8504051e801b_xcom_dag_task_indices.py b/airflow/migrations/versions/8504051e801b_xcom_dag_task_indices.py new file mode 100644 index 0000000000000..99b5653a1c148 --- /dev/null +++ b/airflow/migrations/versions/8504051e801b_xcom_dag_task_indices.py @@ -0,0 +1,38 @@ +# -*- coding: utf-8 -*- +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""xcom dag task indices + +Revision ID: 8504051e801b +Revises: 4addfa1236f1 +Create Date: 2016-11-29 08:13:03.253312 + +""" + +# revision identifiers, used by Alembic. +revision = '8504051e801b' +down_revision = '4addfa1236f1' +branch_labels = None +depends_on = None + +from alembic import op +import sqlalchemy as sa + + +def upgrade(): + op.create_index('idx_xcom_dag_task_date', 'xcom', ['dag_id', 'task_id', 'execution_date'], unique=False) + + +def downgrade(): + op.drop_index('idx_xcom_dag_task_date', table_name='xcom') diff --git a/airflow/migrations/versions/947454bf1dff_add_ti_job_id_index.py b/airflow/migrations/versions/947454bf1dff_add_ti_job_id_index.py new file mode 100644 index 0000000000000..b0817c35a9075 --- /dev/null +++ b/airflow/migrations/versions/947454bf1dff_add_ti_job_id_index.py @@ -0,0 +1,38 @@ +# -*- coding: utf-8 -*- +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""add ti job_id index + +Revision ID: 947454bf1dff +Revises: bdaa763e6c56 +Create Date: 2017-08-15 15:12:13.845074 + +""" + +# revision identifiers, used by Alembic. +revision = '947454bf1dff' +down_revision = 'bdaa763e6c56' +branch_labels = None +depends_on = None + +from alembic import op +import sqlalchemy as sa + + +def upgrade(): + op.create_index('ti_job_id', 'task_instance', ['job_id'], unique=False) + + +def downgrade(): + op.drop_index('ti_job_id', table_name='task_instance') diff --git a/airflow/migrations/versions/__init__.py b/airflow/migrations/versions/__init__.py new file mode 100644 index 0000000000000..c82f5790fe906 --- /dev/null +++ b/airflow/migrations/versions/__init__.py @@ -0,0 +1,14 @@ +# -*- coding: utf-8 -*- +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + diff --git a/airflow/migrations/versions/bba5a7cfc896_add_a_column_to_track_the_encryption_.py b/airflow/migrations/versions/bba5a7cfc896_add_a_column_to_track_the_encryption_.py new file mode 100644 index 0000000000000..7511f79e06247 --- /dev/null +++ b/airflow/migrations/versions/bba5a7cfc896_add_a_column_to_track_the_encryption_.py @@ -0,0 +1,38 @@ +# -*- coding: utf-8 -*- +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Add a column to track the encryption state of the 'Extra' field in connection + +Revision ID: bba5a7cfc896 +Revises: bbc73705a13e +Create Date: 2016-01-29 15:10:32.656425 + +""" + +# revision identifiers, used by Alembic. +revision = 'bba5a7cfc896' +down_revision = 'bbc73705a13e' +branch_labels = None +depends_on = None + +from alembic import op +import sqlalchemy as sa + + +def upgrade(): + op.add_column('connection', sa.Column('is_extra_encrypted', sa.Boolean,default=False)) + + +def downgrade(): + op.drop_column('connection', 'is_extra_encrypted') diff --git a/airflow/migrations/versions/bbc73705a13e_add_notification_sent_column_to_sla_miss.py b/airflow/migrations/versions/bbc73705a13e_add_notification_sent_column_to_sla_miss.py new file mode 100644 index 0000000000000..bc7c3bd904490 --- /dev/null +++ b/airflow/migrations/versions/bbc73705a13e_add_notification_sent_column_to_sla_miss.py @@ -0,0 +1,38 @@ +# -*- coding: utf-8 -*- +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Add notification_sent column to sla_miss + +Revision ID: bbc73705a13e +Revises: 4446e08588 +Create Date: 2016-01-14 18:05:54.871682 + +""" + +# revision identifiers, used by Alembic. +revision = 'bbc73705a13e' +down_revision = '4446e08588' +branch_labels = None +depends_on = None + +from alembic import op +import sqlalchemy as sa + + +def upgrade(): + op.add_column('sla_miss', sa.Column('notification_sent', sa.Boolean,default=False)) + + +def downgrade(): + op.drop_column('sla_miss', 'notification_sent') diff --git a/airflow/migrations/versions/bdaa763e6c56_make_xcom_value_column_a_large_binary.py b/airflow/migrations/versions/bdaa763e6c56_make_xcom_value_column_a_large_binary.py new file mode 100644 index 0000000000000..ea37d7621890e --- /dev/null +++ b/airflow/migrations/versions/bdaa763e6c56_make_xcom_value_column_a_large_binary.py @@ -0,0 +1,45 @@ +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Make xcom value column a large binary + +Revision ID: bdaa763e6c56 +Revises: cc1e65623dc7 +Create Date: 2017-08-14 16:06:31.568971 + +""" + +# revision identifiers, used by Alembic. +revision = 'bdaa763e6c56' +down_revision = 'cc1e65623dc7' +branch_labels = None +depends_on = None + +from alembic import op +import dill +import sqlalchemy as sa + + +def upgrade(): + # There can be data truncation here as LargeBinary can be smaller than the pickle + # type. + + # use batch_alter_table to support SQLite workaround + with op.batch_alter_table("xcom") as batch_op: + batch_op.alter_column('value', type_=sa.LargeBinary()) + + +def downgrade(): + # use batch_alter_table to support SQLite workaround + with op.batch_alter_table("xcom") as batch_op: + batch_op.alter_column('value', type_=sa.PickleType(pickler=dill)) diff --git a/airflow/migrations/versions/cc1e65623dc7_add_max_tries_column_to_task_instance.py b/airflow/migrations/versions/cc1e65623dc7_add_max_tries_column_to_task_instance.py new file mode 100644 index 0000000000000..b151e0cf4c0d3 --- /dev/null +++ b/airflow/migrations/versions/cc1e65623dc7_add_max_tries_column_to_task_instance.py @@ -0,0 +1,109 @@ +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""add max tries column to task instance + +Revision ID: cc1e65623dc7 +Revises: 127d2bf2dfa7 +Create Date: 2017-06-19 16:53:12.851141 + +""" + +# revision identifiers, used by Alembic. +revision = 'cc1e65623dc7' +down_revision = '127d2bf2dfa7' +branch_labels = None +depends_on = None + +from alembic import op +import sqlalchemy as sa +from airflow import settings +from airflow.models import DagBag, TaskInstance +from sqlalchemy.engine.reflection import Inspector + +BATCH_SIZE = 5000 + +def upgrade(): + op.add_column('task_instance', sa.Column('max_tries', sa.Integer, + server_default="-1")) + # Check if table task_instance exist before data migration. This check is + # needed for database that does not create table until migration finishes. + # Checking task_instance table exists prevent the error of querying + # non-existing task_instance table. + connection = op.get_bind() + inspector = Inspector.from_engine(connection) + tables = inspector.get_table_names() + + if 'task_instance' in tables: + # Get current session + sessionmaker = sa.orm.sessionmaker() + session = sessionmaker(bind=connection) + dagbag = DagBag(settings.DAGS_FOLDER) + query = session.query(sa.func.count(TaskInstance.max_tries)).filter( + TaskInstance.max_tries == -1 + ) + # Separate db query in batch to prevent loading entire table + # into memory and cause out of memory error. + while query.scalar(): + tis = session.query(TaskInstance).filter( + TaskInstance.max_tries == -1 + ).limit(BATCH_SIZE).all() + for ti in tis: + dag = dagbag.get_dag(ti.dag_id) + if not dag or not dag.has_task(ti.task_id): + # task_instance table might not have the up-to-date + # information, i.e dag or task might be modified or + # deleted in dagbag but is reflected in task instance + # table. In this case we do not retry the task that can't + # be parsed. + ti.max_tries = ti.try_number + else: + task = dag.get_task(ti.task_id) + ti.max_tries = task.retries + session.merge(ti) + session.commit() + # Commit the current session. + session.commit() + + +def downgrade(): + engine = settings.engine + if engine.dialect.has_table(engine, 'task_instance'): + connection = op.get_bind() + sessionmaker = sa.orm.sessionmaker() + session = sessionmaker(bind=connection) + dagbag = DagBag(settings.DAGS_FOLDER) + query = session.query(sa.func.count(TaskInstance.max_tries)).filter( + TaskInstance.max_tries != -1 + ) + while query.scalar(): + tis = session.query(TaskInstance).filter( + TaskInstance.max_tries != -1 + ).limit(BATCH_SIZE).all() + for ti in tis: + dag = dagbag.get_dag(ti.dag_id) + if not dag or not dag.has_task(ti.task_id): + ti.try_number = 0 + else: + task = dag.get_task(ti.task_id) + # max_tries - try_number is number of times a task instance + # left to retry by itself. So the current try_number should be + # max number of self retry (task.retries) minus number of + # times left for task instance to try the task. + ti.try_number = max(0, task.retries - (ti.max_tries - + ti.try_number)) + ti.max_tries = -1 + session.merge(ti) + session.commit() + session.commit() + op.drop_column('task_instance', 'max_tries') diff --git a/airflow/migrations/versions/d2ae31099d61_increase_text_size_for_mysql.py b/airflow/migrations/versions/d2ae31099d61_increase_text_size_for_mysql.py new file mode 100644 index 0000000000000..1021fdd597506 --- /dev/null +++ b/airflow/migrations/versions/d2ae31099d61_increase_text_size_for_mysql.py @@ -0,0 +1,41 @@ +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Increase text size for MySQL (not relevant for other DBs' text types) + +Revision ID: d2ae31099d61 +Revises: 947454bf1dff +Create Date: 2017-08-18 17:07:16.686130 + +""" + +# revision identifiers, used by Alembic. +revision = 'd2ae31099d61' +down_revision = '947454bf1dff' +branch_labels = None +depends_on = None + +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects import mysql +from alembic import context + + +def upgrade(): + if context.config.get_main_option('sqlalchemy.url').startswith('mysql'): + op.alter_column(table_name='variable', column_name='val', type_=mysql.MEDIUMTEXT) + + +def downgrade(): + if context.config.get_main_option('sqlalchemy.url').startswith('mysql'): + op.alter_column(table_name='variable', column_name='val', type_=mysql.TEXT) diff --git a/airflow/migrations/versions/e3a246e0dc1_current_schema.py b/airflow/migrations/versions/e3a246e0dc1_current_schema.py new file mode 100644 index 0000000000000..c1cb9c6c52d5f --- /dev/null +++ b/airflow/migrations/versions/e3a246e0dc1_current_schema.py @@ -0,0 +1,277 @@ +# -*- coding: utf-8 -*- +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""current schema + +Revision ID: e3a246e0dc1 +Revises: +Create Date: 2015-08-18 16:35:00.883495 + +""" + +# revision identifiers, used by Alembic. +revision = 'e3a246e0dc1' +down_revision = None +branch_labels = None +depends_on = None + +from alembic import op +import sqlalchemy as sa +from sqlalchemy import func +from sqlalchemy.engine.reflection import Inspector + +def upgrade(): + conn = op.get_bind() + inspector = Inspector.from_engine(conn) + tables = inspector.get_table_names() + + if 'connection' not in tables: + op.create_table( + 'connection', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('conn_id', sa.String(length=250), nullable=True), + sa.Column('conn_type', sa.String(length=500), nullable=True), + sa.Column('host', sa.String(length=500), nullable=True), + sa.Column('schema', sa.String(length=500), nullable=True), + sa.Column('login', sa.String(length=500), nullable=True), + sa.Column('password', sa.String(length=500), nullable=True), + sa.Column('port', sa.Integer(), nullable=True), + sa.Column('extra', sa.String(length=5000), nullable=True), + sa.PrimaryKeyConstraint('id') + ) + if 'dag' not in tables: + op.create_table( + 'dag', + sa.Column('dag_id', sa.String(length=250), nullable=False), + sa.Column('is_paused', sa.Boolean(), nullable=True), + sa.Column('is_subdag', sa.Boolean(), nullable=True), + sa.Column('is_active', sa.Boolean(), nullable=True), + sa.Column('last_scheduler_run', sa.DateTime(), nullable=True), + sa.Column('last_pickled', sa.DateTime(), nullable=True), + sa.Column('last_expired', sa.DateTime(), nullable=True), + sa.Column('scheduler_lock', sa.Boolean(), nullable=True), + sa.Column('pickle_id', sa.Integer(), nullable=True), + sa.Column('fileloc', sa.String(length=2000), nullable=True), + sa.Column('owners', sa.String(length=2000), nullable=True), + sa.PrimaryKeyConstraint('dag_id') + ) + if 'dag_pickle' not in tables: + op.create_table( + 'dag_pickle', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('pickle', sa.PickleType(), nullable=True), + sa.Column('created_dttm', sa.DateTime(), nullable=True), + sa.Column('pickle_hash', sa.BigInteger(), nullable=True), + sa.PrimaryKeyConstraint('id') + ) + if 'import_error' not in tables: + op.create_table( + 'import_error', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('timestamp', sa.DateTime(), nullable=True), + sa.Column('filename', sa.String(length=1024), nullable=True), + sa.Column('stacktrace', sa.Text(), nullable=True), + sa.PrimaryKeyConstraint('id') + ) + if 'job' not in tables: + op.create_table( + 'job', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('dag_id', sa.String(length=250), nullable=True), + sa.Column('state', sa.String(length=20), nullable=True), + sa.Column('job_type', sa.String(length=30), nullable=True), + sa.Column('start_date', sa.DateTime(), nullable=True), + sa.Column('end_date', sa.DateTime(), nullable=True), + sa.Column('latest_heartbeat', sa.DateTime(), nullable=True), + sa.Column('executor_class', sa.String(length=500), nullable=True), + sa.Column('hostname', sa.String(length=500), nullable=True), + sa.Column('unixname', sa.String(length=1000), nullable=True), + sa.PrimaryKeyConstraint('id') + ) + op.create_index( + 'job_type_heart', + 'job', + ['job_type', 'latest_heartbeat'], + unique=False + ) + if 'known_event_type' not in tables: + op.create_table( + 'known_event_type', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('know_event_type', sa.String(length=200), nullable=True), + sa.PrimaryKeyConstraint('id') + ) + if 'log' not in tables: + op.create_table( + 'log', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('dttm', sa.DateTime(), nullable=True), + sa.Column('dag_id', sa.String(length=250), nullable=True), + sa.Column('task_id', sa.String(length=250), nullable=True), + sa.Column('event', sa.String(length=30), nullable=True), + sa.Column('execution_date', sa.DateTime(), nullable=True), + sa.Column('owner', sa.String(length=500), nullable=True), + sa.PrimaryKeyConstraint('id') + ) + if 'sla_miss' not in tables: + op.create_table( + 'sla_miss', + sa.Column('task_id', sa.String(length=250), nullable=False), + sa.Column('dag_id', sa.String(length=250), nullable=False), + sa.Column('execution_date', sa.DateTime(), nullable=False), + sa.Column('email_sent', sa.Boolean(), nullable=True), + sa.Column('timestamp', sa.DateTime(), nullable=True), + sa.Column('description', sa.Text(), nullable=True), + sa.PrimaryKeyConstraint('task_id', 'dag_id', 'execution_date') + ) + if 'slot_pool' not in tables: + op.create_table( + 'slot_pool', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('pool', sa.String(length=50), nullable=True), + sa.Column('slots', sa.Integer(), nullable=True), + sa.Column('description', sa.Text(), nullable=True), + sa.PrimaryKeyConstraint('id'), + sa.UniqueConstraint('pool') + ) + if 'task_instance' not in tables: + op.create_table( + 'task_instance', + sa.Column('task_id', sa.String(length=250), nullable=False), + sa.Column('dag_id', sa.String(length=250), nullable=False), + sa.Column('execution_date', sa.DateTime(), nullable=False), + sa.Column('start_date', sa.DateTime(), nullable=True), + sa.Column('end_date', sa.DateTime(), nullable=True), + sa.Column('duration', sa.Integer(), nullable=True), + sa.Column('state', sa.String(length=20), nullable=True), + sa.Column('try_number', sa.Integer(), nullable=True), + sa.Column('hostname', sa.String(length=1000), nullable=True), + sa.Column('unixname', sa.String(length=1000), nullable=True), + sa.Column('job_id', sa.Integer(), nullable=True), + sa.Column('pool', sa.String(length=50), nullable=True), + sa.Column('queue', sa.String(length=50), nullable=True), + sa.Column('priority_weight', sa.Integer(), nullable=True), + sa.PrimaryKeyConstraint('task_id', 'dag_id', 'execution_date') + ) + op.create_index( + 'ti_dag_state', + 'task_instance', + ['dag_id', 'state'], + unique=False + ) + op.create_index( + 'ti_pool', + 'task_instance', + ['pool', 'state', 'priority_weight'], + unique=False + ) + op.create_index( + 'ti_state_lkp', + 'task_instance', + ['dag_id', 'task_id', 'execution_date', 'state'], + unique=False + ) + + if 'user' not in tables: + op.create_table( + 'user', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('username', sa.String(length=250), nullable=True), + sa.Column('email', sa.String(length=500), nullable=True), + sa.PrimaryKeyConstraint('id'), + sa.UniqueConstraint('username') + ) + if 'variable' not in tables: + op.create_table( + 'variable', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('key', sa.String(length=250), nullable=True), + sa.Column('val', sa.Text(), nullable=True), + sa.PrimaryKeyConstraint('id'), + sa.UniqueConstraint('key') + ) + if 'chart' not in tables: + op.create_table( + 'chart', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('label', sa.String(length=200), nullable=True), + sa.Column('conn_id', sa.String(length=250), nullable=False), + sa.Column('user_id', sa.Integer(), nullable=True), + sa.Column('chart_type', sa.String(length=100), nullable=True), + sa.Column('sql_layout', sa.String(length=50), nullable=True), + sa.Column('sql', sa.Text(), nullable=True), + sa.Column('y_log_scale', sa.Boolean(), nullable=True), + sa.Column('show_datatable', sa.Boolean(), nullable=True), + sa.Column('show_sql', sa.Boolean(), nullable=True), + sa.Column('height', sa.Integer(), nullable=True), + sa.Column('default_params', sa.String(length=5000), nullable=True), + sa.Column('x_is_date', sa.Boolean(), nullable=True), + sa.Column('iteration_no', sa.Integer(), nullable=True), + sa.Column('last_modified', sa.DateTime(), nullable=True), + sa.ForeignKeyConstraint(['user_id'], ['user.id'], ), + sa.PrimaryKeyConstraint('id') + ) + if 'known_event' not in tables: + op.create_table( + 'known_event', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('label', sa.String(length=200), nullable=True), + sa.Column('start_date', sa.DateTime(), nullable=True), + sa.Column('end_date', sa.DateTime(), nullable=True), + sa.Column('user_id', sa.Integer(), nullable=True), + sa.Column('known_event_type_id', sa.Integer(), nullable=True), + sa.Column('description', sa.Text(), nullable=True), + sa.ForeignKeyConstraint(['known_event_type_id'], + ['known_event_type.id'], ), + sa.ForeignKeyConstraint(['user_id'], ['user.id'], ), + sa.PrimaryKeyConstraint('id') + ) + if 'xcom' not in tables: + op.create_table( + 'xcom', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('key', sa.String(length=512), nullable=True), + sa.Column('value', sa.PickleType(), nullable=True), + sa.Column( + 'timestamp', + sa.DateTime(), + default=func.now(), + nullable=False), + sa.Column('execution_date', sa.DateTime(), nullable=False), + sa.Column('task_id', sa.String(length=250), nullable=False), + sa.Column('dag_id', sa.String(length=250), nullable=False), + sa.PrimaryKeyConstraint('id') + ) + + +def downgrade(): + op.drop_table('known_event') + op.drop_table('chart') + op.drop_table('variable') + op.drop_table('user') + op.drop_index('ti_state_lkp', table_name='task_instance') + op.drop_index('ti_pool', table_name='task_instance') + op.drop_index('ti_dag_state', table_name='task_instance') + op.drop_table('task_instance') + op.drop_table('slot_pool') + op.drop_table('sla_miss') + op.drop_table('log') + op.drop_table('known_event_type') + op.drop_index('job_type_heart', table_name='job') + op.drop_table('job') + op.drop_table('import_error') + op.drop_table('dag_pickle') + op.drop_table('dag') + op.drop_table('connection') + op.drop_table('xcom') diff --git a/airflow/migrations/versions/f2ca10b85618_add_dag_stats_table.py b/airflow/migrations/versions/f2ca10b85618_add_dag_stats_table.py new file mode 100644 index 0000000000000..dabe812c38edf --- /dev/null +++ b/airflow/migrations/versions/f2ca10b85618_add_dag_stats_table.py @@ -0,0 +1,42 @@ +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""add dag_stats table + +Revision ID: f2ca10b85618 +Revises: 64de9cddf6c9 +Create Date: 2016-07-20 15:08:28.247537 + +""" + +# revision identifiers, used by Alembic. +revision = 'f2ca10b85618' +down_revision = '64de9cddf6c9' +branch_labels = None +depends_on = None + +from alembic import op +import sqlalchemy as sa + + +def upgrade(): + op.create_table('dag_stats', + sa.Column('dag_id', sa.String(length=250), nullable=False), + sa.Column('state', sa.String(length=50), nullable=False), + sa.Column('count', sa.Integer(), nullable=False, default=0), + sa.Column('dirty', sa.Boolean(), nullable=False, default=False), + sa.PrimaryKeyConstraint('dag_id', 'state')) + + +def downgrade(): + op.drop_table('dag_stats') diff --git a/airflow/minihivecluster.py b/airflow/minihivecluster.py new file mode 100644 index 0000000000000..b975f2798fd03 --- /dev/null +++ b/airflow/minihivecluster.py @@ -0,0 +1,46 @@ +# -*- coding: utf-8 -*- +# Copyright (c) 2016 Bolke de Bruin +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may not +# use this file except in compliance with the License. You may obtain a copy of +# the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations under +# the License. +import os +import subprocess +import select +import re + + +class MiniHiveCluster(object): + def __init__(self): + self._minicluster_home = os.environ['MINICLUSTER_HOME'] + self._minicluster_class = "com.ing.minicluster.MiniCluster" + self._start_mini_cluster() + self._is_started() + + def _start_mini_cluster(self): + classpath = os.path.join(self._minicluster_home, "*") + cmd = ["java", "-cp", classpath, self._minicluster_class] + + self.hive = subprocess.Popen(cmd, bufsize=0, stdout=subprocess.PIPE, + stderr=subprocess.PIPE, universal_newlines=True) + + def terminate(self): + self.hive.terminate() + + def _is_started(self): + while self.hive.poll() is None: + rlist, wlist, xlist = select.select([self.hive.stderr, self.hive.stdout], [], []) + for f in rlist: + line = f.readline() + print(line,) + m = re.match(".*Starting ThriftBinaryCLIService", line) + if m: + return True diff --git a/airflow/models.py b/airflow/models.py new file mode 100755 index 0000000000000..e3c52b5b72873 --- /dev/null +++ b/airflow/models.py @@ -0,0 +1,4773 @@ +# -*- coding: utf-8 -*- +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from __future__ import absolute_import +from __future__ import division +from __future__ import print_function +from __future__ import unicode_literals + +from future.standard_library import install_aliases + +install_aliases() +from builtins import str +from builtins import object, bytes +import copy +from collections import namedtuple +from datetime import datetime, timedelta +import dill +import functools +import getpass +import imp +import importlib +import itertools +import zipfile +import jinja2 +import json +import os +import pickle +import re +import signal +import socket +import sys +import textwrap +import traceback +import warnings +import hashlib +from urllib.parse import urlparse + +from sqlalchemy import ( + Column, Integer, String, DateTime, Text, Boolean, ForeignKey, PickleType, + Index, Float, LargeBinary) +from sqlalchemy import func, or_, and_ +from sqlalchemy.ext.declarative import declarative_base, declared_attr +from sqlalchemy.dialects.mysql import LONGTEXT +from sqlalchemy.orm import reconstructor, relationship, synonym + +from croniter import croniter +import six + +from airflow import settings, utils +from airflow.executors import GetDefaultExecutor, LocalExecutor +from airflow import configuration +from airflow.exceptions import AirflowException, AirflowSkipException, AirflowTaskTimeout +from airflow.dag.base_dag import BaseDag, BaseDagBag +from airflow.ti_deps.deps.not_in_retry_period_dep import NotInRetryPeriodDep +from airflow.ti_deps.deps.prev_dagrun_dep import PrevDagrunDep +from airflow.ti_deps.deps.trigger_rule_dep import TriggerRuleDep +from airflow.ti_deps.deps.task_concurrency_dep import TaskConcurrencyDep + +from airflow.ti_deps.dep_context import DepContext, QUEUE_DEPS, RUN_DEPS +from airflow.utils.dates import cron_presets, date_range as utils_date_range +from airflow.utils.db import provide_session +from airflow.utils.decorators import apply_defaults +from airflow.utils.email import send_email +from airflow.utils.helpers import ( + as_tuple, is_container, is_in, validate_key, pprinttable) +from airflow.utils.operator_resources import Resources +from airflow.utils.state import State +from airflow.utils.timeout import timeout +from airflow.utils.trigger_rule import TriggerRule +from airflow.utils.log.logging_mixin import LoggingMixin + +Base = declarative_base() +ID_LEN = 250 +XCOM_RETURN_KEY = 'return_value' + +Stats = settings.Stats + +def get_fernet(): + """ + Deferred load of Fernet key. + + This function could fail either because Cryptography is not installed + or because the Fernet key is invalid. + :return: Fernet object + :raises: AirflowException if there's a problem trying to load Fernet + """ + try: + from cryptography.fernet import Fernet + except: + raise AirflowException('Failed to import Fernet, it may not be installed') + try: + return Fernet(configuration.get('core', 'FERNET_KEY').encode('utf-8')) + except ValueError as ve: + raise AirflowException("Could not create Fernet object: {}".format(ve)) + + +if 'mysql' in settings.SQL_ALCHEMY_CONN: + LongText = LONGTEXT +else: + LongText = Text + +# Used by DAG context_managers +_CONTEXT_MANAGER_DAG = None + + +def clear_task_instances(tis, session, activate_dag_runs=True, dag=None): + """ + Clears a set of task instances, but makes sure the running ones + get killed. + """ + job_ids = [] + for ti in tis: + if ti.state == State.RUNNING: + if ti.job_id: + ti.state = State.SHUTDOWN + job_ids.append(ti.job_id) + else: + task_id = ti.task_id + if dag and dag.has_task(task_id): + task = dag.get_task(task_id) + task_retries = task.retries + ti.max_tries = ti.try_number + task_retries + else: + # Ignore errors when updating max_tries if dag is None or + # task not found in dag since database records could be + # outdated. We make max_tries the maximum value of its + # original max_tries or the current task try number. + ti.max_tries = max(ti.max_tries, ti.try_number) + ti.state = State.NONE + session.merge(ti) + + if job_ids: + from airflow.jobs import BaseJob as BJ + for job in session.query(BJ).filter(BJ.id.in_(job_ids)).all(): + job.state = State.SHUTDOWN + + if activate_dag_runs and tis: + drs = session.query(DagRun).filter( + DagRun.dag_id.in_({ti.dag_id for ti in tis}), + DagRun.execution_date.in_({ti.execution_date for ti in tis}), + ).all() + for dr in drs: + dr.state = State.RUNNING + dr.start_date = datetime.utcnow() + + +class DagBag(BaseDagBag, LoggingMixin): + """ + A dagbag is a collection of dags, parsed out of a folder tree and has high + level configuration settings, like what database to use as a backend and + what executor to use to fire off tasks. This makes it easier to run + distinct environments for say production and development, tests, or for + different teams or security profiles. What would have been system level + settings are now dagbag level so that one system can run multiple, + independent settings sets. + + :param dag_folder: the folder to scan to find DAGs + :type dag_folder: unicode + :param executor: the executor to use when executing task instances + in this DagBag + :param include_examples: whether to include the examples that ship + with airflow or not + :type include_examples: bool + """ + + def __init__( + self, + dag_folder=None, + executor=None, + include_examples=configuration.getboolean('core', 'LOAD_EXAMPLES')): + + # do not use default arg in signature, to fix import cycle on plugin load + if executor is None: + executor = GetDefaultExecutor() + dag_folder = dag_folder or settings.DAGS_FOLDER + self.log.info("Filling up the DagBag from %s", dag_folder) + self.dag_folder = dag_folder + self.dags = {} + # the file's last modified timestamp when we last read it + self.file_last_changed = {} + self.executor = executor + self.import_errors = {} + + if include_examples: + example_dag_folder = os.path.join( + os.path.dirname(__file__), + 'example_dags') + self.collect_dags(example_dag_folder) + self.collect_dags(dag_folder) + + def size(self): + """ + :return: the amount of dags contained in this dagbag + """ + return len(self.dags) + + def get_dag(self, dag_id): + """ + Gets the DAG out of the dictionary, and refreshes it if expired + """ + # If asking for a known subdag, we want to refresh the parent + root_dag_id = dag_id + if dag_id in self.dags: + dag = self.dags[dag_id] + if dag.is_subdag: + root_dag_id = dag.parent_dag.dag_id + + # If the dag corresponding to root_dag_id is absent or expired + orm_dag = DagModel.get_current(root_dag_id) + if orm_dag and ( + root_dag_id not in self.dags or + ( + orm_dag.last_expired and + dag.last_loaded < orm_dag.last_expired + ) + ): + # Reprocess source file + found_dags = self.process_file( + filepath=orm_dag.fileloc, only_if_updated=False) + + # If the source file no longer exports `dag_id`, delete it from self.dags + if found_dags and dag_id in [dag.dag_id for dag in found_dags]: + return self.dags[dag_id] + elif dag_id in self.dags: + del self.dags[dag_id] + return self.dags.get(dag_id) + + def process_file(self, filepath, only_if_updated=True, safe_mode=True): + """ + Given a path to a python module or zip file, this method imports + the module and look for dag objects within it. + """ + found_dags = [] + + # todo: raise exception? + if not os.path.isfile(filepath): + return found_dags + + try: + # This failed before in what may have been a git sync + # race condition + file_last_changed_on_disk = datetime.fromtimestamp(os.path.getmtime(filepath)) + if only_if_updated \ + and filepath in self.file_last_changed \ + and file_last_changed_on_disk == self.file_last_changed[filepath]: + return found_dags + + except Exception as e: + self.log.exception(e) + return found_dags + + mods = [] + if not zipfile.is_zipfile(filepath): + if safe_mode and os.path.isfile(filepath): + with open(filepath, 'rb') as f: + content = f.read() + if not all([s in content for s in (b'DAG', b'airflow')]): + self.file_last_changed[filepath] = file_last_changed_on_disk + return found_dags + + self.log.debug("Importing %s", filepath) + org_mod_name, _ = os.path.splitext(os.path.split(filepath)[-1]) + mod_name = ('unusual_prefix_' + + hashlib.sha1(filepath.encode('utf-8')).hexdigest() + + '_' + org_mod_name) + + if mod_name in sys.modules: + del sys.modules[mod_name] + + with timeout(configuration.getint('core', "DAGBAG_IMPORT_TIMEOUT")): + try: + m = imp.load_source(mod_name, filepath) + mods.append(m) + except Exception as e: + self.log.exception("Failed to import: %s", filepath) + self.import_errors[filepath] = str(e) + self.file_last_changed[filepath] = file_last_changed_on_disk + + else: + zip_file = zipfile.ZipFile(filepath) + for mod in zip_file.infolist(): + head, _ = os.path.split(mod.filename) + mod_name, ext = os.path.splitext(mod.filename) + if not head and (ext == '.py' or ext == '.pyc'): + if mod_name == '__init__': + self.log.warning("Found __init__.%s at root of %s", ext, filepath) + if safe_mode: + with zip_file.open(mod.filename) as zf: + self.log.debug("Reading %s from %s", mod.filename, filepath) + content = zf.read() + if not all([s in content for s in (b'DAG', b'airflow')]): + self.file_last_changed[filepath] = ( + file_last_changed_on_disk) + # todo: create ignore list + return found_dags + + if mod_name in sys.modules: + del sys.modules[mod_name] + + try: + sys.path.insert(0, filepath) + m = importlib.import_module(mod_name) + mods.append(m) + except Exception as e: + self.log.exception("Failed to import: %s", filepath) + self.import_errors[filepath] = str(e) + self.file_last_changed[filepath] = file_last_changed_on_disk + + for m in mods: + for dag in list(m.__dict__.values()): + if isinstance(dag, DAG): + if not dag.full_filepath: + dag.full_filepath = filepath + dag.is_subdag = False + self.bag_dag(dag, parent_dag=dag, root_dag=dag) + found_dags.append(dag) + found_dags += dag.subdags + + self.file_last_changed[filepath] = file_last_changed_on_disk + return found_dags + + @provide_session + def kill_zombies(self, session=None): + """ + Fails tasks that haven't had a heartbeat in too long + """ + from airflow.jobs import LocalTaskJob as LJ + self.log.info("Finding 'running' jobs without a recent heartbeat") + TI = TaskInstance + secs = configuration.getint('scheduler', 'scheduler_zombie_task_threshold') + limit_dttm = datetime.utcnow() - timedelta(seconds=secs) + self.log.info("Failing jobs without heartbeat after %s", limit_dttm) + + tis = ( + session.query(TI) + .join(LJ, TI.job_id == LJ.id) + .filter(TI.state == State.RUNNING) + .filter( + or_( + LJ.state != State.RUNNING, + LJ.latest_heartbeat < limit_dttm, + )) + .all() + ) + + for ti in tis: + if ti and ti.dag_id in self.dags: + dag = self.dags[ti.dag_id] + if ti.task_id in dag.task_ids: + task = dag.get_task(ti.task_id) + ti.task = task + ti.handle_failure("{} killed as zombie".format(str(ti))) + self.log.info('Marked zombie job %s as failed', ti) + Stats.incr('zombies_killed') + session.commit() + + def bag_dag(self, dag, parent_dag, root_dag): + """ + Adds the DAG into the bag, recurses into sub dags. + """ + self.dags[dag.dag_id] = dag + dag.resolve_template_files() + dag.last_loaded = datetime.utcnow() + + for task in dag.tasks: + settings.policy(task) + + for subdag in dag.subdags: + subdag.full_filepath = dag.full_filepath + subdag.parent_dag = dag + subdag.is_subdag = True + self.bag_dag(subdag, parent_dag=dag, root_dag=root_dag) + self.log.debug('Loaded DAG {dag}'.format(**locals())) + + def collect_dags( + self, + dag_folder=None, + only_if_updated=True): + """ + Given a file path or a folder, this method looks for python modules, + imports them and adds them to the dagbag collection. + + Note that if a .airflowignore file is found while processing, + the directory, it will behaves much like a .gitignore does, + ignoring files that match any of the regex patterns specified + in the file. + """ + start_dttm = datetime.utcnow() + dag_folder = dag_folder or self.dag_folder + + # Used to store stats around DagBag processing + stats = [] + FileLoadStat = namedtuple( + 'FileLoadStat', "file duration dag_num task_num dags") + if os.path.isfile(dag_folder): + self.process_file(dag_folder, only_if_updated=only_if_updated) + elif os.path.isdir(dag_folder): + patterns = [] + for root, dirs, files in os.walk(dag_folder, followlinks=True): + ignore_file = [f for f in files if f == '.airflowignore'] + if ignore_file: + f = open(os.path.join(root, ignore_file[0]), 'r') + patterns += [p for p in f.read().split('\n') if p] + f.close() + for f in files: + try: + filepath = os.path.join(root, f) + if not os.path.isfile(filepath): + continue + mod_name, file_ext = os.path.splitext( + os.path.split(filepath)[-1]) + if file_ext != '.py' and not zipfile.is_zipfile(filepath): + continue + if not any( + [re.findall(p, filepath) for p in patterns]): + ts = datetime.utcnow() + found_dags = self.process_file( + filepath, only_if_updated=only_if_updated) + + td = datetime.utcnow() - ts + td = td.total_seconds() + ( + float(td.microseconds) / 1000000) + stats.append(FileLoadStat( + filepath.replace(dag_folder, ''), + td, + len(found_dags), + sum([len(dag.tasks) for dag in found_dags]), + str([dag.dag_id for dag in found_dags]), + )) + except Exception as e: + self.log.warning(e) + Stats.gauge( + 'collect_dags', (datetime.utcnow() - start_dttm).total_seconds(), 1) + Stats.gauge( + 'dagbag_size', len(self.dags), 1) + Stats.gauge( + 'dagbag_import_errors', len(self.import_errors), 1) + self.dagbag_stats = sorted( + stats, key=lambda x: x.duration, reverse=True) + + def dagbag_report(self): + """Prints a report around DagBag loading stats""" + report = textwrap.dedent("""\n + ------------------------------------------------------------------- + DagBag loading stats for {dag_folder} + ------------------------------------------------------------------- + Number of DAGs: {dag_num} + Total task number: {task_num} + DagBag parsing time: {duration} + {table} + """) + stats = self.dagbag_stats + return report.format( + dag_folder=self.dag_folder, + duration=sum([o.duration for o in stats]), + dag_num=sum([o.dag_num for o in stats]), + task_num=sum([o.dag_num for o in stats]), + table=pprinttable(stats), + ) + + def deactivate_inactive_dags(self): + active_dag_ids = [dag.dag_id for dag in list(self.dags.values())] + session = settings.Session() + for dag in session.query( + DagModel).filter(~DagModel.dag_id.in_(active_dag_ids)).all(): + dag.is_active = False + session.merge(dag) + session.commit() + session.close() + + def paused_dags(self): + session = settings.Session() + dag_ids = [dp.dag_id for dp in session.query(DagModel).filter( + DagModel.is_paused.__eq__(True))] + session.commit() + session.close() + return dag_ids + + +class User(Base): + __tablename__ = "users" + + id = Column(Integer, primary_key=True) + username = Column(String(ID_LEN), unique=True) + email = Column(String(500)) + superuser = False + + def __repr__(self): + return self.username + + def get_id(self): + return str(self.id) + + def is_superuser(self): + return self.superuser + + +class Connection(Base, LoggingMixin): + """ + Placeholder to store information about different database instances + connection information. The idea here is that scripts use references to + database instances (conn_id) instead of hard coding hostname, logins and + passwords when using operators or hooks. + """ + __tablename__ = "connection" + + id = Column(Integer(), primary_key=True) + conn_id = Column(String(ID_LEN)) + conn_type = Column(String(500)) + host = Column(String(500)) + schema = Column(String(500)) + login = Column(String(500)) + _password = Column('password', String(5000)) + port = Column(Integer()) + is_encrypted = Column(Boolean, unique=False, default=False) + is_extra_encrypted = Column(Boolean, unique=False, default=False) + _extra = Column('extra', String(5000)) + + _types = [ + ('fs', 'File (path)'), + ('ftp', 'FTP',), + ('google_cloud_platform', 'Google Cloud Platform'), + ('hdfs', 'HDFS',), + ('http', 'HTTP',), + ('hive_cli', 'Hive Client Wrapper',), + ('hive_metastore', 'Hive Metastore Thrift',), + ('hiveserver2', 'Hive Server 2 Thrift',), + ('jdbc', 'Jdbc Connection',), + ('mysql', 'MySQL',), + ('postgres', 'Postgres',), + ('oracle', 'Oracle',), + ('vertica', 'Vertica',), + ('presto', 'Presto',), + ('s3', 'S3',), + ('samba', 'Samba',), + ('sqlite', 'Sqlite',), + ('ssh', 'SSH',), + ('cloudant', 'IBM Cloudant',), + ('mssql', 'Microsoft SQL Server'), + ('mesos_framework-id', 'Mesos Framework ID'), + ('jira', 'JIRA',), + ('redis', 'Redis',), + ('wasb', 'Azure Blob Storage'), + ('databricks', 'Databricks',), + ('aws', 'Amazon Web Services',), + ('emr', 'Elastic MapReduce',), + ] + + def __init__( + self, conn_id=None, conn_type=None, + host=None, login=None, password=None, + schema=None, port=None, extra=None, + uri=None): + self.conn_id = conn_id + if uri: + self.parse_from_uri(uri) + else: + self.conn_type = conn_type + self.host = host + self.login = login + self.password = password + self.schema = schema + self.port = port + self.extra = extra + + def parse_from_uri(self, uri): + temp_uri = urlparse(uri) + hostname = temp_uri.hostname or '' + if '%2f' in hostname: + hostname = hostname.replace('%2f', '/').replace('%2F', '/') + conn_type = temp_uri.scheme + if conn_type == 'postgresql': + conn_type = 'postgres' + self.conn_type = conn_type + self.host = hostname + self.schema = temp_uri.path[1:] + self.login = temp_uri.username + self.password = temp_uri.password + self.port = temp_uri.port + + def get_password(self): + if self._password and self.is_encrypted: + try: + fernet = get_fernet() + except: + raise AirflowException( + "Can't decrypt encrypted password for login={}, \ + FERNET_KEY configuration is missing".format(self.login)) + return fernet.decrypt(bytes(self._password, 'utf-8')).decode() + else: + return self._password + + def set_password(self, value): + if value: + try: + fernet = get_fernet() + self._password = fernet.encrypt(bytes(value, 'utf-8')).decode() + self.is_encrypted = True + except AirflowException: + self.log.exception("Failed to load fernet while encrypting value, " + "using non-encrypted value.") + self._password = value + self.is_encrypted = False + + @declared_attr + def password(cls): + return synonym('_password', + descriptor=property(cls.get_password, cls.set_password)) + + def get_extra(self): + if self._extra and self.is_extra_encrypted: + try: + fernet = get_fernet() + except: + raise AirflowException( + "Can't decrypt `extra` params for login={},\ + FERNET_KEY configuration is missing".format(self.login)) + return fernet.decrypt(bytes(self._extra, 'utf-8')).decode() + else: + return self._extra + + def set_extra(self, value): + if value: + try: + fernet = get_fernet() + self._extra = fernet.encrypt(bytes(value, 'utf-8')).decode() + self.is_extra_encrypted = True + except AirflowException: + self.log.exception("Failed to load fernet while encrypting value, " + "using non-encrypted value.") + self._extra = value + self.is_extra_encrypted = False + + @declared_attr + def extra(cls): + return synonym('_extra', + descriptor=property(cls.get_extra, cls.set_extra)) + + def get_hook(self): + try: + if self.conn_type == 'mysql': + from airflow.hooks.mysql_hook import MySqlHook + return MySqlHook(mysql_conn_id=self.conn_id) + elif self.conn_type == 'google_cloud_platform': + from airflow.contrib.hooks.bigquery_hook import BigQueryHook + return BigQueryHook(bigquery_conn_id=self.conn_id) + elif self.conn_type == 'postgres': + from airflow.hooks.postgres_hook import PostgresHook + return PostgresHook(postgres_conn_id=self.conn_id) + elif self.conn_type == 'hive_cli': + from airflow.hooks.hive_hooks import HiveCliHook + return HiveCliHook(hive_cli_conn_id=self.conn_id) + elif self.conn_type == 'presto': + from airflow.hooks.presto_hook import PrestoHook + return PrestoHook(presto_conn_id=self.conn_id) + elif self.conn_type == 'hiveserver2': + from airflow.hooks.hive_hooks import HiveServer2Hook + return HiveServer2Hook(hiveserver2_conn_id=self.conn_id) + elif self.conn_type == 'sqlite': + from airflow.hooks.sqlite_hook import SqliteHook + return SqliteHook(sqlite_conn_id=self.conn_id) + elif self.conn_type == 'jdbc': + from airflow.hooks.jdbc_hook import JdbcHook + return JdbcHook(jdbc_conn_id=self.conn_id) + elif self.conn_type == 'mssql': + from airflow.hooks.mssql_hook import MsSqlHook + return MsSqlHook(mssql_conn_id=self.conn_id) + elif self.conn_type == 'oracle': + from airflow.hooks.oracle_hook import OracleHook + return OracleHook(oracle_conn_id=self.conn_id) + elif self.conn_type == 'vertica': + from airflow.contrib.hooks.vertica_hook import VerticaHook + return VerticaHook(vertica_conn_id=self.conn_id) + elif self.conn_type == 'cloudant': + from airflow.contrib.hooks.cloudant_hook import CloudantHook + return CloudantHook(cloudant_conn_id=self.conn_id) + elif self.conn_type == 'jira': + from airflow.contrib.hooks.jira_hook import JiraHook + return JiraHook(jira_conn_id=self.conn_id) + elif self.conn_type == 'redis': + from airflow.contrib.hooks.redis_hook import RedisHook + return RedisHook(redis_conn_id=self.conn_id) + elif self.conn_type == 'wasb': + from airflow.contrib.hooks.wasb_hook import WasbHook + return WasbHook(wasb_conn_id=self.conn_id) + except: + pass + + def __repr__(self): + return self.conn_id + + @property + def extra_dejson(self): + """Returns the extra property by deserializing json.""" + obj = {} + if self.extra: + try: + obj = json.loads(self.extra) + except Exception as e: + self.log.exception(e) + self.log.error("Failed parsing the json for conn_id %s", self.conn_id) + + return obj + + +class DagPickle(Base): + """ + Dags can originate from different places (user repos, master repo, ...) + and also get executed in different places (different executors). This + object represents a version of a DAG and becomes a source of truth for + a BackfillJob execution. A pickle is a native python serialized object, + and in this case gets stored in the database for the duration of the job. + + The executors pick up the DagPickle id and read the dag definition from + the database. + """ + id = Column(Integer, primary_key=True) + pickle = Column(PickleType(pickler=dill)) + created_dttm = Column(DateTime, default=func.now()) + pickle_hash = Column(Text) + + __tablename__ = "dag_pickle" + + def __init__(self, dag): + self.dag_id = dag.dag_id + if hasattr(dag, 'template_env'): + dag.template_env = None + self.pickle_hash = hash(dag) + self.pickle = dag + + +class TaskInstance(Base, LoggingMixin): + """ + Task instances store the state of a task instance. This table is the + authority and single source of truth around what tasks have run and the + state they are in. + + The SqlAlchemy model doesn't have a SqlAlchemy foreign key to the task or + dag model deliberately to have more control over transactions. + + Database transactions on this table should insure double triggers and + any confusion around what task instances are or aren't ready to run + even while multiple schedulers may be firing task instances. + """ + + __tablename__ = "task_instance" + + task_id = Column(String(ID_LEN), primary_key=True) + dag_id = Column(String(ID_LEN), primary_key=True) + execution_date = Column(DateTime, primary_key=True) + start_date = Column(DateTime) + end_date = Column(DateTime) + duration = Column(Float) + state = Column(String(20)) + try_number = Column(Integer, default=0) + max_tries = Column(Integer) + hostname = Column(String(1000)) + unixname = Column(String(1000)) + job_id = Column(Integer) + pool = Column(String(50)) + queue = Column(String(50)) + priority_weight = Column(Integer) + operator = Column(String(1000)) + queued_dttm = Column(DateTime) + pid = Column(Integer) + + __table_args__ = ( + Index('ti_dag_state', dag_id, state), + Index('ti_state', state), + Index('ti_state_lkp', dag_id, task_id, execution_date, state), + Index('ti_pool', pool, state, priority_weight), + ) + + def __init__(self, task, execution_date, state=None): + self.dag_id = task.dag_id + self.task_id = task.task_id + self.execution_date = execution_date + self.task = task + self.queue = task.queue + self.pool = task.pool + self.priority_weight = task.priority_weight_total + self.try_number = 0 + self.max_tries = self.task.retries + self.unixname = getpass.getuser() + self.run_as_user = task.run_as_user + if state: + self.state = state + self.hostname = '' + self.init_on_load() + + @reconstructor + def init_on_load(self): + """ Initialize the attributes that aren't stored in the DB. """ + self.test_mode = False # can be changed when calling 'run' + + def command( + self, + mark_success=False, + ignore_all_deps=False, + ignore_depends_on_past=False, + ignore_task_deps=False, + ignore_ti_state=False, + local=False, + pickle_id=None, + raw=False, + job_id=None, + pool=None, + cfg_path=None): + """ + Returns a command that can be executed anywhere where airflow is + installed. This command is part of the message sent to executors by + the orchestrator. + """ + return " ".join(self.command_as_list( + mark_success=mark_success, + ignore_all_deps=ignore_all_deps, + ignore_depends_on_past=ignore_depends_on_past, + ignore_task_deps=ignore_task_deps, + ignore_ti_state=ignore_ti_state, + local=local, + pickle_id=pickle_id, + raw=raw, + job_id=job_id, + pool=pool, + cfg_path=cfg_path)) + + def command_as_list( + self, + mark_success=False, + ignore_all_deps=False, + ignore_task_deps=False, + ignore_depends_on_past=False, + ignore_ti_state=False, + local=False, + pickle_id=None, + raw=False, + job_id=None, + pool=None, + cfg_path=None): + """ + Returns a command that can be executed anywhere where airflow is + installed. This command is part of the message sent to executors by + the orchestrator. + """ + dag = self.task.dag + + should_pass_filepath = not pickle_id and dag + if should_pass_filepath and dag.full_filepath != dag.filepath: + path = "DAGS_FOLDER/{}".format(dag.filepath) + elif should_pass_filepath and dag.full_filepath: + path = dag.full_filepath + else: + path = None + + return TaskInstance.generate_command( + self.dag_id, + self.task_id, + self.execution_date, + mark_success=mark_success, + ignore_all_deps=ignore_all_deps, + ignore_task_deps=ignore_task_deps, + ignore_depends_on_past=ignore_depends_on_past, + ignore_ti_state=ignore_ti_state, + local=local, + pickle_id=pickle_id, + file_path=path, + raw=raw, + job_id=job_id, + pool=pool, + cfg_path=cfg_path) + + @staticmethod + def generate_command(dag_id, + task_id, + execution_date, + mark_success=False, + ignore_all_deps=False, + ignore_depends_on_past=False, + ignore_task_deps=False, + ignore_ti_state=False, + local=False, + pickle_id=None, + file_path=None, + raw=False, + job_id=None, + pool=None, + cfg_path=None + ): + """ + Generates the shell command required to execute this task instance. + + :param dag_id: DAG ID + :type dag_id: unicode + :param task_id: Task ID + :type task_id: unicode + :param execution_date: Execution date for the task + :type execution_date: datetime + :param mark_success: Whether to mark the task as successful + :type mark_success: bool + :param ignore_all_deps: Ignore all ignorable dependencies. + Overrides the other ignore_* parameters. + :type ignore_all_deps: boolean + :param ignore_depends_on_past: Ignore depends_on_past parameter of DAGs + (e.g. for Backfills) + :type ignore_depends_on_past: boolean + :param ignore_task_deps: Ignore task-specific dependencies such as depends_on_past + and trigger rule + :type ignore_task_deps: boolean + :param ignore_ti_state: Ignore the task instance's previous failure/success + :type ignore_ti_state: boolean + :param local: Whether to run the task locally + :type local: bool + :param pickle_id: If the DAG was serialized to the DB, the ID + associated with the pickled DAG + :type pickle_id: unicode + :param file_path: path to the file containing the DAG definition + :param raw: raw mode (needs more details) + :param job_id: job ID (needs more details) + :param pool: the Airflow pool that the task should run in + :type pool: unicode + :return: shell command that can be used to run the task instance + """ + iso = execution_date.isoformat() + cmd = ["airflow", "run", str(dag_id), str(task_id), str(iso)] + cmd.extend(["--mark_success"]) if mark_success else None + cmd.extend(["--pickle", str(pickle_id)]) if pickle_id else None + cmd.extend(["--job_id", str(job_id)]) if job_id else None + cmd.extend(["-A"]) if ignore_all_deps else None + cmd.extend(["-i"]) if ignore_task_deps else None + cmd.extend(["-I"]) if ignore_depends_on_past else None + cmd.extend(["--force"]) if ignore_ti_state else None + cmd.extend(["--local"]) if local else None + cmd.extend(["--pool", pool]) if pool else None + cmd.extend(["--raw"]) if raw else None + cmd.extend(["-sd", file_path]) if file_path else None + cmd.extend(["--cfg_path", cfg_path]) if cfg_path else None + return cmd + + @property + def log_filepath(self): + iso = self.execution_date.isoformat() + log = os.path.expanduser(configuration.get('core', 'BASE_LOG_FOLDER')) + return ( + "{log}/{self.dag_id}/{self.task_id}/{iso}.log".format(**locals())) + + @property + def log_url(self): + iso = self.execution_date.isoformat() + BASE_URL = configuration.get('webserver', 'BASE_URL') + return BASE_URL + ( + "/admin/airflow/log" + "?dag_id={self.dag_id}" + "&task_id={self.task_id}" + "&execution_date={iso}" + ).format(**locals()) + + @property + def mark_success_url(self): + iso = self.execution_date.isoformat() + BASE_URL = configuration.get('webserver', 'BASE_URL') + return BASE_URL + ( + "/admin/airflow/action" + "?action=success" + "&task_id={self.task_id}" + "&dag_id={self.dag_id}" + "&execution_date={iso}" + "&upstream=false" + "&downstream=false" + ).format(**locals()) + + @provide_session + def current_state(self, session=None): + """ + Get the very latest state from the database, if a session is passed, + we use and looking up the state becomes part of the session, otherwise + a new session is used. + """ + TI = TaskInstance + ti = session.query(TI).filter( + TI.dag_id == self.dag_id, + TI.task_id == self.task_id, + TI.execution_date == self.execution_date, + ).all() + if ti: + state = ti[0].state + else: + state = None + return state + + @provide_session + def error(self, session=None): + """ + Forces the task instance's state to FAILED in the database. + """ + self.log.error("Recording the task instance as FAILED") + self.state = State.FAILED + session.merge(self) + session.commit() + + @provide_session + def refresh_from_db(self, session=None, lock_for_update=False): + """ + Refreshes the task instance from the database based on the primary key + + :param lock_for_update: if True, indicates that the database should + lock the TaskInstance (issuing a FOR UPDATE clause) until the + session is committed. + """ + TI = TaskInstance + + qry = session.query(TI).filter( + TI.dag_id == self.dag_id, + TI.task_id == self.task_id, + TI.execution_date == self.execution_date) + + if lock_for_update: + ti = qry.with_for_update().first() + else: + ti = qry.first() + if ti: + self.state = ti.state + self.start_date = ti.start_date + self.end_date = ti.end_date + self.try_number = ti.try_number + self.max_tries = ti.max_tries + self.hostname = ti.hostname + self.pid = ti.pid + else: + self.state = None + + @provide_session + def clear_xcom_data(self, session=None): + """ + Clears all XCom data from the database for the task instance + """ + session.query(XCom).filter( + XCom.dag_id == self.dag_id, + XCom.task_id == self.task_id, + XCom.execution_date == self.execution_date + ).delete() + session.commit() + + @property + def key(self): + """ + Returns a tuple that identifies the task instance uniquely + """ + return self.dag_id, self.task_id, self.execution_date + + def set_state(self, state, session): + self.state = state + self.start_date = datetime.utcnow() + self.end_date = datetime.utcnow() + session.merge(self) + session.commit() + + @property + def is_premature(self): + """ + Returns whether a task is in UP_FOR_RETRY state and its retry interval + has elapsed. + """ + # is the task still in the retry waiting period? + return self.state == State.UP_FOR_RETRY and not self.ready_for_retry() + + @provide_session + def are_dependents_done(self, session=None): + """ + Checks whether the dependents of this task instance have all succeeded. + This is meant to be used by wait_for_downstream. + + This is useful when you do not want to start processing the next + schedule of a task until the dependents are done. For instance, + if the task DROPs and recreates a table. + """ + task = self.task + + if not task.downstream_task_ids: + return True + + ti = session.query(func.count(TaskInstance.task_id)).filter( + TaskInstance.dag_id == self.dag_id, + TaskInstance.task_id.in_(task.downstream_task_ids), + TaskInstance.execution_date == self.execution_date, + TaskInstance.state == State.SUCCESS, + ) + count = ti[0][0] + return count == len(task.downstream_task_ids) + + @property + @provide_session + def previous_ti(self, session=None): + """ The task instance for the task that ran before this task instance """ + + dag = self.task.dag + if dag: + dr = self.get_dagrun(session=session) + + # LEGACY: most likely running from unit tests + if not dr: + # Means that this TI is NOT being run from a DR, but from a catchup + previous_scheduled_date = dag.previous_schedule(self.execution_date) + if not previous_scheduled_date: + return None + + return TaskInstance(task=self.task, + execution_date=previous_scheduled_date) + + dr.dag = dag + if dag.catchup: + last_dagrun = dr.get_previous_scheduled_dagrun(session=session) + else: + last_dagrun = dr.get_previous_dagrun(session=session) + + if last_dagrun: + return last_dagrun.get_task_instance(self.task_id, session=session) + + return None + + @provide_session + def are_dependencies_met( + self, + dep_context=None, + session=None, + verbose=False): + """ + Returns whether or not all the conditions are met for this task instance to be run + given the context for the dependencies (e.g. a task instance being force run from + the UI will ignore some dependencies). + + :param dep_context: The execution context that determines the dependencies that + should be evaluated. + :type dep_context: DepContext + :param session: database session + :type session: Session + :param verbose: whether or not to print details on failed dependencies + :type verbose: boolean + """ + dep_context = dep_context or DepContext() + failed = False + for dep_status in self.get_failed_dep_statuses( + dep_context=dep_context, + session=session): + failed = True + if verbose: + self.log.info( + "Dependencies not met for %s, dependency '%s' FAILED: %s", + self, dep_status.dep_name, dep_status.reason + ) + + if failed: + return False + + if verbose: + self.log.info("Dependencies all met for %s", self) + + return True + + @provide_session + def get_failed_dep_statuses( + self, + dep_context=None, + session=None): + dep_context = dep_context or DepContext() + for dep in dep_context.deps | self.task.deps: + for dep_status in dep.get_dep_statuses( + self, + session, + dep_context): + + self.log.debug( + "%s dependency '%s' PASSED: %s, %s", + self, dep_status.dep_name, dep_status.passed, dep_status.reason + ) + + if not dep_status.passed: + yield dep_status + + def __repr__(self): + return ( + "" + ).format(ti=self) + + def next_retry_datetime(self): + """ + Get datetime of the next retry if the task instance fails. For exponential + backoff, retry_delay is used as base and will be converted to seconds. + """ + delay = self.task.retry_delay + if self.task.retry_exponential_backoff: + min_backoff = int(delay.total_seconds() * (2 ** (self.try_number - 2))) + # deterministic per task instance + hash = int(hashlib.sha1("{}#{}#{}#{}".format(self.dag_id, self.task_id, + self.execution_date, self.try_number).encode('utf-8')).hexdigest(), 16) + # between 0.5 * delay * (2^retry_number) and 1.0 * delay * (2^retry_number) + modded_hash = min_backoff + hash % min_backoff + # timedelta has a maximum representable value. The exponentiation + # here means this value can be exceeded after a certain number + # of tries (around 50 if the initial delay is 1s, even fewer if + # the delay is larger). Cap the value here before creating a + # timedelta object so the operation doesn't fail. + delay_backoff_in_seconds = min( + modded_hash, + timedelta.max.total_seconds() - 1 + ) + delay = timedelta(seconds=delay_backoff_in_seconds) + if self.task.max_retry_delay: + delay = min(self.task.max_retry_delay, delay) + return self.end_date + delay + + def ready_for_retry(self): + """ + Checks on whether the task instance is in the right state and timeframe + to be retried. + """ + return (self.state == State.UP_FOR_RETRY and + self.next_retry_datetime() < datetime.utcnow()) + + @provide_session + def pool_full(self, session): + """ + Returns a boolean as to whether the slot pool has room for this + task to run + """ + if not self.task.pool: + return False + + pool = ( + session + .query(Pool) + .filter(Pool.pool == self.task.pool) + .first() + ) + if not pool: + return False + open_slots = pool.open_slots(session=session) + + return open_slots <= 0 + + @provide_session + def get_dagrun(self, session): + """ + Returns the DagRun for this TaskInstance + + :param session: + :return: DagRun + """ + dr = session.query(DagRun).filter( + DagRun.dag_id == self.dag_id, + DagRun.execution_date == self.execution_date + ).first() + + return dr + + @provide_session + def _check_and_change_state_before_execution( + self, + verbose=True, + ignore_all_deps=False, + ignore_depends_on_past=False, + ignore_task_deps=False, + ignore_ti_state=False, + mark_success=False, + test_mode=False, + job_id=None, + pool=None, + session=None): + """ + Checks dependencies and then sets state to RUNNING if they are met. Returns + True if and only if state is set to RUNNING, which implies that task should be + executed, in preparation for _run_raw_task + + :param verbose: whether to turn on more verbose logging + :type verbose: boolean + :param ignore_all_deps: Ignore all of the non-critical dependencies, just runs + :type ignore_all_deps: boolean + :param ignore_depends_on_past: Ignore depends_on_past DAG attribute + :type ignore_depends_on_past: boolean + :param ignore_task_deps: Don't check the dependencies of this TI's task + :type ignore_task_deps: boolean + :param ignore_ti_state: Disregards previous task instance state + :type ignore_ti_state: boolean + :param mark_success: Don't run the task, mark its state as success + :type mark_success: boolean + :param test_mode: Doesn't record success or failure in the DB + :type test_mode: boolean + :param pool: specifies the pool to use to run the task instance + :type pool: str + :return: whether the state was changed to running or not + :rtype: bool + """ + task = self.task + self.pool = pool or task.pool + self.test_mode = test_mode + self.refresh_from_db(session=session, lock_for_update=True) + self.job_id = job_id + self.hostname = socket.getfqdn() + self.operator = task.__class__.__name__ + + if not ignore_all_deps and not ignore_ti_state and self.state == State.SUCCESS: + Stats.incr('previously_succeeded', 1, 1) + + queue_dep_context = DepContext( + deps=QUEUE_DEPS, + ignore_all_deps=ignore_all_deps, + ignore_ti_state=ignore_ti_state, + ignore_depends_on_past=ignore_depends_on_past, + ignore_task_deps=ignore_task_deps) + if not self.are_dependencies_met( + dep_context=queue_dep_context, + session=session, + verbose=True): + session.commit() + return False + + #TODO: Logging needs cleanup, not clear what is being printed + hr = "\n" + ("-" * 80) + "\n" # Line break + + # For reporting purposes, we report based on 1-indexed, + # not 0-indexed lists (i.e. Attempt 1 instead of + # Attempt 0 for the first attempt). + msg = "Starting attempt {attempt} of {total}".format( + attempt=self.try_number + 1, + total=self.max_tries + 1) + self.start_date = datetime.utcnow() + + dep_context = DepContext( + deps=RUN_DEPS - QUEUE_DEPS, + ignore_all_deps=ignore_all_deps, + ignore_depends_on_past=ignore_depends_on_past, + ignore_task_deps=ignore_task_deps, + ignore_ti_state=ignore_ti_state) + runnable = self.are_dependencies_met( + dep_context=dep_context, + session=session, + verbose=True) + + if not runnable and not mark_success: + # FIXME: we might have hit concurrency limits, which means we probably + # have been running prematurely. This should be handled in the + # scheduling mechanism. + self.state = State.NONE + msg = ("FIXME: Rescheduling due to concurrency limits reached at task " + "runtime. Attempt {attempt} of {total}. State set to NONE.").format( + attempt=self.try_number + 1, + total=self.max_tries + 1) + self.log.warning(hr + msg + hr) + + self.queued_dttm = datetime.utcnow() + self.log.info("Queuing into pool %s", self.pool) + session.merge(self) + session.commit() + return False + + # Another worker might have started running this task instance while + # the current worker process was blocked on refresh_from_db + if self.state == State.RUNNING: + msg = "Task Instance already running {}".format(self) + self.log.warning(msg) + session.commit() + return False + + # print status message + self.log.info(hr + msg + hr) + self.try_number += 1 + + if not test_mode: + session.add(Log(State.RUNNING, self)) + self.state = State.RUNNING + self.pid = os.getpid() + self.end_date = None + if not test_mode: + session.merge(self) + session.commit() + + # Closing all pooled connections to prevent + # "max number of connections reached" + settings.engine.dispose() + if verbose: + if mark_success: + msg = "Marking success for {} on {}".format(self.task, self.execution_date) + self.log.info(msg) + else: + msg = "Executing {} on {}".format(self.task, self.execution_date) + self.log.info(msg) + return True + + @provide_session + def _run_raw_task( + self, + mark_success=False, + test_mode=False, + job_id=None, + pool=None, + session=None): + """ + Immediately runs the task (without checking or changing db state + before execution) and then sets the appropriate final state after + completion and runs any post-execute callbacks. Meant to be called + only after another function changes the state to running. + + :param mark_success: Don't run the task, mark its state as success + :type mark_success: boolean + :param test_mode: Doesn't record success or failure in the DB + :type test_mode: boolean + :param pool: specifies the pool to use to run the task instance + :type pool: str + """ + task = self.task + self.pool = pool or task.pool + self.test_mode = test_mode + self.refresh_from_db(session=session) + self.job_id = job_id + self.hostname = socket.getfqdn() + self.operator = task.__class__.__name__ + + context = {} + try: + if not mark_success: + context = self.get_template_context() + + task_copy = copy.copy(task) + self.task = task_copy + + def signal_handler(signum, frame): + """Setting kill signal handler""" + self.log.error("Killing subprocess") + task_copy.on_kill() + raise AirflowException("Task received SIGTERM signal") + signal.signal(signal.SIGTERM, signal_handler) + + # Don't clear Xcom until the task is certain to execute + self.clear_xcom_data() + + self.render_templates() + task_copy.pre_execute(context=context) + + # If a timeout is specified for the task, make it fail + # if it goes beyond + result = None + if task_copy.execution_timeout: + try: + with timeout(int( + task_copy.execution_timeout.total_seconds())): + result = task_copy.execute(context=context) + except AirflowTaskTimeout: + task_copy.on_kill() + raise + else: + result = task_copy.execute(context=context) + + # If the task returns a result, push an XCom containing it + if result is not None: + self.xcom_push(key=XCOM_RETURN_KEY, value=result) + + # TODO remove deprecated behavior in Airflow 2.0 + try: + task_copy.post_execute(context=context, result=result) + except TypeError as e: + if 'unexpected keyword argument' in str(e): + warnings.warn( + 'BaseOperator.post_execute() now takes two ' + 'arguments, `context` and `result`, but "{}" only ' + 'expected one. This behavior is deprecated and ' + 'will be removed in a future version of ' + 'Airflow.'.format(self.task_id), + category=DeprecationWarning) + task_copy.post_execute(context=context) + else: + raise + + Stats.incr('operator_successes_{}'.format( + self.task.__class__.__name__), 1, 1) + Stats.incr('ti_successes') + self.refresh_from_db(lock_for_update=True) + self.state = State.SUCCESS + except AirflowSkipException: + self.refresh_from_db(lock_for_update=True) + self.state = State.SKIPPED + except AirflowException as e: + self.refresh_from_db() + # for case when task is marked as success externally + # current behavior doesn't hit the success callback + if self.state == State.SUCCESS: + return + else: + self.handle_failure(e, test_mode, context) + raise + except (Exception, KeyboardInterrupt) as e: + self.handle_failure(e, test_mode, context) + raise + + # Recording SUCCESS + self.end_date = datetime.utcnow() + self.set_duration() + if not test_mode: + session.add(Log(self.state, self)) + session.merge(self) + session.commit() + + # Success callback + try: + if task.on_success_callback: + task.on_success_callback(context) + except Exception as e3: + self.log.error("Failed when executing success callback") + self.log.exception(e3) + + session.commit() + + @provide_session + def run( + self, + verbose=True, + ignore_all_deps=False, + ignore_depends_on_past=False, + ignore_task_deps=False, + ignore_ti_state=False, + mark_success=False, + test_mode=False, + job_id=None, + pool=None, + session=None): + res = self._check_and_change_state_before_execution( + verbose=verbose, + ignore_all_deps=ignore_all_deps, + ignore_depends_on_past=ignore_depends_on_past, + ignore_task_deps=ignore_task_deps, + ignore_ti_state=ignore_ti_state, + mark_success=mark_success, + test_mode=test_mode, + job_id=job_id, + pool=pool, + session=session) + if res: + self._run_raw_task( + mark_success=mark_success, + test_mode=test_mode, + job_id=job_id, + pool=pool, + session=session) + + def dry_run(self): + task = self.task + task_copy = copy.copy(task) + self.task = task_copy + + self.render_templates() + task_copy.dry_run() + + def handle_failure(self, error, test_mode=False, context=None): + self.log.exception(error) + task = self.task + session = settings.Session() + self.end_date = datetime.utcnow() + self.set_duration() + Stats.incr('operator_failures_{}'.format(task.__class__.__name__), 1, 1) + Stats.incr('ti_failures') + if not test_mode: + session.add(Log(State.FAILED, self)) + + # Log failure duration + session.add(TaskFail(task, self.execution_date, self.start_date, self.end_date)) + + # Let's go deeper + try: + # try_number is incremented by 1 during task instance run. So the + # current task instance try_number is the try_number for the next + # task instance run. We only mark task instance as FAILED if the + # next task instance try_number exceeds the max_tries. + if task.retries and self.try_number <= self.max_tries: + self.state = State.UP_FOR_RETRY + self.log.info('Marking task as UP_FOR_RETRY') + if task.email_on_retry and task.email: + self.email_alert(error, is_retry=True) + else: + self.state = State.FAILED + if task.retries: + self.log.info('All retries failed; marking task as FAILED') + else: + self.log.info('Marking task as FAILED.') + if task.email_on_failure and task.email: + self.email_alert(error, is_retry=False) + except Exception as e2: + self.log.error('Failed to send email to: %s', task.email) + self.log.exception(e2) + + # Handling callbacks pessimistically + try: + if self.state == State.UP_FOR_RETRY and task.on_retry_callback: + task.on_retry_callback(context) + if self.state == State.FAILED and task.on_failure_callback: + task.on_failure_callback(context) + except Exception as e3: + self.log.error("Failed at executing callback") + self.log.exception(e3) + + if not test_mode: + session.merge(self) + session.commit() + self.log.error(str(error)) + + @provide_session + def get_template_context(self, session=None): + task = self.task + from airflow import macros + tables = None + if 'tables' in task.params: + tables = task.params['tables'] + + ds = self.execution_date.isoformat()[:10] + ts = self.execution_date.isoformat() + yesterday_ds = (self.execution_date - timedelta(1)).isoformat()[:10] + tomorrow_ds = (self.execution_date + timedelta(1)).isoformat()[:10] + + prev_execution_date = task.dag.previous_schedule(self.execution_date) + next_execution_date = task.dag.following_schedule(self.execution_date) + + ds_nodash = ds.replace('-', '') + ts_nodash = ts.replace('-', '').replace(':', '') + yesterday_ds_nodash = yesterday_ds.replace('-', '') + tomorrow_ds_nodash = tomorrow_ds.replace('-', '') + + ti_key_str = "{task.dag_id}__{task.task_id}__{ds_nodash}" + ti_key_str = ti_key_str.format(**locals()) + + params = {} + run_id = '' + dag_run = None + if hasattr(task, 'dag'): + if task.dag.params: + params.update(task.dag.params) + dag_run = ( + session.query(DagRun) + .filter_by( + dag_id=task.dag.dag_id, + execution_date=self.execution_date) + .first() + ) + run_id = dag_run.run_id if dag_run else None + session.expunge_all() + session.commit() + + if task.params: + params.update(task.params) + + class VariableAccessor: + """ + Wrapper around Variable. This way you can get variables in templates by using + {var.variable_name}. + """ + def __init__(self): + self.var = None + + def __getattr__(self, item): + self.var = Variable.get(item) + return self.var + + def __repr__(self): + return str(self.var) + + class VariableJsonAccessor: + def __init__(self): + self.var = None + + def __getattr__(self, item): + self.var = Variable.get(item, deserialize_json=True) + return self.var + + def __repr__(self): + return str(self.var) + + return { + 'dag': task.dag, + 'ds': ds, + 'ds_nodash': ds_nodash, + 'ts': ts, + 'ts_nodash': ts_nodash, + 'yesterday_ds': yesterday_ds, + 'yesterday_ds_nodash': yesterday_ds_nodash, + 'tomorrow_ds': tomorrow_ds, + 'tomorrow_ds_nodash': tomorrow_ds_nodash, + 'END_DATE': ds, + 'end_date': ds, + 'dag_run': dag_run, + 'run_id': run_id, + 'execution_date': self.execution_date, + 'prev_execution_date': prev_execution_date, + 'next_execution_date': next_execution_date, + 'latest_date': ds, + 'macros': macros, + 'params': params, + 'tables': tables, + 'task': task, + 'task_instance': self, + 'ti': self, + 'task_instance_key_str': ti_key_str, + 'conf': configuration, + 'test_mode': self.test_mode, + 'var': { + 'value': VariableAccessor(), + 'json': VariableJsonAccessor() + } + } + + def render_templates(self): + task = self.task + jinja_context = self.get_template_context() + if hasattr(self, 'task') and hasattr(self.task, 'dag'): + if self.task.dag.user_defined_macros: + jinja_context.update( + self.task.dag.user_defined_macros) + + rt = self.task.render_template # shortcut to method + for attr in task.__class__.template_fields: + content = getattr(task, attr) + if content: + rendered_content = rt(attr, content, jinja_context) + setattr(task, attr, rendered_content) + + def email_alert(self, exception, is_retry=False): + task = self.task + title = "Airflow alert: {self}".format(**locals()) + exception = str(exception).replace('\n', '
') + # For reporting purposes, we report based on 1-indexed, + # not 0-indexed lists (i.e. Try 1 instead of + # Try 0 for the first attempt). + body = ( + "Try {try_number} out of {max_tries}
" + "Exception:
{exception}
" + "Log: Link
" + "Host: {self.hostname}
" + "Log file: {self.log_filepath}
" + "Mark success: Link
" + ).format(try_number=self.try_number + 1, max_tries=self.max_tries + 1, **locals()) + send_email(task.email, title, body) + + def set_duration(self): + if self.end_date and self.start_date: + self.duration = (self.end_date - self.start_date).total_seconds() + else: + self.duration = None + + def xcom_push( + self, + key, + value, + execution_date=None): + """ + Make an XCom available for tasks to pull. + + :param key: A key for the XCom + :type key: string + :param value: A value for the XCom. The value is pickled and stored + in the database. + :type value: any pickleable object + :param execution_date: if provided, the XCom will not be visible until + this date. This can be used, for example, to send a message to a + task on a future date without it being immediately visible. + :type execution_date: datetime + """ + + if execution_date and execution_date < self.execution_date: + raise ValueError( + 'execution_date can not be in the past (current ' + 'execution_date is {}; received {})'.format( + self.execution_date, execution_date)) + + XCom.set( + key=key, + value=value, + task_id=self.task_id, + dag_id=self.dag_id, + execution_date=execution_date or self.execution_date) + + def xcom_pull( + self, + task_ids, + dag_id=None, + key=XCOM_RETURN_KEY, + include_prior_dates=False): + """ + Pull XComs that optionally meet certain criteria. + + The default value for `key` limits the search to XComs + that were returned by other tasks (as opposed to those that were pushed + manually). To remove this filter, pass key=None (or any desired value). + + If a single task_id string is provided, the result is the value of the + most recent matching XCom from that task_id. If multiple task_ids are + provided, a tuple of matching values is returned. None is returned + whenever no matches are found. + + :param key: A key for the XCom. If provided, only XComs with matching + keys will be returned. The default key is 'return_value', also + available as a constant XCOM_RETURN_KEY. This key is automatically + given to XComs returned by tasks (as opposed to being pushed + manually). To remove the filter, pass key=None. + :type key: string + :param task_ids: Only XComs from tasks with matching ids will be + pulled. Can pass None to remove the filter. + :type task_ids: string or iterable of strings (representing task_ids) + :param dag_id: If provided, only pulls XComs from this DAG. + If None (default), the DAG of the calling task is used. + :type dag_id: string + :param include_prior_dates: If False, only XComs from the current + execution_date are returned. If True, XComs from previous dates + are returned as well. + :type include_prior_dates: bool + """ + + if dag_id is None: + dag_id = self.dag_id + + pull_fn = functools.partial( + XCom.get_one, + execution_date=self.execution_date, + key=key, + dag_id=dag_id, + include_prior_dates=include_prior_dates) + + if is_container(task_ids): + return tuple(pull_fn(task_id=t) for t in task_ids) + else: + return pull_fn(task_id=task_ids) + + @provide_session + def get_num_running_task_instances(self, session): + TI = TaskInstance + return session.query(TI).filter( + TI.dag_id == self.dag_id, + TI.task_id == self.task_id, + TI.state == State.RUNNING + ).count() + + +class TaskFail(Base): + """ + TaskFail tracks the failed run durations of each task instance. + """ + + __tablename__ = "task_fail" + + task_id = Column(String(ID_LEN), primary_key=True) + dag_id = Column(String(ID_LEN), primary_key=True) + execution_date = Column(DateTime, primary_key=True) + start_date = Column(DateTime) + end_date = Column(DateTime) + duration = Column(Float) + + def __init__(self, task, execution_date, start_date, end_date): + self.dag_id = task.dag_id + self.task_id = task.task_id + self.execution_date = execution_date + self.start_date = start_date + self.end_date = end_date + self.duration = (self.end_date - self.start_date).total_seconds() + + +class Log(Base): + """ + Used to actively log events to the database + """ + + __tablename__ = "log" + + id = Column(Integer, primary_key=True) + dttm = Column(DateTime) + dag_id = Column(String(ID_LEN)) + task_id = Column(String(ID_LEN)) + event = Column(String(30)) + execution_date = Column(DateTime) + owner = Column(String(500)) + extra = Column(Text) + + def __init__(self, event, task_instance, owner=None, extra=None, **kwargs): + self.dttm = datetime.utcnow() + self.event = event + self.extra = extra + + task_owner = None + + if task_instance: + self.dag_id = task_instance.dag_id + self.task_id = task_instance.task_id + self.execution_date = task_instance.execution_date + task_owner = task_instance.task.owner + + if 'task_id' in kwargs: + self.task_id = kwargs['task_id'] + if 'dag_id' in kwargs: + self.dag_id = kwargs['dag_id'] + if 'execution_date' in kwargs: + if kwargs['execution_date']: + self.execution_date = kwargs['execution_date'] + + self.owner = owner or task_owner + + +class SkipMixin(LoggingMixin): + def skip(self, dag_run, execution_date, tasks): + """ + Sets tasks instances to skipped from the same dag run. + :param dag_run: the DagRun for which to set the tasks to skipped + :param execution_date: execution_date + :param tasks: tasks to skip (not task_ids) + """ + if not tasks: + return + + task_ids = [d.task_id for d in tasks] + now = datetime.utcnow() + session = settings.Session() + + if dag_run: + session.query(TaskInstance).filter( + TaskInstance.dag_id == dag_run.dag_id, + TaskInstance.execution_date == dag_run.execution_date, + TaskInstance.task_id.in_(task_ids) + ).update({TaskInstance.state : State.SKIPPED, + TaskInstance.start_date: now, + TaskInstance.end_date: now}, + synchronize_session=False) + session.commit() + else: + assert execution_date is not None, "Execution date is None and no dag run" + + self.log.warning("No DAG RUN present this should not happen") + # this is defensive against dag runs that are not complete + for task in tasks: + ti = TaskInstance(task, execution_date=execution_date) + ti.state = State.SKIPPED + ti.start_date = now + ti.end_date = now + session.merge(ti) + + session.commit() + session.close() + + +@functools.total_ordering +class BaseOperator(LoggingMixin): + """ + Abstract base class for all operators. Since operators create objects that + become node in the dag, BaseOperator contains many recursive methods for + dag crawling behavior. To derive this class, you are expected to override + the constructor as well as the 'execute' method. + + Operators derived from this class should perform or trigger certain tasks + synchronously (wait for completion). Example of operators could be an + operator the runs a Pig job (PigOperator), a sensor operator that + waits for a partition to land in Hive (HiveSensorOperator), or one that + moves data from Hive to MySQL (Hive2MySqlOperator). Instances of these + operators (tasks) target specific operations, running specific scripts, + functions or data transfers. + + This class is abstract and shouldn't be instantiated. Instantiating a + class derived from this one results in the creation of a task object, + which ultimately becomes a node in DAG objects. Task dependencies should + be set by using the set_upstream and/or set_downstream methods. + + :param task_id: a unique, meaningful id for the task + :type task_id: string + :param owner: the owner of the task, using the unix username is recommended + :type owner: string + :param retries: the number of retries that should be performed before + failing the task + :type retries: int + :param retry_delay: delay between retries + :type retry_delay: timedelta + :param retry_exponential_backoff: allow progressive longer waits between + retries by using exponential backoff algorithm on retry delay (delay + will be converted into seconds) + :type retry_exponential_backoff: bool + :param max_retry_delay: maximum delay interval between retries + :type max_retry_delay: timedelta + :param start_date: The ``start_date`` for the task, determines + the ``execution_date`` for the first task instance. The best practice + is to have the start_date rounded + to your DAG's ``schedule_interval``. Daily jobs have their start_date + some day at 00:00:00, hourly jobs have their start_date at 00:00 + of a specific hour. Note that Airflow simply looks at the latest + ``execution_date`` and adds the ``schedule_interval`` to determine + the next ``execution_date``. It is also very important + to note that different tasks' dependencies + need to line up in time. If task A depends on task B and their + start_date are offset in a way that their execution_date don't line + up, A's dependencies will never be met. If you are looking to delay + a task, for example running a daily task at 2AM, look into the + ``TimeSensor`` and ``TimeDeltaSensor``. We advise against using + dynamic ``start_date`` and recommend using fixed ones. Read the + FAQ entry about start_date for more information. + :type start_date: datetime + :param end_date: if specified, the scheduler won't go beyond this date + :type end_date: datetime + :param depends_on_past: when set to true, task instances will run + sequentially while relying on the previous task's schedule to + succeed. The task instance for the start_date is allowed to run. + :type depends_on_past: bool + :param wait_for_downstream: when set to true, an instance of task + X will wait for tasks immediately downstream of the previous instance + of task X to finish successfully before it runs. This is useful if the + different instances of a task X alter the same asset, and this asset + is used by tasks downstream of task X. Note that depends_on_past + is forced to True wherever wait_for_downstream is used. + :type wait_for_downstream: bool + :param queue: which queue to target when running this job. Not + all executors implement queue management, the CeleryExecutor + does support targeting specific queues. + :type queue: str + :param dag: a reference to the dag the task is attached to (if any) + :type dag: DAG + :param priority_weight: priority weight of this task against other task. + This allows the executor to trigger higher priority tasks before + others when things get backed up. + :type priority_weight: int + :param pool: the slot pool this task should run in, slot pools are a + way to limit concurrency for certain tasks + :type pool: str + :param sla: time by which the job is expected to succeed. Note that + this represents the ``timedelta`` after the period is closed. For + example if you set an SLA of 1 hour, the scheduler would send dan email + soon after 1:00AM on the ``2016-01-02`` if the ``2016-01-01`` instance + has not succeeded yet. + The scheduler pays special attention for jobs with an SLA and + sends alert + emails for sla misses. SLA misses are also recorded in the database + for future reference. All tasks that share the same SLA time + get bundled in a single email, sent soon after that time. SLA + notification are sent once and only once for each task instance. + :type sla: datetime.timedelta + :param execution_timeout: max time allowed for the execution of + this task instance, if it goes beyond it will raise and fail. + :type execution_timeout: datetime.timedelta + :param on_failure_callback: a function to be called when a task instance + of this task fails. a context dictionary is passed as a single + parameter to this function. Context contains references to related + objects to the task instance and is documented under the macros + section of the API. + :type on_failure_callback: callable + :param on_retry_callback: much like the ``on_failure_callback`` except + that it is executed when retries occur. + :param on_success_callback: much like the ``on_failure_callback`` except + that it is executed when the task succeeds. + :type on_success_callback: callable + :param trigger_rule: defines the rule by which dependencies are applied + for the task to get triggered. Options are: + ``{ all_success | all_failed | all_done | one_success | + one_failed | dummy}`` + default is ``all_success``. Options can be set as string or + using the constants defined in the static class + ``airflow.utils.TriggerRule`` + :type trigger_rule: str + :param resources: A map of resource parameter names (the argument names of the + Resources constructor) to their values. + :type resources: dict + :param run_as_user: unix username to impersonate while running the task + :type run_as_user: str + :param task_concurrency: When set, a task will be able to limit the concurrent + runs across execution_dates + :type task_concurrency: int + """ + + # For derived classes to define which fields will get jinjaified + template_fields = [] + # Defines which files extensions to look for in the templated fields + template_ext = [] + # Defines the color in the UI + ui_color = '#fff' + ui_fgcolor = '#000' + + @apply_defaults + def __init__( + self, + task_id, + owner=configuration.get('operators', 'DEFAULT_OWNER'), + email=None, + email_on_retry=True, + email_on_failure=True, + retries=0, + retry_delay=timedelta(seconds=300), + retry_exponential_backoff=False, + max_retry_delay=None, + start_date=None, + end_date=None, + schedule_interval=None, # not hooked as of now + depends_on_past=False, + wait_for_downstream=False, + dag=None, + params=None, + default_args=None, + adhoc=False, + priority_weight=1, + queue=configuration.get('celery', 'default_queue'), + pool=None, + sla=None, + execution_timeout=None, + on_failure_callback=None, + on_success_callback=None, + on_retry_callback=None, + trigger_rule=TriggerRule.ALL_SUCCESS, + resources=None, + run_as_user=None, + task_concurrency=None, + *args, + **kwargs): + + if args or kwargs: + # TODO remove *args and **kwargs in Airflow 2.0 + warnings.warn( + 'Invalid arguments were passed to {c}. Support for ' + 'passing such arguments will be dropped in Airflow 2.0. ' + 'Invalid arguments were:' + '\n*args: {a}\n**kwargs: {k}'.format( + c=self.__class__.__name__, a=args, k=kwargs), + category=PendingDeprecationWarning + ) + + validate_key(task_id) + self.task_id = task_id + self.owner = owner + self.email = email + self.email_on_retry = email_on_retry + self.email_on_failure = email_on_failure + self.start_date = start_date + if start_date and not isinstance(start_date, datetime): + self.log.warning("start_date for %s isn't datetime.datetime", self) + self.end_date = end_date + if not TriggerRule.is_valid(trigger_rule): + raise AirflowException( + "The trigger_rule must be one of {all_triggers}," + "'{d}.{t}'; received '{tr}'." + .format(all_triggers=TriggerRule.all_triggers, + d=dag.dag_id, t=task_id, tr=trigger_rule)) + + self.trigger_rule = trigger_rule + self.depends_on_past = depends_on_past + self.wait_for_downstream = wait_for_downstream + if wait_for_downstream: + self.depends_on_past = True + + if schedule_interval: + self.log.warning( + "schedule_interval is used for {}, though it has " + "been deprecated as a task parameter, you need to " + "specify it as a DAG parameter instead", + self + ) + self._schedule_interval = schedule_interval + self.retries = retries + self.queue = queue + self.pool = pool + self.sla = sla + self.execution_timeout = execution_timeout + self.on_failure_callback = on_failure_callback + self.on_success_callback = on_success_callback + self.on_retry_callback = on_retry_callback + if isinstance(retry_delay, timedelta): + self.retry_delay = retry_delay + else: + self.log.debug("Retry_delay isn't timedelta object, assuming secs") + self.retry_delay = timedelta(seconds=retry_delay) + self.retry_exponential_backoff = retry_exponential_backoff + self.max_retry_delay = max_retry_delay + self.params = params or {} # Available in templates! + self.adhoc = adhoc + self.priority_weight = priority_weight + self.resources = Resources(**(resources or {})) + self.run_as_user = run_as_user + self.task_concurrency = task_concurrency + + # Private attributes + self._upstream_task_ids = [] + self._downstream_task_ids = [] + + if not dag and _CONTEXT_MANAGER_DAG: + dag = _CONTEXT_MANAGER_DAG + if dag: + self.dag = dag + + self._comps = { + 'task_id', + 'dag_id', + 'owner', + 'email', + 'email_on_retry', + 'retry_delay', + 'retry_exponential_backoff', + 'max_retry_delay', + 'start_date', + 'schedule_interval', + 'depends_on_past', + 'wait_for_downstream', + 'adhoc', + 'priority_weight', + 'sla', + 'execution_timeout', + 'on_failure_callback', + 'on_success_callback', + 'on_retry_callback', + } + + def __eq__(self, other): + return ( + type(self) == type(other) and + all(self.__dict__.get(c, None) == other.__dict__.get(c, None) + for c in self._comps)) + + def __ne__(self, other): + return not self == other + + def __lt__(self, other): + return self.task_id < other.task_id + + def __hash__(self): + hash_components = [type(self)] + for c in self._comps: + val = getattr(self, c, None) + try: + hash(val) + hash_components.append(val) + except TypeError: + hash_components.append(repr(val)) + return hash(tuple(hash_components)) + + # Composing Operators ----------------------------------------------- + + def __rshift__(self, other): + """ + Implements Self >> Other == self.set_downstream(other) + + If "Other" is a DAG, the DAG is assigned to the Operator. + """ + if isinstance(other, DAG): + # if this dag is already assigned, do nothing + # otherwise, do normal dag assignment + if not (self.has_dag() and self.dag is other): + self.dag = other + else: + self.set_downstream(other) + return other + + def __lshift__(self, other): + """ + Implements Self << Other == self.set_upstream(other) + + If "Other" is a DAG, the DAG is assigned to the Operator. + """ + if isinstance(other, DAG): + # if this dag is already assigned, do nothing + # otherwise, do normal dag assignment + if not (self.has_dag() and self.dag is other): + self.dag = other + else: + self.set_upstream(other) + return other + + def __rrshift__(self, other): + """ + Called for [DAG] >> [Operator] because DAGs don't have + __rshift__ operators. + """ + self.__lshift__(other) + return self + + def __rlshift__(self, other): + """ + Called for [DAG] << [Operator] because DAGs don't have + __lshift__ operators. + """ + self.__rshift__(other) + return self + + # /Composing Operators --------------------------------------------- + + @property + def dag(self): + """ + Returns the Operator's DAG if set, otherwise raises an error + """ + if self.has_dag(): + return self._dag + else: + raise AirflowException( + 'Operator {} has not been assigned to a DAG yet'.format(self)) + + @dag.setter + def dag(self, dag): + """ + Operators can be assigned to one DAG, one time. Repeat assignments to + that same DAG are ok. + """ + if not isinstance(dag, DAG): + raise TypeError( + 'Expected DAG; received {}'.format(dag.__class__.__name__)) + elif self.has_dag() and self.dag is not dag: + raise AirflowException( + "The DAG assigned to {} can not be changed.".format(self)) + elif self.task_id not in dag.task_dict: + dag.add_task(self) + + self._dag = dag + + def has_dag(self): + """ + Returns True if the Operator has been assigned to a DAG. + """ + return getattr(self, '_dag', None) is not None + + @property + def dag_id(self): + if self.has_dag(): + return self.dag.dag_id + else: + return 'adhoc_' + self.owner + + @property + def deps(self): + """ + Returns the list of dependencies for the operator. These differ from execution + context dependencies in that they are specific to tasks and can be + extended/overridden by subclasses. + """ + return { + NotInRetryPeriodDep(), + PrevDagrunDep(), + TriggerRuleDep(), + } + + @property + def schedule_interval(self): + """ + The schedule interval of the DAG always wins over individual tasks so + that tasks within a DAG always line up. The task still needs a + schedule_interval as it may not be attached to a DAG. + """ + if self.has_dag(): + return self.dag._schedule_interval + else: + return self._schedule_interval + + @property + def priority_weight_total(self): + return sum([ + t.priority_weight + for t in self.get_flat_relatives(upstream=False) + ]) + self.priority_weight + + def pre_execute(self, context): + """ + This hook is triggered right before self.execute() is called. + """ + pass + + def execute(self, context): + """ + This is the main method to derive when creating an operator. + Context is the same dictionary used as when rendering jinja templates. + + Refer to get_template_context for more context. + """ + raise NotImplementedError() + + def post_execute(self, context, result=None): + """ + This hook is triggered right after self.execute() is called. + It is passed the execution context and any results returned by the + operator. + """ + pass + + def on_kill(self): + """ + Override this method to cleanup subprocesses when a task instance + gets killed. Any use of the threading, subprocess or multiprocessing + module within an operator needs to be cleaned up or it will leave + ghost processes behind. + """ + pass + + def __deepcopy__(self, memo): + """ + Hack sorting double chained task lists by task_id to avoid hitting + max_depth on deepcopy operations. + """ + sys.setrecursionlimit(5000) # TODO fix this in a better way + cls = self.__class__ + result = cls.__new__(cls) + memo[id(self)] = result + + for k, v in list(self.__dict__.items()): + if k not in ('user_defined_macros', 'user_defined_filters', 'params'): + setattr(result, k, copy.deepcopy(v, memo)) + result.params = self.params + if hasattr(self, 'user_defined_macros'): + result.user_defined_macros = self.user_defined_macros + if hasattr(self, 'user_defined_filters'): + result.user_defined_filters = self.user_defined_filters + return result + + def render_template_from_field(self, attr, content, context, jinja_env): + """ + Renders a template from a field. If the field is a string, it will + simply render the string and return the result. If it is a collection or + nested set of collections, it will traverse the structure and render + all strings in it. + """ + rt = self.render_template + if isinstance(content, six.string_types): + result = jinja_env.from_string(content).render(**context) + elif isinstance(content, (list, tuple)): + result = [rt(attr, e, context) for e in content] + elif isinstance(content, dict): + result = { + k: rt("{}[{}]".format(attr, k), v, context) + for k, v in list(content.items())} + else: + param_type = type(content) + msg = ( + "Type '{param_type}' used for parameter '{attr}' is " + "not supported for templating").format(**locals()) + raise AirflowException(msg) + return result + + def render_template(self, attr, content, context): + """ + Renders a template either from a file or directly in a field, and returns + the rendered result. + """ + jinja_env = self.dag.get_template_env() \ + if hasattr(self, 'dag') \ + else jinja2.Environment(cache_size=0) + + exts = self.__class__.template_ext + if ( + isinstance(content, six.string_types) and + any([content.endswith(ext) for ext in exts])): + return jinja_env.get_template(content).render(**context) + else: + return self.render_template_from_field(attr, content, context, jinja_env) + + def prepare_template(self): + """ + Hook that is triggered after the templated fields get replaced + by their content. If you need your operator to alter the + content of the file before the template is rendered, + it should override this method to do so. + """ + pass + + def resolve_template_files(self): + # Getting the content of files for template_field / template_ext + for attr in self.template_fields: + content = getattr(self, attr) + if content is not None and \ + isinstance(content, six.string_types) and \ + any([content.endswith(ext) for ext in self.template_ext]): + env = self.dag.get_template_env() + try: + setattr(self, attr, env.loader.get_source(env, content)[0]) + except Exception as e: + self.log.exception(e) + self.prepare_template() + + @property + def upstream_list(self): + """@property: list of tasks directly upstream""" + return [self.dag.get_task(tid) for tid in self._upstream_task_ids] + + @property + def upstream_task_ids(self): + return self._upstream_task_ids + + @property + def downstream_list(self): + """@property: list of tasks directly downstream""" + return [self.dag.get_task(tid) for tid in self._downstream_task_ids] + + @property + def downstream_task_ids(self): + return self._downstream_task_ids + + def clear(self, start_date=None, end_date=None, upstream=False, downstream=False): + """ + Clears the state of task instances associated with the task, following + the parameters specified. + """ + session = settings.Session() + + TI = TaskInstance + qry = session.query(TI).filter(TI.dag_id == self.dag_id) + + if start_date: + qry = qry.filter(TI.execution_date >= start_date) + if end_date: + qry = qry.filter(TI.execution_date <= end_date) + + tasks = [self.task_id] + + if upstream: + tasks += [ + t.task_id for t in self.get_flat_relatives(upstream=True)] + + if downstream: + tasks += [ + t.task_id for t in self.get_flat_relatives(upstream=False)] + + qry = qry.filter(TI.task_id.in_(tasks)) + + count = qry.count() + + clear_task_instances(qry.all(), session, dag=self.dag) + + session.commit() + session.close() + return count + + def get_task_instances(self, session, start_date=None, end_date=None): + """ + Get a set of task instance related to this task for a specific date + range. + """ + TI = TaskInstance + end_date = end_date or datetime.utcnow() + return session.query(TI).filter( + TI.dag_id == self.dag_id, + TI.task_id == self.task_id, + TI.execution_date >= start_date, + TI.execution_date <= end_date, + ).order_by(TI.execution_date).all() + + def get_flat_relatives(self, upstream=False, l=None): + """ + Get a flat list of relatives, either upstream or downstream. + """ + if not l: + l = [] + for t in self.get_direct_relatives(upstream): + if not is_in(t, l): + l.append(t) + t.get_flat_relatives(upstream, l) + return l + + def detect_downstream_cycle(self, task=None): + """ + When invoked, this routine will raise an exception if a cycle is + detected downstream from self. It is invoked when tasks are added to + the DAG to detect cycles. + """ + if not task: + task = self + for t in self.get_direct_relatives(): + if task is t: + msg = "Cycle detected in DAG. Faulty task: {0}".format(task) + raise AirflowException(msg) + else: + t.detect_downstream_cycle(task=task) + return False + + def run( + self, + start_date=None, + end_date=None, + ignore_first_depends_on_past=False, + ignore_ti_state=False, + mark_success=False): + """ + Run a set of task instances for a date range. + """ + start_date = start_date or self.start_date + end_date = end_date or self.end_date or datetime.utcnow() + + for dt in self.dag.date_range(start_date, end_date=end_date): + TaskInstance(self, dt).run( + mark_success=mark_success, + ignore_depends_on_past=( + dt == start_date and ignore_first_depends_on_past), + ignore_ti_state=ignore_ti_state) + + def dry_run(self): + self.log.info('Dry run') + for attr in self.template_fields: + content = getattr(self, attr) + if content and isinstance(content, six.string_types): + self.log.info('Rendering template for %s', attr) + self.log.info(content) + + def get_direct_relatives(self, upstream=False): + """ + Get the direct relatives to the current task, upstream or + downstream. + """ + if upstream: + return self.upstream_list + else: + return self.downstream_list + + def __repr__(self): + return "".format( + self=self) + + @property + def task_type(self): + return self.__class__.__name__ + + def append_only_new(self, l, item): + if any([item is t for t in l]): + raise AirflowException( + 'Dependency {self}, {item} already registered' + ''.format(**locals())) + else: + l.append(item) + + def _set_relatives(self, task_or_task_list, upstream=False): + try: + task_list = list(task_or_task_list) + except TypeError: + task_list = [task_or_task_list] + + for t in task_list: + if not isinstance(t, BaseOperator): + raise AirflowException( + "Relationships can only be set between " + "Operators; received {}".format(t.__class__.__name__)) + + # relationships can only be set if the tasks share a single DAG. Tasks + # without a DAG are assigned to that DAG. + dags = set(t.dag for t in [self] + task_list if t.has_dag()) + + if len(dags) > 1: + raise AirflowException( + 'Tried to set relationships between tasks in ' + 'more than one DAG: {}'.format(dags)) + elif len(dags) == 1: + dag = list(dags)[0] + else: + raise AirflowException( + "Tried to create relationships between tasks that don't have " + "DAGs yet. Set the DAG for at least one " + "task and try again: {}".format([self] + task_list)) + + if dag and not self.has_dag(): + self.dag = dag + + for task in task_list: + if dag and not task.has_dag(): + task.dag = dag + if upstream: + task.append_only_new(task._downstream_task_ids, self.task_id) + self.append_only_new(self._upstream_task_ids, task.task_id) + else: + self.append_only_new(self._downstream_task_ids, task.task_id) + task.append_only_new(task._upstream_task_ids, self.task_id) + + self.detect_downstream_cycle() + + def set_downstream(self, task_or_task_list): + """ + Set a task or a task list to be directly downstream from the current + task. + """ + self._set_relatives(task_or_task_list, upstream=False) + + def set_upstream(self, task_or_task_list): + """ + Set a task or a task list to be directly upstream from the current + task. + """ + self._set_relatives(task_or_task_list, upstream=True) + + def xcom_push( + self, + context, + key, + value, + execution_date=None): + """ + See TaskInstance.xcom_push() + """ + context['ti'].xcom_push( + key=key, + value=value, + execution_date=execution_date) + + def xcom_pull( + self, + context, + task_ids, + dag_id=None, + key=XCOM_RETURN_KEY, + include_prior_dates=None): + """ + See TaskInstance.xcom_pull() + """ + return context['ti'].xcom_pull( + key=key, + task_ids=task_ids, + dag_id=dag_id, + include_prior_dates=include_prior_dates) + + +class DagModel(Base): + + __tablename__ = "dag" + """ + These items are stored in the database for state related information + """ + dag_id = Column(String(ID_LEN), primary_key=True) + # A DAG can be paused from the UI / DB + # Set this default value of is_paused based on a configuration value! + is_paused_at_creation = configuration.getboolean('core', + 'dags_are_paused_at_creation') + is_paused = Column(Boolean, default=is_paused_at_creation) + # Whether the DAG is a subdag + is_subdag = Column(Boolean, default=False) + # Whether that DAG was seen on the last DagBag load + is_active = Column(Boolean, default=False) + # Last time the scheduler started + last_scheduler_run = Column(DateTime) + # Last time this DAG was pickled + last_pickled = Column(DateTime) + # Time when the DAG last received a refresh signal + # (e.g. the DAG's "refresh" button was clicked in the web UI) + last_expired = Column(DateTime) + # Whether (one of) the scheduler is scheduling this DAG at the moment + scheduler_lock = Column(Boolean) + # Foreign key to the latest pickle_id + pickle_id = Column(Integer) + # The location of the file containing the DAG object + fileloc = Column(String(2000)) + # String representing the owners + owners = Column(String(2000)) + + def __repr__(self): + return "".format(self=self) + + @classmethod + def get_current(cls, dag_id): + session = settings.Session() + obj = session.query(cls).filter(cls.dag_id == dag_id).first() + session.expunge_all() + session.commit() + session.close() + return obj + + +@functools.total_ordering +class DAG(BaseDag, LoggingMixin): + """ + A dag (directed acyclic graph) is a collection of tasks with directional + dependencies. A dag also has a schedule, a start end an end date + (optional). For each schedule, (say daily or hourly), the DAG needs to run + each individual tasks as their dependencies are met. Certain tasks have + the property of depending on their own past, meaning that they can't run + until their previous schedule (and upstream tasks) are completed. + + DAGs essentially act as namespaces for tasks. A task_id can only be + added once to a DAG. + + :param dag_id: The id of the DAG + :type dag_id: string + :param description: The description for the DAG to e.g. be shown on the webserver + :type description: string + :param schedule_interval: Defines how often that DAG runs, this + timedelta object gets added to your latest task instance's + execution_date to figure out the next schedule + :type schedule_interval: datetime.timedelta or + dateutil.relativedelta.relativedelta or str that acts as a cron + expression + :param start_date: The timestamp from which the scheduler will + attempt to backfill + :type start_date: datetime.datetime + :param end_date: A date beyond which your DAG won't run, leave to None + for open ended scheduling + :type end_date: datetime.datetime + :param template_searchpath: This list of folders (non relative) + defines where jinja will look for your templates. Order matters. + Note that jinja/airflow includes the path of your DAG file by + default + :type template_searchpath: string or list of stings + :param user_defined_macros: a dictionary of macros that will be exposed + in your jinja templates. For example, passing ``dict(foo='bar')`` + to this argument allows you to ``{{ foo }}`` in all jinja + templates related to this DAG. Note that you can pass any + type of object here. + :type user_defined_macros: dict + :param user_defined_filters: a dictionary of filters that will be exposed + in your jinja templates. For example, passing + ``dict(hello=lambda name: 'Hello %s' % name)`` to this argument allows + you to ``{{ 'world' | hello }}`` in all jinja templates related to + this DAG. + :type user_defined_filters: dict + :param default_args: A dictionary of default parameters to be used + as constructor keyword parameters when initialising operators. + Note that operators have the same hook, and precede those defined + here, meaning that if your dict contains `'depends_on_past': True` + here and `'depends_on_past': False` in the operator's call + `default_args`, the actual value will be `False`. + :type default_args: dict + :param params: a dictionary of DAG level parameters that are made + accessible in templates, namespaced under `params`. These + params can be overridden at the task level. + :type params: dict + :param concurrency: the number of task instances allowed to run + concurrently + :type concurrency: int + :param max_active_runs: maximum number of active DAG runs, beyond this + number of DAG runs in a running state, the scheduler won't create + new active DAG runs + :type max_active_runs: int + :param dagrun_timeout: specify how long a DagRun should be up before + timing out / failing, so that new DagRuns can be created + :type dagrun_timeout: datetime.timedelta + :param sla_miss_callback: specify a function to call when reporting SLA + timeouts. + :type sla_miss_callback: types.FunctionType + :param default_view: Specify DAG default view (tree, graph, duration, gantt, landing_times) + :type default_view: string + :param orientation: Specify DAG orientation in graph view (LR, TB, RL, BT) + :type orientation: string + :param catchup: Perform scheduler catchup (or only run latest)? Defaults to True + :type catchup: bool + """ + + def __init__( + self, dag_id, + description='', + schedule_interval=timedelta(days=1), + start_date=None, end_date=None, + full_filepath=None, + template_searchpath=None, + user_defined_macros=None, + user_defined_filters=None, + default_args=None, + concurrency=configuration.getint('core', 'dag_concurrency'), + max_active_runs=configuration.getint( + 'core', 'max_active_runs_per_dag'), + dagrun_timeout=None, + sla_miss_callback=None, + default_view=configuration.get('webserver', 'dag_default_view').lower(), + orientation=configuration.get('webserver', 'dag_orientation'), + catchup=configuration.getboolean('scheduler', 'catchup_by_default'), + params=None): + + self.user_defined_macros = user_defined_macros + self.user_defined_filters = user_defined_filters + self.default_args = default_args or {} + self.params = params or {} + + # merging potentially conflicting default_args['params'] into params + if 'params' in self.default_args: + self.params.update(self.default_args['params']) + del self.default_args['params'] + + validate_key(dag_id) + + # Properties from BaseDag + self._dag_id = dag_id + self._full_filepath = full_filepath if full_filepath else '' + self._concurrency = concurrency + self._pickle_id = None + + self._description = description + # set file location to caller source path + self.fileloc = sys._getframe().f_back.f_code.co_filename + self.task_dict = dict() + self.start_date = start_date + self.end_date = end_date + self.schedule_interval = schedule_interval + if schedule_interval in cron_presets: + self._schedule_interval = cron_presets.get(schedule_interval) + elif schedule_interval == '@once': + self._schedule_interval = None + else: + self._schedule_interval = schedule_interval + if isinstance(template_searchpath, six.string_types): + template_searchpath = [template_searchpath] + self.template_searchpath = template_searchpath + self.parent_dag = None # Gets set when DAGs are loaded + self.last_loaded = datetime.utcnow() + self.safe_dag_id = dag_id.replace('.', '__dot__') + self.max_active_runs = max_active_runs + self.dagrun_timeout = dagrun_timeout + self.sla_miss_callback = sla_miss_callback + self.default_view = default_view + self.orientation = orientation + self.catchup = catchup + self.is_subdag = False # DagBag.bag_dag() will set this to True if appropriate + + self.partial = False + + self._comps = { + 'dag_id', + 'task_ids', + 'parent_dag', + 'start_date', + 'schedule_interval', + 'full_filepath', + 'template_searchpath', + 'last_loaded', + } + + def __repr__(self): + return "".format(self=self) + + def __eq__(self, other): + return ( + type(self) == type(other) and + # Use getattr() instead of __dict__ as __dict__ doesn't return + # correct values for properties. + all(getattr(self, c, None) == getattr(other, c, None) + for c in self._comps)) + + def __ne__(self, other): + return not self == other + + def __lt__(self, other): + return self.dag_id < other.dag_id + + def __hash__(self): + hash_components = [type(self)] + for c in self._comps: + # task_ids returns a list and lists can't be hashed + if c == 'task_ids': + val = tuple(self.task_dict.keys()) + else: + val = getattr(self, c, None) + try: + hash(val) + hash_components.append(val) + except TypeError: + hash_components.append(repr(val)) + return hash(tuple(hash_components)) + + # Context Manager ----------------------------------------------- + + def __enter__(self): + global _CONTEXT_MANAGER_DAG + self._old_context_manager_dag = _CONTEXT_MANAGER_DAG + _CONTEXT_MANAGER_DAG = self + return self + + def __exit__(self, _type, _value, _tb): + global _CONTEXT_MANAGER_DAG + _CONTEXT_MANAGER_DAG = self._old_context_manager_dag + + # /Context Manager ---------------------------------------------- + + def date_range(self, start_date, num=None, end_date=datetime.utcnow()): + if num: + end_date = None + return utils_date_range( + start_date=start_date, end_date=end_date, + num=num, delta=self._schedule_interval) + + def following_schedule(self, dttm): + if isinstance(self._schedule_interval, six.string_types): + cron = croniter(self._schedule_interval, dttm) + return cron.get_next(datetime) + elif isinstance(self._schedule_interval, timedelta): + return dttm + self._schedule_interval + + def previous_schedule(self, dttm): + if isinstance(self._schedule_interval, six.string_types): + cron = croniter(self._schedule_interval, dttm) + return cron.get_prev(datetime) + elif isinstance(self._schedule_interval, timedelta): + return dttm - self._schedule_interval + + def get_run_dates(self, start_date, end_date=None): + """ + Returns a list of dates between the interval received as parameter using this + dag's schedule interval. Returned dates can be used for execution dates. + :param start_date: the start date of the interval + :type start_date: datetime + :param end_date: the end date of the interval, defaults to datetime.utcnow() + :type end_date: datetime + :return: a list of dates within the interval following the dag's schedule + :rtype: list + """ + run_dates = [] + + using_start_date = start_date + using_end_date = end_date + + # dates for dag runs + using_start_date = using_start_date or min([t.start_date for t in self.tasks]) + using_end_date = using_end_date or datetime.utcnow() + + # next run date for a subdag isn't relevant (schedule_interval for subdags + # is ignored) so we use the dag run's start date in the case of a subdag + next_run_date = (self.normalize_schedule(using_start_date) + if not self.is_subdag else using_start_date) + + while next_run_date and next_run_date <= using_end_date: + run_dates.append(next_run_date) + next_run_date = self.following_schedule(next_run_date) + + return run_dates + + def normalize_schedule(self, dttm): + """ + Returns dttm + interval unless dttm is first interval then it returns dttm + """ + following = self.following_schedule(dttm) + + # in case of @once + if not following: + return dttm + + if self.previous_schedule(following) != dttm: + return following + + return dttm + + @provide_session + def get_last_dagrun(self, session=None, include_externally_triggered=False): + """ + Returns the last dag run for this dag, None if there was none. + Last dag run can be any type of run eg. scheduled or backfilled. + Overridden DagRuns are ignored + """ + DR = DagRun + qry = session.query(DR).filter( + DR.dag_id == self.dag_id, + ) + if not include_externally_triggered: + qry = qry.filter(DR.external_trigger.__eq__(False)) + + qry = qry.order_by(DR.execution_date.desc()) + + last = qry.first() + + return last + + @property + def dag_id(self): + return self._dag_id + + @dag_id.setter + def dag_id(self, value): + self._dag_id = value + + @property + def full_filepath(self): + return self._full_filepath + + @full_filepath.setter + def full_filepath(self, value): + self._full_filepath = value + + @property + def concurrency(self): + return self._concurrency + + @concurrency.setter + def concurrency(self, value): + self._concurrency = value + + @property + def description(self): + return self._description + + @property + def pickle_id(self): + return self._pickle_id + + @pickle_id.setter + def pickle_id(self, value): + self._pickle_id = value + + @property + def tasks(self): + return list(self.task_dict.values()) + + @tasks.setter + def tasks(self, val): + raise AttributeError( + 'DAG.tasks can not be modified. Use dag.add_task() instead.') + + @property + def task_ids(self): + return list(self.task_dict.keys()) + + @property + def active_task_ids(self): + return list(k for k, v in self.task_dict.items() if not v.adhoc) + + @property + def active_tasks(self): + return [t for t in self.tasks if not t.adhoc] + + @property + def filepath(self): + """ + File location of where the dag object is instantiated + """ + fn = self.full_filepath.replace(settings.DAGS_FOLDER + '/', '') + fn = fn.replace(os.path.dirname(__file__) + '/', '') + return fn + + @property + def folder(self): + """ + Folder location of where the dag object is instantiated + """ + return os.path.dirname(self.full_filepath) + + @property + def owner(self): + return ", ".join(list(set([t.owner for t in self.tasks]))) + + @property + @provide_session + def concurrency_reached(self, session=None): + """ + Returns a boolean indicating whether the concurrency limit for this DAG + has been reached + """ + TI = TaskInstance + qry = session.query(func.count(TI.task_id)).filter( + TI.dag_id == self.dag_id, + TI.task_id.in_(self.task_ids), + TI.state == State.RUNNING, + ) + return qry.scalar() >= self.concurrency + + @property + @provide_session + def is_paused(self, session=None): + """ + Returns a boolean indicating whether this DAG is paused + """ + qry = session.query(DagModel).filter( + DagModel.dag_id == self.dag_id) + return qry.value('is_paused') + + @provide_session + def get_active_runs(self, session=None): + """ + Returns a list of dag run execution dates currently running + :param session: + :return: List of execution dates + """ + runs = DagRun.find(dag_id=self.dag_id, state=State.RUNNING) + + active_dates = [] + for run in runs: + active_dates.append(run.execution_date) + + return active_dates + + @provide_session + def get_num_active_runs(self, external_trigger=None, session=None): + """ + Returns the number of active "running" dag runs + :param external_trigger: True for externally triggered active dag runs + :type external_trigger: bool + :param session: + :return: number greater than 0 for active dag runs + """ + query = (session + .query(DagRun) + .filter(DagRun.dag_id == self.dag_id) + .filter(DagRun.state == State.RUNNING)) + + if external_trigger is not None: + query = query.filter(DagRun.external_trigger == external_trigger) + + return query.count() + + @provide_session + def get_dagrun(self, execution_date, session=None): + """ + Returns the dag run for a given execution date if it exists, otherwise + none. + + :param execution_date: The execution date of the DagRun to find. + :param session: + :return: The DagRun if found, otherwise None. + """ + dagrun = ( + session.query(DagRun) + .filter( + DagRun.dag_id == self.dag_id, + DagRun.execution_date == execution_date) + .first()) + + return dagrun + + @property + def latest_execution_date(self): + """ + Returns the latest date for which at least one dag run exists + """ + session = settings.Session() + execution_date = session.query(func.max(DagRun.execution_date)).filter( + DagRun.dag_id == self.dag_id + ).scalar() + session.commit() + session.close() + return execution_date + + @property + def subdags(self): + """ + Returns a list of the subdag objects associated to this DAG + """ + # Check SubDag for class but don't check class directly, see + # https://github.com/airbnb/airflow/issues/1168 + from airflow.operators.subdag_operator import SubDagOperator + l = [] + for task in self.tasks: + if (isinstance(task, SubDagOperator) or + #TODO remove in Airflow 2.0 + type(task).__name__ == 'SubDagOperator'): + l.append(task.subdag) + l += task.subdag.subdags + return l + + def resolve_template_files(self): + for t in self.tasks: + t.resolve_template_files() + + def get_template_env(self): + """ + Returns a jinja2 Environment while taking into account the DAGs + template_searchpath, user_defined_macros and user_defined_filters + """ + searchpath = [self.folder] + if self.template_searchpath: + searchpath += self.template_searchpath + + env = jinja2.Environment( + loader=jinja2.FileSystemLoader(searchpath), + extensions=["jinja2.ext.do"], + cache_size=0) + if self.user_defined_macros: + env.globals.update(self.user_defined_macros) + if self.user_defined_filters: + env.filters.update(self.user_defined_filters) + + return env + + def set_dependency(self, upstream_task_id, downstream_task_id): + """ + Simple utility method to set dependency between two tasks that + already have been added to the DAG using add_task() + """ + self.get_task(upstream_task_id).set_downstream( + self.get_task(downstream_task_id)) + + def get_task_instances( + self, session, start_date=None, end_date=None, state=None): + TI = TaskInstance + if not start_date: + start_date = (datetime.utcnow() - timedelta(30)).date() + start_date = datetime.combine(start_date, datetime.min.time()) + end_date = end_date or datetime.utcnow() + tis = session.query(TI).filter( + TI.dag_id == self.dag_id, + TI.execution_date >= start_date, + TI.execution_date <= end_date, + TI.task_id.in_([t.task_id for t in self.tasks]), + ) + if state: + tis = tis.filter(TI.state == state) + tis = tis.order_by(TI.execution_date).all() + return tis + + @property + def roots(self): + return [t for t in self.tasks if not t.downstream_list] + + def topological_sort(self): + """ + Sorts tasks in topographical order, such that a task comes after any of its + upstream dependencies. + + Heavily inspired by: + http://blog.jupo.org/2012/04/06/topological-sorting-acyclic-directed-graphs/ + + :return: list of tasks in topological order + """ + + # copy the the tasks so we leave it unmodified + graph_unsorted = self.tasks[:] + + graph_sorted = [] + + # special case + if len(self.tasks) == 0: + return tuple(graph_sorted) + + # Run until the unsorted graph is empty. + while graph_unsorted: + # Go through each of the node/edges pairs in the unsorted + # graph. If a set of edges doesn't contain any nodes that + # haven't been resolved, that is, that are still in the + # unsorted graph, remove the pair from the unsorted graph, + # and append it to the sorted graph. Note here that by using + # using the items() method for iterating, a copy of the + # unsorted graph is used, allowing us to modify the unsorted + # graph as we move through it. We also keep a flag for + # checking that that graph is acyclic, which is true if any + # nodes are resolved during each pass through the graph. If + # not, we need to bail out as the graph therefore can't be + # sorted. + acyclic = False + for node in list(graph_unsorted): + for edge in node.upstream_list: + if edge in graph_unsorted: + break + # no edges in upstream tasks + else: + acyclic = True + graph_unsorted.remove(node) + graph_sorted.append(node) + + if not acyclic: + raise AirflowException("A cyclic dependency occurred in dag: {}" + .format(self.dag_id)) + + return tuple(graph_sorted) + + @provide_session + def set_dag_runs_state( + self, state=State.RUNNING, session=None): + drs = session.query(DagModel).filter_by(dag_id=self.dag_id).all() + dirty_ids = [] + for dr in drs: + dr.state = state + dirty_ids.append(dr.dag_id) + DagStat.update(dirty_ids, session=session) + + def clear( + self, start_date=None, end_date=None, + only_failed=False, + only_running=False, + confirm_prompt=False, + include_subdags=True, + reset_dag_runs=True, + dry_run=False): + """ + Clears a set of task instances associated with the current dag for + a specified date range. + """ + session = settings.Session() + TI = TaskInstance + tis = session.query(TI) + if include_subdags: + # Crafting the right filter for dag_id and task_ids combo + conditions = [] + for dag in self.subdags + [self]: + conditions.append( + TI.dag_id.like(dag.dag_id) & + TI.task_id.in_(dag.task_ids) + ) + tis = tis.filter(or_(*conditions)) + else: + tis = session.query(TI).filter(TI.dag_id == self.dag_id) + tis = tis.filter(TI.task_id.in_(self.task_ids)) + + if start_date: + tis = tis.filter(TI.execution_date >= start_date) + if end_date: + tis = tis.filter(TI.execution_date <= end_date) + if only_failed: + tis = tis.filter(TI.state == State.FAILED) + if only_running: + tis = tis.filter(TI.state == State.RUNNING) + + if dry_run: + tis = tis.all() + session.expunge_all() + return tis + + count = tis.count() + do_it = True + if count == 0: + return 0 + if confirm_prompt: + ti_list = "\n".join([str(t) for t in tis]) + question = ( + "You are about to delete these {count} tasks:\n" + "{ti_list}\n\n" + "Are you sure? (yes/no): ").format(**locals()) + do_it = utils.helpers.ask_yesno(question) + + if do_it: + clear_task_instances(tis.all(), session, dag=self) + if reset_dag_runs: + self.set_dag_runs_state(session=session) + else: + count = 0 + print("Bail. Nothing was cleared.") + + session.commit() + session.close() + return count + + @classmethod + def clear_dags( + cls, dags, + start_date=None, + end_date=None, + only_failed=False, + only_running=False, + confirm_prompt=False, + include_subdags=True, + reset_dag_runs=True, + dry_run=False): + all_tis = [] + for dag in dags: + tis = dag.clear( + start_date=start_date, + end_date=end_date, + only_failed=only_failed, + only_running=only_running, + confirm_prompt=False, + include_subdags=include_subdags, + reset_dag_runs=reset_dag_runs, + dry_run=True) + all_tis.extend(tis) + + if dry_run: + return all_tis + + count = len(all_tis) + do_it = True + if count == 0: + print("Nothing to clear.") + return 0 + if confirm_prompt: + ti_list = "\n".join([str(t) for t in all_tis]) + question = ( + "You are about to delete these {} tasks:\n" + "{}\n\n" + "Are you sure? (yes/no): ").format(count, ti_list) + do_it = utils.helpers.ask_yesno(question) + + if do_it: + for dag in dags: + dag.clear(start_date=start_date, + end_date=end_date, + only_failed=only_failed, + only_running=only_running, + confirm_prompt=False, + include_subdags=include_subdags, + reset_dag_runs=reset_dag_runs, + dry_run=False) + else: + count = 0 + print("Bail. Nothing was cleared.") + return count + + def __deepcopy__(self, memo): + # Swiwtcharoo to go around deepcopying objects coming through the + # backdoor + cls = self.__class__ + result = cls.__new__(cls) + memo[id(self)] = result + for k, v in list(self.__dict__.items()): + if k not in ('user_defined_macros', 'user_defined_filters', 'params'): + setattr(result, k, copy.deepcopy(v, memo)) + + result.user_defined_macros = self.user_defined_macros + result.user_defined_filters = self.user_defined_filters + result.params = self.params + return result + + def sub_dag(self, task_regex, include_downstream=False, + include_upstream=True): + """ + Returns a subset of the current dag as a deep copy of the current dag + based on a regex that should match one or many tasks, and includes + upstream and downstream neighbours based on the flag passed. + """ + + dag = copy.deepcopy(self) + + regex_match = [ + t for t in dag.tasks if re.findall(task_regex, t.task_id)] + also_include = [] + for t in regex_match: + if include_downstream: + also_include += t.get_flat_relatives(upstream=False) + if include_upstream: + also_include += t.get_flat_relatives(upstream=True) + + # Compiling the unique list of tasks that made the cut + dag.task_dict = {t.task_id: t for t in regex_match + also_include} + for t in dag.tasks: + # Removing upstream/downstream references to tasks that did not + # made the cut + t._upstream_task_ids = [ + tid for tid in t._upstream_task_ids if tid in dag.task_ids] + t._downstream_task_ids = [ + tid for tid in t._downstream_task_ids if tid in dag.task_ids] + + if len(dag.tasks) < len(self.tasks): + dag.partial = True + + return dag + + def has_task(self, task_id): + return task_id in (t.task_id for t in self.tasks) + + def get_task(self, task_id): + if task_id in self.task_dict: + return self.task_dict[task_id] + raise AirflowException("Task {task_id} not found".format(**locals())) + + @provide_session + def pickle_info(self, session=None): + d = {} + d['is_picklable'] = True + try: + dttm = datetime.utcnow() + pickled = pickle.dumps(self) + d['pickle_len'] = len(pickled) + d['pickling_duration'] = "{}".format(datetime.utcnow() - dttm) + except Exception as e: + self.log.debug(e) + d['is_picklable'] = False + d['stacktrace'] = traceback.format_exc() + return d + + @provide_session + def pickle(self, session=None): + dag = session.query( + DagModel).filter(DagModel.dag_id == self.dag_id).first() + dp = None + if dag and dag.pickle_id: + dp = session.query(DagPickle).filter( + DagPickle.id == dag.pickle_id).first() + if not dp or dp.pickle != self: + dp = DagPickle(dag=self) + session.add(dp) + self.last_pickled = datetime.utcnow() + session.commit() + self.pickle_id = dp.id + + return dp + + def tree_view(self): + """ + Shows an ascii tree representation of the DAG + """ + def get_downstream(task, level=0): + print((" " * level * 4) + str(task)) + level += 1 + for t in task.upstream_list: + get_downstream(t, level) + + for t in self.roots: + get_downstream(t) + + def add_task(self, task): + """ + Add a task to the DAG + + :param task: the task you want to add + :type task: task + """ + if not self.start_date and not task.start_date: + raise AirflowException("Task is missing the start_date parameter") + # if the task has no start date, assign it the same as the DAG + elif not task.start_date: + task.start_date = self.start_date + # otherwise, the task will start on the later of its own start date and + # the DAG's start date + elif self.start_date: + task.start_date = max(task.start_date, self.start_date) + + # if the task has no end date, assign it the same as the dag + if not task.end_date: + task.end_date = self.end_date + # otherwise, the task will end on the earlier of its own end date and + # the DAG's end date + elif task.end_date and self.end_date: + task.end_date = min(task.end_date, self.end_date) + + if task.task_id in self.task_dict: + # TODO: raise an error in Airflow 2.0 + warnings.warn( + 'The requested task could not be added to the DAG because a ' + 'task with task_id {} is already in the DAG. Starting in ' + 'Airflow 2.0, trying to overwrite a task will raise an ' + 'exception.'.format(task.task_id), + category=PendingDeprecationWarning) + else: + self.tasks.append(task) + self.task_dict[task.task_id] = task + task.dag = self + + self.task_count = len(self.tasks) + + def add_tasks(self, tasks): + """ + Add a list of tasks to the DAG + + :param tasks: a lit of tasks you want to add + :type tasks: list of tasks + """ + for task in tasks: + self.add_task(task) + + def db_merge(self): + BO = BaseOperator + session = settings.Session() + tasks = session.query(BO).filter(BO.dag_id == self.dag_id).all() + for t in tasks: + session.delete(t) + session.commit() + session.merge(self) + session.commit() + + def run( + self, + start_date=None, + end_date=None, + mark_success=False, + include_adhoc=False, + local=False, + executor=None, + donot_pickle=configuration.getboolean('core', 'donot_pickle'), + ignore_task_deps=False, + ignore_first_depends_on_past=False, + pool=None, + delay_on_limit_secs=1.0): + """ + Runs the DAG. + :param start_date: the start date of the range to run + :type start_date: datetime + :param end_date: the end date of the range to run + :type end_date: datetime + :param mark_success: True to mark jobs as succeeded without running them + :type mark_success: bool + :param include_adhoc: True to include dags with the adhoc parameter + :type include_adhoc: bool + :param local: True to run the tasks using the LocalExecutor + :type local: bool + :param executor: The executor instance to run the tasks + :type executor: BaseExecutor + :param donot_pickle: True to avoid pickling DAG object and send to workers + :type donot_pickle: bool + :param ignore_task_deps: True to skip upstream tasks + :type ignore_task_deps: bool + :param ignore_first_depends_on_past: True to ignore depends_on_past + dependencies for the first set of tasks only + :type ignore_first_depends_on_past: bool + :param pool: Resource pool to use + :type pool: string + :param delay_on_limit_secs: Time in seconds to wait before next attempt to run + dag run when max_active_runs limit has been reached + :type delay_on_limit_secs: float + """ + from airflow.jobs import BackfillJob + if not executor and local: + executor = LocalExecutor() + elif not executor: + executor = GetDefaultExecutor() + job = BackfillJob( + self, + start_date=start_date, + end_date=end_date, + mark_success=mark_success, + include_adhoc=include_adhoc, + executor=executor, + donot_pickle=donot_pickle, + ignore_task_deps=ignore_task_deps, + ignore_first_depends_on_past=ignore_first_depends_on_past, + pool=pool, + delay_on_limit_secs=delay_on_limit_secs) + job.run() + + def cli(self): + """ + Exposes a CLI specific to this DAG + """ + from airflow.bin import cli + parser = cli.CLIFactory.get_parser(dag_parser=True) + args = parser.parse_args() + args.func(args, self) + + @provide_session + def create_dagrun(self, + run_id, + state, + execution_date=None, + start_date=None, + external_trigger=False, + conf=None, + session=None): + """ + Creates a dag run from this dag including the tasks associated with this dag. + Returns the dag run. + + :param run_id: defines the the run id for this dag run + :type run_id: string + :param execution_date: the execution date of this dag run + :type execution_date: datetime + :param state: the state of the dag run + :type state: State + :param start_date: the date this dag run should be evaluated + :type start_date: datetime + :param external_trigger: whether this dag run is externally triggered + :type external_trigger: bool + :param session: database session + :type session: Session + """ + run = DagRun( + dag_id=self.dag_id, + run_id=run_id, + execution_date=execution_date, + start_date=start_date, + external_trigger=external_trigger, + conf=conf, + state=state + ) + session.add(run) + + DagStat.set_dirty(dag_id=self.dag_id, session=session) + + session.commit() + + run.dag = self + + # create the associated task instances + # state is None at the moment of creation + run.verify_integrity(session=session) + + run.refresh_from_db() + + return run + + @provide_session + def sync_to_db(self, owner=None, sync_time=None, session=None): + """ + Save attributes about this DAG to the DB. Note that this method + can be called for both DAGs and SubDAGs. A SubDag is actually a + SubDagOperator. + + :param dag: the DAG object to save to the DB + :type dag: DAG + :param sync_time: The time that the DAG should be marked as sync'ed + :type sync_time: datetime + :return: None + """ + + if owner is None: + owner = self.owner + if sync_time is None: + sync_time = datetime.utcnow() + + orm_dag = session.query( + DagModel).filter(DagModel.dag_id == self.dag_id).first() + if not orm_dag: + orm_dag = DagModel(dag_id=self.dag_id) + self.log.info("Creating ORM DAG for %s", self.dag_id) + orm_dag.fileloc = self.fileloc + orm_dag.is_subdag = self.is_subdag + orm_dag.owners = owner + orm_dag.is_active = True + orm_dag.last_scheduler_run = sync_time + session.merge(orm_dag) + session.commit() + + for subdag in self.subdags: + subdag.sync_to_db(owner=owner, sync_time=sync_time, session=session) + + @staticmethod + @provide_session + def deactivate_unknown_dags(active_dag_ids, session=None): + """ + Given a list of known DAGs, deactivate any other DAGs that are + marked as active in the ORM + + :param active_dag_ids: list of DAG IDs that are active + :type active_dag_ids: list[unicode] + :return: None + """ + + if len(active_dag_ids) == 0: + return + for dag in session.query( + DagModel).filter(~DagModel.dag_id.in_(active_dag_ids)).all(): + dag.is_active = False + session.merge(dag) + + @staticmethod + @provide_session + def deactivate_stale_dags(expiration_date, session=None): + """ + Deactivate any DAGs that were last touched by the scheduler before + the expiration date. These DAGs were likely deleted. + + :param expiration_date: set inactive DAGs that were touched before this + time + :type expiration_date: datetime + :return: None + """ + log = LoggingMixin().log + for dag in session.query( + DagModel).filter(DagModel.last_scheduler_run < expiration_date, + DagModel.is_active).all(): + log.info( + "Deactivating DAG ID %s since it was last touched by the scheduler at %s", + dag.dag_id, dag.last_scheduler_run.isoformat() + ) + dag.is_active = False + session.merge(dag) + session.commit() + + @staticmethod + @provide_session + def get_num_task_instances(dag_id, task_ids, states=None, session=None): + """ + Returns the number of task instances in the given DAG. + + :param session: ORM session + :param dag_id: ID of the DAG to get the task concurrency of + :type dag_id: unicode + :param task_ids: A list of valid task IDs for the given DAG + :type task_ids: list[unicode] + :param states: A list of states to filter by if supplied + :type states: list[state] + :return: The number of running tasks + :rtype: int + """ + qry = session.query(func.count(TaskInstance.task_id)).filter( + TaskInstance.dag_id == dag_id, + TaskInstance.task_id.in_(task_ids)) + if states is not None: + if None in states: + qry = qry.filter(or_( + TaskInstance.state.in_(states), + TaskInstance.state.is_(None))) + else: + qry = qry.filter(TaskInstance.state.in_(states)) + return qry.scalar() + + +class Chart(Base): + __tablename__ = "chart" + + id = Column(Integer, primary_key=True) + label = Column(String(200)) + conn_id = Column(String(ID_LEN), nullable=False) + user_id = Column(Integer(), ForeignKey('users.id'), nullable=True) + chart_type = Column(String(100), default="line") + sql_layout = Column(String(50), default="series") + sql = Column(Text, default="SELECT series, x, y FROM table") + y_log_scale = Column(Boolean) + show_datatable = Column(Boolean) + show_sql = Column(Boolean, default=True) + height = Column(Integer, default=600) + default_params = Column(String(5000), default="{}") + owner = relationship( + "User", cascade=False, cascade_backrefs=False, backref='charts') + x_is_date = Column(Boolean, default=True) + iteration_no = Column(Integer, default=0) + last_modified = Column(DateTime, default=func.now()) + + def __repr__(self): + return self.label + + +class KnownEventType(Base): + __tablename__ = "known_event_type" + + id = Column(Integer, primary_key=True) + know_event_type = Column(String(200)) + + def __repr__(self): + return self.know_event_type + + +class KnownEvent(Base): + __tablename__ = "known_event" + + id = Column(Integer, primary_key=True) + label = Column(String(200)) + start_date = Column(DateTime) + end_date = Column(DateTime) + user_id = Column(Integer(), ForeignKey('users.id'),) + known_event_type_id = Column(Integer(), ForeignKey('known_event_type.id'),) + reported_by = relationship( + "User", cascade=False, cascade_backrefs=False, backref='known_events') + event_type = relationship( + "KnownEventType", + cascade=False, + cascade_backrefs=False, backref='known_events') + description = Column(Text) + + def __repr__(self): + return self.label + + +class Variable(Base, LoggingMixin): + __tablename__ = "variable" + + id = Column(Integer, primary_key=True) + key = Column(String(ID_LEN), unique=True) + _val = Column('val', Text) + is_encrypted = Column(Boolean, unique=False, default=False) + + def __repr__(self): + # Hiding the value + return '{} : {}'.format(self.key, self._val) + + def get_val(self): + if self._val and self.is_encrypted: + try: + fernet = get_fernet() + except: + raise AirflowException( + "Can't decrypt _val for key={}, FERNET_KEY configuration \ + missing".format(self.key)) + try: + return fernet.decrypt(bytes(self._val, 'utf-8')).decode() + except: + raise AirflowException( + "Can't decrypt _val for key={}, invalid token or value" + .format(self.key)) + else: + return self._val + + def set_val(self, value): + if value: + try: + fernet = get_fernet() + self._val = fernet.encrypt(bytes(value, 'utf-8')).decode() + self.is_encrypted = True + except AirflowException: + self.log.exception( + "Failed to load fernet while encrypting value, using non-encrypted value." + ) + self._val = value + self.is_encrypted = False + + @declared_attr + def val(cls): + return synonym('_val', + descriptor=property(cls.get_val, cls.set_val)) + + @classmethod + def setdefault(cls, key, default, deserialize_json=False): + """ + Like a Python builtin dict object, setdefault returns the current value + for a key, and if it isn't there, stores the default value and returns it. + + :param key: Dict key for this Variable + :type key: String + :param default: Default value to set and return if the variable + isn't already in the DB + :type default: Mixed + :param deserialize_json: Store this as a JSON encoded value in the DB + and un-encode it when retrieving a value + :return: Mixed + """ + default_sentinel = object() + obj = Variable.get(key, default_var=default_sentinel, deserialize_json=deserialize_json) + if obj is default_sentinel: + if default is not None: + Variable.set(key, default, serialize_json=deserialize_json) + return default + else: + raise ValueError('Default Value must be set') + else: + return obj + + @classmethod + @provide_session + def get(cls, key, default_var=None, deserialize_json=False, session=None): + obj = session.query(cls).filter(cls.key == key).first() + if obj is None: + if default_var is not None: + return default_var + else: + raise KeyError('Variable {} does not exist'.format(key)) + else: + if deserialize_json: + return json.loads(obj.val) + else: + return obj.val + + @classmethod + @provide_session + def set(cls, key, value, serialize_json=False, session=None): + + if serialize_json: + stored_value = json.dumps(value) + else: + stored_value = value + + session.query(cls).filter(cls.key == key).delete() + session.add(Variable(key=key, val=stored_value)) + session.flush() + + +class XCom(Base, LoggingMixin): + """ + Base class for XCom objects. + """ + __tablename__ = "xcom" + + id = Column(Integer, primary_key=True) + key = Column(String(512)) + value = Column(LargeBinary) + timestamp = Column( + DateTime, default=func.now(), nullable=False) + execution_date = Column(DateTime, nullable=False) + + # source information + task_id = Column(String(ID_LEN), nullable=False) + dag_id = Column(String(ID_LEN), nullable=False) + + __table_args__ = ( + Index('idx_xcom_dag_task_date', dag_id, task_id, execution_date, unique=False), + ) + + def __repr__(self): + return ''.format( + key=self.key, + task_id=self.task_id, + execution_date=self.execution_date) + + @classmethod + @provide_session + def set( + cls, + key, + value, + execution_date, + task_id, + dag_id, + enable_pickling=None, + session=None): + """ + Store an XCom value. + TODO: "pickling" has been deprecated and JSON is preferred. "pickling" will be + removed in Airflow 2.0. :param enable_pickling: If pickling is not enabled, the + XCOM value will be parsed as JSON instead. + :return: None + """ + session.expunge_all() + + if enable_pickling is None: + enable_pickling = configuration.getboolean('core', 'enable_xcom_pickling') + + if enable_pickling: + value = pickle.dumps(value) + else: + try: + value = json.dumps(value).encode('UTF-8') + except ValueError: + log = LoggingMixin().log + log.error("Could not serialize the XCOM value into JSON. " + "If you are using pickles instead of JSON " + "for XCOM, then you need to enable pickle " + "support for XCOM in your airflow config.") + raise + + # remove any duplicate XComs + session.query(cls).filter( + cls.key == key, + cls.execution_date == execution_date, + cls.task_id == task_id, + cls.dag_id == dag_id).delete() + + session.commit() + + # insert new XCom + session.add(XCom( + key=key, + value=value, + execution_date=execution_date, + task_id=task_id, + dag_id=dag_id)) + + session.commit() + + @classmethod + @provide_session + def get_one( + cls, + execution_date, + key=None, + task_id=None, + dag_id=None, + include_prior_dates=False, + enable_pickling=None, + session=None): + """ + Retrieve an XCom value, optionally meeting certain criteria. + TODO: "pickling" has been deprecated and JSON is preferred. "pickling" will be removed in Airflow 2.0. + :param enable_pickling: If pickling is not enabled, the XCOM value will be parsed to JSON instead. + :return: XCom value + """ + filters = [] + if key: + filters.append(cls.key == key) + if task_id: + filters.append(cls.task_id == task_id) + if dag_id: + filters.append(cls.dag_id == dag_id) + if include_prior_dates: + filters.append(cls.execution_date <= execution_date) + else: + filters.append(cls.execution_date == execution_date) + + query = ( + session.query(cls.value) + .filter(and_(*filters)) + .order_by(cls.execution_date.desc(), cls.timestamp.desc())) + + result = query.first() + if result: + if enable_pickling is None: + enable_pickling = configuration.getboolean('core', 'enable_xcom_pickling') + + if enable_pickling: + return pickle.loads(result.value) + else: + try: + return json.loads(result.value.decode('UTF-8')) + except ValueError: + log = LoggingMixin().log + log.error("Could not serialize the XCOM value into JSON. " + "If you are using pickles instead of JSON " + "for XCOM, then you need to enable pickle " + "support for XCOM in your airflow config.") + raise + + @classmethod + @provide_session + def get_many( + cls, + execution_date, + key=None, + task_ids=None, + dag_ids=None, + include_prior_dates=False, + limit=100, + enable_pickling=None, + session=None): + """ + Retrieve an XCom value, optionally meeting certain criteria + TODO: "pickling" has been deprecated and JSON is preferred. "pickling" will be removed in Airflow 2.0. + """ + filters = [] + if key: + filters.append(cls.key == key) + if task_ids: + filters.append(cls.task_id.in_(as_tuple(task_ids))) + if dag_ids: + filters.append(cls.dag_id.in_(as_tuple(dag_ids))) + if include_prior_dates: + filters.append(cls.execution_date <= execution_date) + else: + filters.append(cls.execution_date == execution_date) + + query = ( + session.query(cls) + .filter(and_(*filters)) + .order_by(cls.execution_date.desc(), cls.timestamp.desc()) + .limit(limit)) + results = query.all() + if enable_pickling is None: + enable_pickling = configuration.getboolean('core', 'enable_xcom_pickling') + for result in results: + if enable_pickling: + result.value = pickle.loads(result.value) + else: + try: + result.value = json.loads(result.value.decode('UTF-8')) + except ValueError: + log = LoggingMixin().log + log.error("Could not serialize the XCOM value into JSON. " + "If you are using pickles instead of JSON " + "for XCOM, then you need to enable pickle " + "support for XCOM in your airflow config.") + raise + return results + + @classmethod + @provide_session + def delete(cls, xcoms, session=None): + if isinstance(xcoms, XCom): + xcoms = [xcoms] + for xcom in xcoms: + if not isinstance(xcom, XCom): + raise TypeError( + 'Expected XCom; received {}'.format(xcom.__class__.__name__) + ) + session.delete(xcom) + session.commit() + + +class DagStat(Base): + __tablename__ = "dag_stats" + + dag_id = Column(String(ID_LEN), primary_key=True) + state = Column(String(50), primary_key=True) + count = Column(Integer, default=0) + dirty = Column(Boolean, default=False) + + def __init__(self, dag_id, state, count=0, dirty=False): + self.dag_id = dag_id + self.state = state + self.count = count + self.dirty = dirty + + @staticmethod + @provide_session + def set_dirty(dag_id, session=None): + """ + :param dag_id: the dag_id to mark dirty + :param session: database session + :return: + """ + DagStat.create(dag_id=dag_id, session=session) + + try: + stats = session.query(DagStat).filter( + DagStat.dag_id == dag_id + ).with_for_update().all() + + for stat in stats: + stat.dirty = True + session.commit() + except Exception as e: + session.rollback() + log = LoggingMixin().log + log.warning("Could not update dag stats for %s", dag_id) + log.exception(e) + + @staticmethod + @provide_session + def update(dag_ids=None, dirty_only=True, session=None): + """ + Updates the stats for dirty/out-of-sync dags + + :param dag_ids: dag_ids to be updated + :type dag_ids: list + :param dirty_only: only updated for marked dirty, defaults to True + :type dirty_only: bool + :param session: db session to use + :type session: Session + """ + try: + qry = session.query(DagStat) + if dag_ids: + qry = qry.filter(DagStat.dag_id.in_(set(dag_ids))) + if dirty_only: + qry = qry.filter(DagStat.dirty == True) + + qry = qry.with_for_update().all() + + ids = set([dag_stat.dag_id for dag_stat in qry]) + + # avoid querying with an empty IN clause + if len(ids) == 0: + session.commit() + return + + dagstat_states = set(itertools.product(ids, State.dag_states)) + qry = ( + session.query(DagRun.dag_id, DagRun.state, func.count('*')) + .filter(DagRun.dag_id.in_(ids)) + .group_by(DagRun.dag_id, DagRun.state) + ) + + counts = {(dag_id, state): count for dag_id, state, count in qry} + for dag_id, state in dagstat_states: + count = 0 + if (dag_id, state) in counts: + count = counts[(dag_id, state)] + + session.merge( + DagStat(dag_id=dag_id, state=state, count=count, dirty=False) + ) + + session.commit() + except Exception as e: + session.rollback() + log = LoggingMixin().log + log.warning("Could not update dag stat table") + log.exception(e) + + @staticmethod + @provide_session + def create(dag_id, session=None): + """ + Creates the missing states the stats table for the dag specified + + :param dag_id: dag id of the dag to create stats for + :param session: database session + :return: + """ + # unfortunately sqlalchemy does not know upsert + qry = session.query(DagStat).filter(DagStat.dag_id == dag_id).all() + states = [dag_stat.state for dag_stat in qry] + for state in State.dag_states: + if state not in states: + try: + session.merge(DagStat(dag_id=dag_id, state=state)) + session.commit() + except Exception as e: + session.rollback() + log = LoggingMixin().log + log.warning("Could not create stat record") + log.exception(e) + + +class DagRun(Base, LoggingMixin): + """ + DagRun describes an instance of a Dag. It can be created + by the scheduler (for regular runs) or by an external trigger + """ + __tablename__ = "dag_run" + + ID_PREFIX = 'scheduled__' + ID_FORMAT_PREFIX = ID_PREFIX + '{0}' + + id = Column(Integer, primary_key=True) + dag_id = Column(String(ID_LEN)) + execution_date = Column(DateTime, default=func.now()) + start_date = Column(DateTime, default=func.now()) + end_date = Column(DateTime) + _state = Column('state', String(50), default=State.RUNNING) + run_id = Column(String(ID_LEN)) + external_trigger = Column(Boolean, default=True) + conf = Column(PickleType) + + dag = None + + __table_args__ = ( + Index('dr_run_id', dag_id, run_id, unique=True), + ) + + def __repr__(self): + return ( + '' + ).format( + dag_id=self.dag_id, + execution_date=self.execution_date, + run_id=self.run_id, + external_trigger=self.external_trigger) + + def get_state(self): + return self._state + + def set_state(self, state): + if self._state != state: + self._state = state + if self.dag_id is not None: + # something really weird goes on here: if you try to close the session + # dag runs will end up detached + session = settings.Session() + DagStat.set_dirty(self.dag_id, session=session) + + @declared_attr + def state(self): + return synonym('_state', + descriptor=property(self.get_state, self.set_state)) + + @classmethod + def id_for_date(cls, date, prefix=ID_FORMAT_PREFIX): + return prefix.format(date.isoformat()[:19]) + + @provide_session + def refresh_from_db(self, session=None): + """ + Reloads the current dagrun from the database + :param session: database session + """ + DR = DagRun + + exec_date = func.cast(self.execution_date, DateTime) + + dr = session.query(DR).filter( + DR.dag_id == self.dag_id, + func.cast(DR.execution_date, DateTime) == exec_date, + DR.run_id == self.run_id + ).one() + + self.id = dr.id + self.state = dr.state + + @staticmethod + @provide_session + def find(dag_id=None, run_id=None, execution_date=None, + state=None, external_trigger=None, no_backfills=False, + session=None): + """ + Returns a set of dag runs for the given search criteria. + :param dag_id: the dag_id to find dag runs for + :type dag_id: integer, list + :param run_id: defines the the run id for this dag run + :type run_id: string + :param execution_date: the execution date + :type execution_date: datetime + :param state: the state of the dag run + :type state: State + :param external_trigger: whether this dag run is externally triggered + :type external_trigger: bool + :param no_backfills: return no backfills (True), return all (False). + Defaults to False + :type no_backfills: bool + :param session: database session + :type session: Session + """ + DR = DagRun + + qry = session.query(DR) + if dag_id: + qry = qry.filter(DR.dag_id == dag_id) + if run_id: + qry = qry.filter(DR.run_id == run_id) + if execution_date: + if isinstance(execution_date, list): + qry = qry.filter(DR.execution_date.in_(execution_date)) + else: + qry = qry.filter(DR.execution_date == execution_date) + if state: + qry = qry.filter(DR.state == state) + if external_trigger is not None: + qry = qry.filter(DR.external_trigger == external_trigger) + if no_backfills: + # in order to prevent a circular dependency + from airflow.jobs import BackfillJob + qry = qry.filter(DR.run_id.notlike(BackfillJob.ID_PREFIX + '%')) + + dr = qry.order_by(DR.execution_date).all() + + return dr + + @provide_session + def get_task_instances(self, state=None, session=None): + """ + Returns the task instances for this dag run + """ + TI = TaskInstance + tis = session.query(TI).filter( + TI.dag_id == self.dag_id, + TI.execution_date == self.execution_date, + ) + if state: + if isinstance(state, six.string_types): + tis = tis.filter(TI.state == state) + else: + # this is required to deal with NULL values + if None in state: + tis = tis.filter( + or_(TI.state.in_(state), + TI.state.is_(None)) + ) + else: + tis = tis.filter(TI.state.in_(state)) + + if self.dag and self.dag.partial: + tis = tis.filter(TI.task_id.in_(self.dag.task_ids)) + + return tis.all() + + @provide_session + def get_task_instance(self, task_id, session=None): + """ + Returns the task instance specified by task_id for this dag run + :param task_id: the task id + """ + + TI = TaskInstance + ti = session.query(TI).filter( + TI.dag_id == self.dag_id, + TI.execution_date == self.execution_date, + TI.task_id == task_id + ).first() + + return ti + + def get_dag(self): + """ + Returns the Dag associated with this DagRun. + + :return: DAG + """ + if not self.dag: + raise AirflowException("The DAG (.dag) for {} needs to be set" + .format(self)) + + return self.dag + + @provide_session + def get_previous_dagrun(self, session=None): + """The previous DagRun, if there is one""" + + return session.query(DagRun).filter( + DagRun.dag_id == self.dag_id, + DagRun.execution_date < self.execution_date + ).order_by( + DagRun.execution_date.desc() + ).first() + + @provide_session + def get_previous_scheduled_dagrun(self, session=None): + """The previous, SCHEDULED DagRun, if there is one""" + dag = self.get_dag() + + return session.query(DagRun).filter( + DagRun.dag_id == self.dag_id, + DagRun.execution_date == dag.previous_schedule(self.execution_date) + ).first() + + @provide_session + def update_state(self, session=None): + """ + Determines the overall state of the DagRun based on the state + of its TaskInstances. + :return: State + """ + + dag = self.get_dag() + + tis = self.get_task_instances(session=session) + + self.log.info("Updating state for %s considering %s task(s)", self, len(tis)) + + for ti in list(tis): + # skip in db? + if ti.state == State.REMOVED: + tis.remove(ti) + else: + ti.task = dag.get_task(ti.task_id) + + # pre-calculate + # db is faster + start_dttm = datetime.utcnow() + unfinished_tasks = self.get_task_instances( + state=State.unfinished(), + session=session + ) + none_depends_on_past = all(not t.task.depends_on_past for t in unfinished_tasks) + none_task_concurrency = all(t.task.task_concurrency is None for t in unfinished_tasks) + # small speed up + if unfinished_tasks and none_depends_on_past and none_task_concurrency: + # todo: this can actually get pretty slow: one task costs between 0.01-015s + no_dependencies_met = True + for ut in unfinished_tasks: + # We need to flag upstream and check for changes because upstream + # failures can result in deadlock false positives + old_state = ut.state + deps_met = ut.are_dependencies_met( + dep_context=DepContext( + flag_upstream_failed=True, + ignore_in_retry_period=True), + session=session) + if deps_met or old_state != ut.current_state(session=session): + no_dependencies_met = False + break + + duration = (datetime.utcnow() - start_dttm).total_seconds() * 1000 + Stats.timing("dagrun.dependency-check.{}.{}". + format(self.dag_id, self.execution_date), duration) + + # future: remove the check on adhoc tasks (=active_tasks) + if len(tis) == len(dag.active_tasks): + root_ids = [t.task_id for t in dag.roots] + roots = [t for t in tis if t.task_id in root_ids] + + # if all roots finished and at least on failed, the run failed + if (not unfinished_tasks and + any(r.state in (State.FAILED, State.UPSTREAM_FAILED) for r in roots)): + self.log.info('Marking run %s failed', self) + self.state = State.FAILED + + # if all roots succeeded and no unfinished tasks, the run succeeded + elif not unfinished_tasks and all(r.state in (State.SUCCESS, State.SKIPPED) + for r in roots): + self.log.info('Marking run %s successful', self) + self.state = State.SUCCESS + + # if *all tasks* are deadlocked, the run failed + elif (unfinished_tasks and none_depends_on_past and + none_task_concurrency and no_dependencies_met): + self.log.info('Deadlock; marking run %s failed', self) + self.state = State.FAILED + + # finally, if the roots aren't done, the dag is still running + else: + self.state = State.RUNNING + + # todo: determine we want to use with_for_update to make sure to lock the run + session.merge(self) + session.commit() + + return self.state + + @provide_session + def verify_integrity(self, session=None): + """ + Verifies the DagRun by checking for removed tasks or tasks that are not in the + database yet. It will set state to removed or add the task if required. + """ + dag = self.get_dag() + tis = self.get_task_instances(session=session) + + # check for removed tasks + task_ids = [] + for ti in tis: + task_ids.append(ti.task_id) + try: + dag.get_task(ti.task_id) + except AirflowException: + if self.state is not State.RUNNING and not dag.partial: + ti.state = State.REMOVED + + # check for missing tasks + for task in dag.tasks: + if task.adhoc: + continue + + if task.task_id not in task_ids: + ti = TaskInstance(task, self.execution_date) + session.add(ti) + + session.commit() + + @staticmethod + def get_run(session, dag_id, execution_date): + """ + :param dag_id: DAG ID + :type dag_id: unicode + :param execution_date: execution date + :type execution_date: datetime + :return: DagRun corresponding to the given dag_id and execution date + if one exists. None otherwise. + :rtype: DagRun + """ + qry = session.query(DagRun).filter( + DagRun.dag_id == dag_id, + DagRun.external_trigger == False, + DagRun.execution_date == execution_date, + ) + return qry.first() + + @property + def is_backfill(self): + from airflow.jobs import BackfillJob + return self.run_id.startswith(BackfillJob.ID_PREFIX) + + @classmethod + @provide_session + def get_latest_runs(cls, session): + """Returns the latest DagRun for each DAG. """ + subquery = ( + session + .query( + cls.dag_id, + func.max(cls.execution_date).label('execution_date')) + .group_by(cls.dag_id) + .subquery() + ) + dagruns = ( + session + .query(cls) + .join(subquery, + and_(cls.dag_id == subquery.c.dag_id, + cls.execution_date == subquery.c.execution_date)) + .all() + ) + return dagruns + + +class Pool(Base): + __tablename__ = "slot_pool" + + id = Column(Integer, primary_key=True) + pool = Column(String(50), unique=True) + slots = Column(Integer, default=0) + description = Column(Text) + + def __repr__(self): + return self.pool + + def to_json(self): + return { + 'id': self.id, + 'pool': self.pool, + 'slots': self.slots, + 'description': self.description, + } + + @provide_session + def used_slots(self, session): + """ + Returns the number of slots used at the moment + """ + running = ( + session + .query(TaskInstance) + .filter(TaskInstance.pool == self.pool) + .filter(TaskInstance.state == State.RUNNING) + .count() + ) + return running + + @provide_session + def queued_slots(self, session): + """ + Returns the number of slots used at the moment + """ + return ( + session + .query(TaskInstance) + .filter(TaskInstance.pool == self.pool) + .filter(TaskInstance.state == State.QUEUED) + .count() + ) + + @provide_session + def open_slots(self, session): + """ + Returns the number of slots open at the moment + """ + used_slots = self.used_slots(session=session) + queued_slots = self.queued_slots(session=session) + return self.slots - used_slots - queued_slots + + +class SlaMiss(Base): + """ + Model that stores a history of the SLA that have been missed. + It is used to keep track of SLA failures over time and to avoid double + triggering alert emails. + """ + __tablename__ = "sla_miss" + + task_id = Column(String(ID_LEN), primary_key=True) + dag_id = Column(String(ID_LEN), primary_key=True) + execution_date = Column(DateTime, primary_key=True) + email_sent = Column(Boolean, default=False) + timestamp = Column(DateTime) + description = Column(Text) + notification_sent = Column(Boolean, default=False) + + def __repr__(self): + return str(( + self.dag_id, self.task_id, self.execution_date.isoformat())) + + +class ImportError(Base): + __tablename__ = "import_error" + id = Column(Integer, primary_key=True) + timestamp = Column(DateTime) + filename = Column(String(1024)) + stacktrace = Column(Text) diff --git a/airflow/operators/__init__.py b/airflow/operators/__init__.py new file mode 100644 index 0000000000000..50b05ff4618ce --- /dev/null +++ b/airflow/operators/__init__.py @@ -0,0 +1,123 @@ +# -*- coding: utf-8 -*- +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + + +import sys +from airflow.models import BaseOperator + + +# ------------------------------------------------------------------------ +# +# #TODO #FIXME Airflow 2.0 +# +# Old import machinary below. +# +# This is deprecated but should be kept until Airflow 2.0 +# for compatibility. +# +# ------------------------------------------------------------------------ + + +# Imports operators dynamically while keeping the package API clean, +# abstracting the underlying modules + + +_operators = { + 'bash_operator': ['BashOperator'], + 'check_operator': [ + 'CheckOperator', + 'ValueCheckOperator', + 'IntervalCheckOperator', + ], + 'python_operator': [ + 'PythonOperator', + 'BranchPythonOperator', + 'ShortCircuitOperator', + ], + 'hive_operator': ['HiveOperator'], + 'pig_operator': ['PigOperator'], + 'presto_check_operator': [ + 'PrestoCheckOperator', + 'PrestoValueCheckOperator', + 'PrestoIntervalCheckOperator', + ], + 'dagrun_operator': ['TriggerDagRunOperator'], + 'dummy_operator': ['DummyOperator'], + 'email_operator': ['EmailOperator'], + 'hive_to_samba_operator': ['Hive2SambaOperator'], + 'latest_only_operator': ['LatestOnlyOperator'], + 'mysql_operator': ['MySqlOperator'], + 'sqlite_operator': ['SqliteOperator'], + 'mysql_to_hive': ['MySqlToHiveTransfer'], + 'postgres_operator': ['PostgresOperator'], + 'sensors': [ + 'BaseSensorOperator', + 'ExternalTaskSensor', + 'HdfsSensor', + 'HivePartitionSensor', + 'HttpSensor', + 'MetastorePartitionSensor', + 'NamedHivePartitionSensor', + 'S3KeySensor', + 'S3PrefixSensor', + 'SqlSensor', + 'TimeDeltaSensor', + 'TimeSensor', + 'WebHdfsSensor', + ], + 'subdag_operator': ['SubDagOperator'], + 'hive_stats_operator': ['HiveStatsCollectionOperator'], + 's3_to_hive_operator': ['S3ToHiveTransfer'], + 'hive_to_mysql': ['HiveToMySqlTransfer'], + 'presto_to_mysql': ['PrestoToMySqlTransfer'], + 's3_file_transform_operator': ['S3FileTransformOperator'], + 'http_operator': ['SimpleHttpOperator'], + 'hive_to_druid': ['HiveToDruidTransfer'], + 'jdbc_operator': ['JdbcOperator'], + 'mssql_operator': ['MsSqlOperator'], + 'mssql_to_hive': ['MsSqlToHiveTransfer'], + 'slack_operator': ['SlackAPIOperator', 'SlackAPIPostOperator'], + 'generic_transfer': ['GenericTransfer'], + 'oracle_operator': ['OracleOperator'] +} + +import os as _os +if not _os.environ.get('AIRFLOW_USE_NEW_IMPORTS', False): + from airflow.utils.helpers import AirflowImporter + airflow_importer = AirflowImporter(sys.modules[__name__], _operators) + + +def _integrate_plugins(): + """Integrate plugins to the context""" + from airflow.plugins_manager import operators_modules + for operators_module in operators_modules: + sys.modules[operators_module.__name__] = operators_module + globals()[operators_module._name] = operators_module + + ########################################################## + # TODO FIXME Remove in Airflow 2.0 + + if not _os.environ.get('AIRFLOW_USE_NEW_IMPORTS', False): + from zope.deprecation import deprecated as _deprecated + for _operator in operators_module._objects: + operator_name = _operator.__name__ + globals()[operator_name] = _operator + _deprecated( + operator_name, + "Importing plugin operator '{i}' directly from " + "'airflow.operators' has been deprecated. Please " + "import from 'airflow.operators.[plugin_module]' " + "instead. Support for direct imports will be dropped " + "entirely in Airflow 2.0.".format(i=operator_name)) diff --git a/airflow/operators/bash_operator.py b/airflow/operators/bash_operator.py new file mode 100644 index 0000000000000..a7eeb03ad6d0e --- /dev/null +++ b/airflow/operators/bash_operator.py @@ -0,0 +1,117 @@ +# -*- coding: utf-8 -*- +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + + +from builtins import bytes +import os +import signal +from subprocess import Popen, STDOUT, PIPE +from tempfile import gettempdir, NamedTemporaryFile + +from airflow.exceptions import AirflowException +from airflow.models import BaseOperator +from airflow.utils.decorators import apply_defaults +from airflow.utils.file import TemporaryDirectory + + +class BashOperator(BaseOperator): + """ + Execute a Bash script, command or set of commands. + + :param bash_command: The command, set of commands or reference to a + bash script (must be '.sh') to be executed. + :type bash_command: string + :param xcom_push: If xcom_push is True, the last line written to stdout + will also be pushed to an XCom when the bash command completes. + :type xcom_push: bool + :param env: If env is not None, it must be a mapping that defines the + environment variables for the new process; these are used instead + of inheriting the current process environment, which is the default + behavior. (templated) + :type env: dict + :type output_encoding: output encoding of bash command + """ + template_fields = ('bash_command', 'env') + template_ext = ('.sh', '.bash',) + ui_color = '#f0ede4' + + @apply_defaults + def __init__( + self, + bash_command, + xcom_push=False, + env=None, + output_encoding='utf-8', + *args, **kwargs): + + super(BashOperator, self).__init__(*args, **kwargs) + self.bash_command = bash_command + self.env = env + self.xcom_push_flag = xcom_push + self.output_encoding = output_encoding + + def execute(self, context): + """ + Execute the bash command in a temporary directory + which will be cleaned afterwards + """ + bash_command = self.bash_command + self.log.info("Tmp dir root location: \n %s", gettempdir()) + with TemporaryDirectory(prefix='airflowtmp') as tmp_dir: + with NamedTemporaryFile(dir=tmp_dir, prefix=self.task_id) as f: + + f.write(bytes(bash_command, 'utf_8')) + f.flush() + fname = f.name + script_location = tmp_dir + "/" + fname + self.log.info( + "Temporary script location: %s", + script_location + ) + def pre_exec(): + # Restore default signal disposition and invoke setsid + for sig in ('SIGPIPE', 'SIGXFZ', 'SIGXFSZ'): + if hasattr(signal, sig): + signal.signal(getattr(signal, sig), signal.SIG_DFL) + os.setsid() + self.log.info("Running command: %s", bash_command) + sp = Popen( + ['bash', fname], + stdout=PIPE, stderr=STDOUT, + cwd=tmp_dir, env=self.env, + preexec_fn=pre_exec) + + self.sp = sp + + self.log.info("Output:") + line = '' + for line in iter(sp.stdout.readline, b''): + line = line.decode(self.output_encoding).strip() + self.log.info(line) + sp.wait() + self.log.info( + "Command exited with return code %s", + sp.returncode + ) + + if sp.returncode: + raise AirflowException("Bash command failed") + + if self.xcom_push_flag: + return line + + def on_kill(self): + self.log.info('Sending SIGTERM signal to bash process group') + os.killpg(os.getpgid(self.sp.pid), signal.SIGTERM) + diff --git a/airflow/operators/check_operator.py b/airflow/operators/check_operator.py new file mode 100644 index 0000000000000..ff82539b61392 --- /dev/null +++ b/airflow/operators/check_operator.py @@ -0,0 +1,249 @@ +# -*- coding: utf-8 -*- +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from builtins import zip +from builtins import str + +from airflow.exceptions import AirflowException +from airflow.hooks.base_hook import BaseHook +from airflow.models import BaseOperator +from airflow.utils.decorators import apply_defaults + + +class CheckOperator(BaseOperator): + """ + Performs checks against a db. The ``CheckOperator`` expects + a sql query that will return a single row. Each value on that + first row is evaluated using python ``bool`` casting. If any of the + values return ``False`` the check is failed and errors out. + + Note that Python bool casting evals the following as ``False``: + + * ``False`` + * ``0`` + * Empty string (``""``) + * Empty list (``[]``) + * Empty dictionary or set (``{}``) + + Given a query like ``SELECT COUNT(*) FROM foo``, it will fail only if + the count ``== 0``. You can craft much more complex query that could, + for instance, check that the table has the same number of rows as + the source table upstream, or that the count of today's partition is + greater than yesterday's partition, or that a set of metrics are less + than 3 standard deviation for the 7 day average. + + This operator can be used as a data quality check in your pipeline, and + depending on where you put it in your DAG, you have the choice to + stop the critical path, preventing from + publishing dubious data, or on the side and receive email alerts + without stopping the progress of the DAG. + + Note that this is an abstract class and get_db_hook + needs to be defined. Whereas a get_db_hook is hook that gets a + single record from an external source. + + :param sql: the sql to be executed + :type sql: string + """ + + template_fields = ('sql',) + template_ext = ('.hql', '.sql',) + ui_color = '#fff7e6' + + @apply_defaults + def __init__( + self, sql, + conn_id=None, + *args, **kwargs): + super(CheckOperator, self).__init__(*args, **kwargs) + self.conn_id = conn_id + self.sql = sql + + def execute(self, context=None): + self.log.info('Executing SQL check: %s', self.sql) + records = self.get_db_hook().get_first(self.sql) + self.log.info('Record: %s', records) + if not records: + raise AirflowException("The query returned None") + elif not all([bool(r) for r in records]): + exceptstr = "Test failed.\nQuery:\n{q}\nResults:\n{r!s}" + raise AirflowException(exceptstr.format(q=self.sql, r=records)) + self.log.info("Success.") + + def get_db_hook(self): + return BaseHook.get_hook(conn_id=self.conn_id) + + +def _convert_to_float_if_possible(s): + ''' + A small helper function to convert a string to a numeric value + if appropriate + + :param s: the string to be converted + :type s: str + ''' + try: + ret = float(s) + except (ValueError, TypeError): + ret = s + return ret + + +class ValueCheckOperator(BaseOperator): + """ + Performs a simple value check using sql code. + + Note that this is an abstract class and get_db_hook + needs to be defined. Whereas a get_db_hook is hook that gets a + single record from an external source. + + :param sql: the sql to be executed + :type sql: string + """ + + __mapper_args__ = { + 'polymorphic_identity': 'ValueCheckOperator' + } + template_fields = ('sql',) + template_ext = ('.hql', '.sql',) + ui_color = '#fff7e6' + + @apply_defaults + def __init__( + self, sql, pass_value, tolerance=None, + conn_id=None, + *args, **kwargs): + super(ValueCheckOperator, self).__init__(*args, **kwargs) + self.sql = sql + self.conn_id = conn_id + self.pass_value = _convert_to_float_if_possible(pass_value) + tol = _convert_to_float_if_possible(tolerance) + self.tol = tol if isinstance(tol, float) else None + self.is_numeric_value_check = isinstance(self.pass_value, float) + self.has_tolerance = self.tol is not None + + def execute(self, context=None): + self.log.info('Executing SQL check: %s', self.sql) + records = self.get_db_hook().get_first(self.sql) + if not records: + raise AirflowException("The query returned None") + test_results = [] + except_temp = ("Test failed.\nPass value:{self.pass_value}\n" + "Query:\n{self.sql}\nResults:\n{records!s}") + if not self.is_numeric_value_check: + tests = [str(r) == self.pass_value for r in records] + elif self.is_numeric_value_check: + try: + num_rec = [float(r) for r in records] + except (ValueError, TypeError) as e: + cvestr = "Converting a result to float failed.\n" + raise AirflowException(cvestr+except_temp.format(**locals())) + if self.has_tolerance: + tests = [ + r / (1 + self.tol) <= self.pass_value <= r / (1 - self.tol) + for r in num_rec] + else: + tests = [r == self.pass_value for r in num_rec] + if not all(tests): + raise AirflowException(except_temp.format(**locals())) + + def get_db_hook(self): + return BaseHook.get_hook(conn_id=self.conn_id) + + +class IntervalCheckOperator(BaseOperator): + """ + Checks that the values of metrics given as SQL expressions are within + a certain tolerance of the ones from days_back before. + + Note that this is an abstract class and get_db_hook + needs to be defined. Whereas a get_db_hook is hook that gets a + single record from an external source. + + :param table: the table name + :type table: str + :param days_back: number of days between ds and the ds we want to check + against. Defaults to 7 days + :type days_back: int + :param metrics_threshold: a dictionary of ratios indexed by metrics + :type metrics_threshold: dict + """ + + __mapper_args__ = { + 'polymorphic_identity': 'IntervalCheckOperator' + } + template_fields = ('sql1', 'sql2') + template_ext = ('.hql', '.sql',) + ui_color = '#fff7e6' + + @apply_defaults + def __init__( + self, table, metrics_thresholds, + date_filter_column='ds', days_back=-7, + conn_id=None, + *args, **kwargs): + super(IntervalCheckOperator, self).__init__(*args, **kwargs) + self.table = table + self.metrics_thresholds = metrics_thresholds + self.metrics_sorted = sorted(metrics_thresholds.keys()) + self.date_filter_column = date_filter_column + self.days_back = -abs(days_back) + self.conn_id = conn_id + sqlexp = ', '.join(self.metrics_sorted) + sqlt = ("SELECT {sqlexp} FROM {table}" + " WHERE {date_filter_column}=").format(**locals()) + self.sql1 = sqlt + "'{{ ds }}'" + self.sql2 = sqlt + "'{{ macros.ds_add(ds, "+str(self.days_back)+") }}'" + + def execute(self, context=None): + hook = self.get_db_hook() + self.log.info('Executing SQL check: %s', self.sql2) + row2 = hook.get_first(self.sql2) + self.log.info('Executing SQL check: %s', self.sql1) + row1 = hook.get_first(self.sql1) + if not row2: + raise AirflowException("The query {q} returned None".format(q=self.sql2)) + if not row1: + raise AirflowException("The query {q} returned None".format(q=self.sql1)) + current = dict(zip(self.metrics_sorted, row1)) + reference = dict(zip(self.metrics_sorted, row2)) + ratios = {} + test_results = {} + rlog = "Ratio for {0}: {1} \n Ratio threshold : {2}" + fstr = "'{k}' check failed. {r} is above {tr}" + estr = "The following tests have failed:\n {0}" + countstr = "The following {j} tests out of {n} failed:" + for m in self.metrics_sorted: + if current[m] == 0 or reference[m] == 0: + ratio = None + else: + ratio = float(max(current[m], reference[m])) / \ + min(current[m], reference[m]) + self.log.info(rlog.format(m, ratio, self.metrics_thresholds[m])) + ratios[m] = ratio + test_results[m] = ratio < self.metrics_thresholds[m] + if not all(test_results.values()): + failed_tests = [it[0] for it in test_results.items() if not it[1]] + j = len(failed_tests) + n = len(self.metrics_sorted) + self.log.warning(countstr.format(**locals())) + for k in failed_tests: + self.log.warning( + fstr.format(k=k, r=ratios[k], tr=self.metrics_thresholds[k]) + ) + raise AirflowException(estr.format(", ".join(failed_tests))) + self.log.info("All tests have passed") + + def get_db_hook(self): + return BaseHook.get_hook(conn_id=self.conn_id) diff --git a/airflow/operators/dagrun_operator.py b/airflow/operators/dagrun_operator.py new file mode 100644 index 0000000000000..9a13f906c5842 --- /dev/null +++ b/airflow/operators/dagrun_operator.py @@ -0,0 +1,77 @@ +# -*- coding: utf-8 -*- +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from datetime import datetime + +from airflow.models import BaseOperator, DagBag +from airflow.utils.decorators import apply_defaults +from airflow.utils.state import State +from airflow import settings + + +class DagRunOrder(object): + def __init__(self, run_id=None, payload=None): + self.run_id = run_id + self.payload = payload + + +class TriggerDagRunOperator(BaseOperator): + """ + Triggers a DAG run for a specified ``dag_id`` if a criteria is met + + :param trigger_dag_id: the dag_id to trigger + :type trigger_dag_id: str + :param python_callable: a reference to a python function that will be + called while passing it the ``context`` object and a placeholder + object ``obj`` for your callable to fill and return if you want + a DagRun created. This ``obj`` object contains a ``run_id`` and + ``payload`` attribute that you can modify in your function. + The ``run_id`` should be a unique identifier for that DAG run, and + the payload has to be a picklable object that will be made available + to your tasks while executing that DAG run. Your function header + should look like ``def foo(context, dag_run_obj):`` + :type python_callable: python callable + """ + template_fields = tuple() + template_ext = tuple() + ui_color = '#ffefeb' + + @apply_defaults + def __init__( + self, + trigger_dag_id, + python_callable, + *args, **kwargs): + super(TriggerDagRunOperator, self).__init__(*args, **kwargs) + self.python_callable = python_callable + self.trigger_dag_id = trigger_dag_id + + def execute(self, context): + dro = DagRunOrder(run_id='trig__' + datetime.utcnow().isoformat()) + dro = self.python_callable(context, dro) + if dro: + session = settings.Session() + dbag = DagBag(settings.DAGS_FOLDER) + trigger_dag = dbag.get_dag(self.trigger_dag_id) + dr = trigger_dag.create_dagrun( + run_id=dro.run_id, + state=State.RUNNING, + conf=dro.payload, + external_trigger=True) + self.log.info("Creating DagRun %s", dr) + session.add(dr) + session.commit() + session.close() + else: + self.log.info("Criteria not met, moving on") diff --git a/airflow/operators/docker_operator.py b/airflow/operators/docker_operator.py new file mode 100644 index 0000000000000..f319cf44a796a --- /dev/null +++ b/airflow/operators/docker_operator.py @@ -0,0 +1,206 @@ +# -*- coding: utf-8 -*- +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import json + +from airflow.exceptions import AirflowException +from airflow.models import BaseOperator +from airflow.utils.decorators import apply_defaults +from airflow.utils.file import TemporaryDirectory +from docker import APIClient as Client, tls +import ast + + +class DockerOperator(BaseOperator): + """ + Execute a command inside a docker container. + + A temporary directory is created on the host and mounted into a container to allow storing files + that together exceed the default disk size of 10GB in a container. The path to the mounted + directory can be accessed via the environment variable ``AIRFLOW_TMP_DIR``. + + :param image: Docker image from which to create the container. + :type image: str + :param api_version: Remote API version. + :type api_version: str + :param command: Command to be run in the container. + :type command: str or list + :param cpus: Number of CPUs to assign to the container. + This value gets multiplied with 1024. See + https://docs.docker.com/engine/reference/run/#cpu-share-constraint + :type cpus: float + :param docker_url: URL of the host running the docker daemon. + :type docker_url: str + :param environment: Environment variables to set in the container. + :type environment: dict + :param force_pull: Pull the docker image on every run. + :type force_pull: bool + :param mem_limit: Maximum amount of memory the container can use. Either a float value, which + represents the limit in bytes, or a string like ``128m`` or ``1g``. + :type mem_limit: float or str + :param network_mode: Network mode for the container. + :type network_mode: str + :param tls_ca_cert: Path to a PEM-encoded certificate authority to secure the docker connection. + :type tls_ca_cert: str + :param tls_client_cert: Path to the PEM-encoded certificate used to authenticate docker client. + :type tls_client_cert: str + :param tls_client_key: Path to the PEM-encoded key used to authenticate docker client. + :type tls_client_key: str + :param tls_hostname: Hostname to match against the docker server certificate or False to + disable the check. + :type tls_hostname: str or bool + :param tls_ssl_version: Version of SSL to use when communicating with docker daemon. + :type tls_ssl_version: str + :param tmp_dir: Mount point inside the container to a temporary directory created on the host by + the operator. The path is also made available via the environment variable + ``AIRFLOW_TMP_DIR`` inside the container. + :type tmp_dir: str + :param user: Default user inside the docker container. + :type user: int or str + :param volumes: List of volumes to mount into the container, e.g. + ``['/host/path:/container/path', '/host/path2:/container/path2:ro']``. + :param working_dir: Working directory to set on the container (equivalent to the -w switch + the docker client) + :type working_dir: str + :param xcom_push: Does the stdout will be pushed to the next step using XCom. + The default is False. + :type xcom_push: bool + :param xcom_all: Push all the stdout or just the last line. The default is False (last line). + :type xcom_all: bool + """ + template_fields = ('command',) + template_ext = ('.sh', '.bash',) + + @apply_defaults + def __init__( + self, + image, + api_version=None, + command=None, + cpus=1.0, + docker_url='unix://var/run/docker.sock', + environment=None, + force_pull=False, + mem_limit=None, + network_mode=None, + tls_ca_cert=None, + tls_client_cert=None, + tls_client_key=None, + tls_hostname=None, + tls_ssl_version=None, + tmp_dir='/tmp/airflow', + user=None, + volumes=None, + working_dir=None, + xcom_push=False, + xcom_all=False, + *args, + **kwargs): + + super(DockerOperator, self).__init__(*args, **kwargs) + self.api_version = api_version + self.command = command + self.cpus = cpus + self.docker_url = docker_url + self.environment = environment or {} + self.force_pull = force_pull + self.image = image + self.mem_limit = mem_limit + self.network_mode = network_mode + self.tls_ca_cert = tls_ca_cert + self.tls_client_cert = tls_client_cert + self.tls_client_key = tls_client_key + self.tls_hostname = tls_hostname + self.tls_ssl_version = tls_ssl_version + self.tmp_dir = tmp_dir + self.user = user + self.volumes = volumes or [] + self.working_dir = working_dir + self.xcom_push_flag = xcom_push + self.xcom_all = xcom_all + + self.cli = None + self.container = None + + def execute(self, context): + self.log.info('Starting docker container from image %s', self.image) + + tls_config = None + if self.tls_ca_cert and self.tls_client_cert and self.tls_client_key: + tls_config = tls.TLSConfig( + ca_cert=self.tls_ca_cert, + client_cert=(self.tls_client_cert, self.tls_client_key), + verify=True, + ssl_version=self.tls_ssl_version, + assert_hostname=self.tls_hostname + ) + self.docker_url = self.docker_url.replace('tcp://', 'https://') + + self.cli = Client(base_url=self.docker_url, version=self.api_version, tls=tls_config) + + if ':' not in self.image: + image = self.image + ':latest' + else: + image = self.image + + if self.force_pull or len(self.cli.images(name=image)) == 0: + self.log.info('Pulling docker image %s', image) + for l in self.cli.pull(image, stream=True): + output = json.loads(l.decode('utf-8')) + self.log.info("%s", output['status']) + + cpu_shares = int(round(self.cpus * 1024)) + + with TemporaryDirectory(prefix='airflowtmp') as host_tmp_dir: + self.environment['AIRFLOW_TMP_DIR'] = self.tmp_dir + self.volumes.append('{0}:{1}'.format(host_tmp_dir, self.tmp_dir)) + + self.container = self.cli.create_container( + command=self.get_command(), + cpu_shares=cpu_shares, + environment=self.environment, + host_config=self.cli.create_host_config(binds=self.volumes, + network_mode=self.network_mode), + image=image, + mem_limit=self.mem_limit, + user=self.user, + working_dir=self.working_dir + ) + self.cli.start(self.container['Id']) + + line = '' + for line in self.cli.logs(container=self.container['Id'], stream=True): + line = line.strip() + if hasattr(line, 'decode'): + line = line.decode('utf-8') + self.log.info(line) + + exit_code = self.cli.wait(self.container['Id']) + if exit_code != 0: + raise AirflowException('docker container failed') + + if self.xcom_push_flag: + return self.cli.logs(container=self.container['Id']) if self.xcom_all else str(line) + + def get_command(self): + if self.command is not None and self.command.strip().find('[') == 0: + commands = ast.literal_eval(self.command) + else: + commands = self.command + return commands + + def on_kill(self): + if self.cli is not None: + self.log.info('Stopping docker container') + self.cli.stop(self.container['Id']) diff --git a/airflow/operators/dummy_operator.py b/airflow/operators/dummy_operator.py new file mode 100644 index 0000000000000..4517a8a0b6a6e --- /dev/null +++ b/airflow/operators/dummy_operator.py @@ -0,0 +1,33 @@ +# -*- coding: utf-8 -*- +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from airflow.models import BaseOperator +from airflow.utils.decorators import apply_defaults + + +class DummyOperator(BaseOperator): + """ + Operator that does literally nothing. It can be used to group tasks in a + DAG. + """ + + template_fields = tuple() + ui_color = '#e8f7e4' + + @apply_defaults + def __init__(self, *args, **kwargs): + super(DummyOperator, self).__init__(*args, **kwargs) + + def execute(self, context): + pass diff --git a/airflow/operators/email_operator.py b/airflow/operators/email_operator.py new file mode 100644 index 0000000000000..6eaae95105506 --- /dev/null +++ b/airflow/operators/email_operator.py @@ -0,0 +1,64 @@ +# -*- coding: utf-8 -*- +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from airflow.models import BaseOperator +from airflow.utils.email import send_email +from airflow.utils.decorators import apply_defaults + + +class EmailOperator(BaseOperator): + """ + Sends an email. + + :param to: list of emails to send the email to + :type to: list or string (comma or semicolon delimited) + :param subject: subject line for the email (templated) + :type subject: string + :param html_content: content of the email (templated), html markup + is allowed + :type html_content: string + :param files: file names to attach in email + :type files: list + :param cc: list of recipients to be added in CC field + :type cc: list or string (comma or semicolon delimited) + :param bcc: list of recipients to be added in BCC field + :type bcc: list or string (comma or semicolon delimited) + """ + + template_fields = ('to', 'subject', 'html_content') + template_ext = ('.html',) + ui_color = '#e6faf9' + + @apply_defaults + def __init__( + self, + to, + subject, + html_content, + files=None, + cc=None, + bcc=None, + mime_subtype='mixed', + *args, **kwargs): + super(EmailOperator, self).__init__(*args, **kwargs) + self.to = to + self.subject = subject + self.html_content = html_content + self.files = files or [] + self.cc = cc + self.bcc = bcc + self.mime_subtype = mime_subtype + + def execute(self, context): + send_email(self.to, self.subject, self.html_content, files=self.files, cc=self.cc, bcc=self.bcc, mime_subtype=self.mime_subtype) diff --git a/airflow/operators/generic_transfer.py b/airflow/operators/generic_transfer.py new file mode 100644 index 0000000000000..c8a2a58be20ff --- /dev/null +++ b/airflow/operators/generic_transfer.py @@ -0,0 +1,75 @@ +# -*- coding: utf-8 -*- +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +from airflow.models import BaseOperator +from airflow.utils.decorators import apply_defaults +from airflow.hooks.base_hook import BaseHook + + +class GenericTransfer(BaseOperator): + """ + Moves data from a connection to another, assuming that they both + provide the required methods in their respective hooks. The source hook + needs to expose a `get_records` method, and the destination a + `insert_rows` method. + + This is mean to be used on small-ish datasets that fit in memory. + + :param sql: SQL query to execute against the source database + :type sql: str + :param destination_table: target table + :type destination_table: str + :param source_conn_id: source connection + :type source_conn_id: str + :param destination_conn_id: source connection + :type destination_conn_id: str + :param preoperator: sql statement or list of statements to be + executed prior to loading the data + :type preoperator: str or list of str + """ + + template_fields = ('sql', 'destination_table', 'preoperator') + template_ext = ('.sql', '.hql',) + ui_color = '#b0f07c' + + @apply_defaults + def __init__( + self, + sql, + destination_table, + source_conn_id, + destination_conn_id, + preoperator=None, + *args, **kwargs): + super(GenericTransfer, self).__init__(*args, **kwargs) + self.sql = sql + self.destination_table = destination_table + self.source_conn_id = source_conn_id + self.destination_conn_id = destination_conn_id + self.preoperator = preoperator + + def execute(self, context): + source_hook = BaseHook.get_hook(self.source_conn_id) + + self.log.info("Extracting data from %s", self.source_conn_id) + self.log.info("Executing: \n %s", self.sql) + results = source_hook.get_records(self.sql) + + destination_hook = BaseHook.get_hook(self.destination_conn_id) + if self.preoperator: + self.log.info("Running preoperator") + self.log.info(self.preoperator) + destination_hook.run(self.preoperator) + + self.log.info("Inserting rows into %s", self.destination_conn_id) + destination_hook.insert_rows(table=self.destination_table, rows=results) diff --git a/airflow/operators/hive_operator.py b/airflow/operators/hive_operator.py new file mode 100644 index 0000000000000..221feebe06288 --- /dev/null +++ b/airflow/operators/hive_operator.py @@ -0,0 +1,106 @@ +# -*- coding: utf-8 -*- +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +import re + +from airflow.hooks.hive_hooks import HiveCliHook +from airflow.models import BaseOperator +from airflow.utils.decorators import apply_defaults +from airflow.utils.operator_helpers import context_to_airflow_vars + + +class HiveOperator(BaseOperator): + """ + Executes hql code in a specific Hive database. + + :param hql: the hql to be executed + :type hql: string + :param hive_cli_conn_id: reference to the Hive database + :type hive_cli_conn_id: string + :param hiveconf_jinja_translate: when True, hiveconf-type templating + ${var} gets translated into jinja-type templating {{ var }}. Note that + you may want to use this along with the + ``DAG(user_defined_macros=myargs)`` parameter. View the DAG + object documentation for more details. + :type hiveconf_jinja_translate: boolean + :param script_begin_tag: If defined, the operator will get rid of the + part of the script before the first occurrence of `script_begin_tag` + :type script_begin_tag: str + :param mapred_queue: queue used by the Hadoop CapacityScheduler + :type mapred_queue: string + :param mapred_queue_priority: priority within CapacityScheduler queue. + Possible settings include: VERY_HIGH, HIGH, NORMAL, LOW, VERY_LOW + :type mapred_queue_priority: string + :param mapred_job_name: This name will appear in the jobtracker. + This can make monitoring easier. + :type mapred_job_name: string + """ + + template_fields = ('hql', 'schema') + template_ext = ('.hql', '.sql',) + ui_color = '#f0e4ec' + + @apply_defaults + def __init__( + self, hql, + hive_cli_conn_id='hive_cli_default', + schema='default', + hiveconf_jinja_translate=False, + script_begin_tag=None, + run_as_owner=False, + mapred_queue=None, + mapred_queue_priority=None, + mapred_job_name=None, + *args, **kwargs): + + super(HiveOperator, self).__init__(*args, **kwargs) + self.hiveconf_jinja_translate = hiveconf_jinja_translate + self.hql = hql + self.schema = schema + self.hive_cli_conn_id = hive_cli_conn_id + self.script_begin_tag = script_begin_tag + self.run_as = None + if run_as_owner: + self.run_as = self.dag.owner + + self.mapred_queue = mapred_queue + self.mapred_queue_priority = mapred_queue_priority + self.mapred_job_name = mapred_job_name + + def get_hook(self): + return HiveCliHook( + hive_cli_conn_id=self.hive_cli_conn_id, + run_as=self.run_as, + mapred_queue=self.mapred_queue, + mapred_queue_priority=self.mapred_queue_priority, + mapred_job_name=self.mapred_job_name) + + def prepare_template(self): + if self.hiveconf_jinja_translate: + self.hql = re.sub( + "(\$\{([ a-zA-Z0-9_]*)\})", "{{ \g<2> }}", self.hql) + if self.script_begin_tag and self.script_begin_tag in self.hql: + self.hql = "\n".join(self.hql.split(self.script_begin_tag)[1:]) + + def execute(self, context): + self.log.info('Executing: %s', self.hql) + self.hook = self.get_hook() + self.hook.run_cli(hql=self.hql, schema=self.schema, + hive_conf=context_to_airflow_vars(context)) + + def dry_run(self): + self.hook = self.get_hook() + self.hook.test_hql(hql=self.hql) + + def on_kill(self): + self.hook.kill() diff --git a/airflow/operators/hive_stats_operator.py b/airflow/operators/hive_stats_operator.py new file mode 100644 index 0000000000000..896547eabee60 --- /dev/null +++ b/airflow/operators/hive_stats_operator.py @@ -0,0 +1,187 @@ +# -*- coding: utf-8 -*- +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from builtins import zip +from collections import OrderedDict +import json + +from airflow.exceptions import AirflowException +from airflow.hooks.mysql_hook import MySqlHook +from airflow.hooks.presto_hook import PrestoHook +from airflow.hooks.hive_hooks import HiveMetastoreHook +from airflow.models import BaseOperator +from airflow.utils.decorators import apply_defaults + + +class HiveStatsCollectionOperator(BaseOperator): + """ + Gathers partition statistics using a dynamically generated Presto + query, inserts the stats into a MySql table with this format. Stats + overwrite themselves if you rerun the same date/partition. + + `` + CREATE TABLE hive_stats ( + ds VARCHAR(16), + table_name VARCHAR(500), + metric VARCHAR(200), + value BIGINT + ); + `` + + :param table: the source table, in the format ``database.table_name`` + :type table: str + :param partition: the source partition + :type partition: dict of {col:value} + :param extra_exprs: dict of expression to run against the table where + keys are metric names and values are Presto compatible expressions + :type extra_exprs: dict + :param col_blacklist: list of columns to blacklist, consider + blacklisting blobs, large json columns, ... + :type col_blacklist: list + :param assignment_func: a function that receives a column name and + a type, and returns a dict of metric names and an Presto expressions. + If None is returned, the global defaults are applied. If an + empty dictionary is returned, no stats are computed for that + column. + :type assignment_func: function + """ + + template_fields = ('table', 'partition', 'ds', 'dttm') + ui_color = '#aff7a6' + + @apply_defaults + def __init__( + self, + table, + partition, + extra_exprs=None, + col_blacklist=None, + assignment_func=None, + metastore_conn_id='metastore_default', + presto_conn_id='presto_default', + mysql_conn_id='airflow_db', + *args, **kwargs): + super(HiveStatsCollectionOperator, self).__init__(*args, **kwargs) + + self.table = table + self.partition = partition + self.extra_exprs = extra_exprs or {} + self.col_blacklist = col_blacklist or {} + self.metastore_conn_id = metastore_conn_id + self.presto_conn_id = presto_conn_id + self.mysql_conn_id = mysql_conn_id + self.assignment_func = assignment_func + self.ds = '{{ ds }}' + self.dttm = '{{ execution_date.isoformat() }}' + + def get_default_exprs(self, col, col_type): + if col in self.col_blacklist: + return {} + d = {} + d[(col, 'non_null')] = "COUNT({col})" + if col_type in ['double', 'int', 'bigint', 'float', 'double']: + d[(col, 'sum')] = 'SUM({col})' + d[(col, 'min')] = 'MIN({col})' + d[(col, 'max')] = 'MAX({col})' + d[(col, 'avg')] = 'AVG({col})' + elif col_type == 'boolean': + d[(col, 'true')] = 'SUM(CASE WHEN {col} THEN 1 ELSE 0 END)' + d[(col, 'false')] = 'SUM(CASE WHEN NOT {col} THEN 1 ELSE 0 END)' + elif col_type in ['string']: + d[(col, 'len')] = 'SUM(CAST(LENGTH({col}) AS BIGINT))' + d[(col, 'approx_distinct')] = 'APPROX_DISTINCT({col})' + + return {k: v.format(col=col) for k, v in d.items()} + + def execute(self, context=None): + metastore = HiveMetastoreHook(metastore_conn_id=self.metastore_conn_id) + table = metastore.get_table(table_name=self.table) + field_types = {col.name: col.type for col in table.sd.cols} + + exprs = { + ('', 'count'): 'COUNT(*)' + } + for col, col_type in list(field_types.items()): + d = {} + if self.assignment_func: + d = self.assignment_func(col, col_type) + if d is None: + d = self.get_default_exprs(col, col_type) + else: + d = self.get_default_exprs(col, col_type) + exprs.update(d) + exprs.update(self.extra_exprs) + exprs = OrderedDict(exprs) + exprs_str = ",\n ".join([ + v + " AS " + k[0] + '__' + k[1] + for k, v in exprs.items()]) + + where_clause = [ + "{0} = '{1}'".format(k, v) for k, v in self.partition.items()] + where_clause = " AND\n ".join(where_clause) + sql = """ + SELECT + {exprs_str} + FROM {self.table} + WHERE + {where_clause}; + """.format(**locals()) + + hook = PrestoHook(presto_conn_id=self.presto_conn_id) + self.log.info('Executing SQL check: %s', sql) + row = hook.get_first(hql=sql) + self.log.info("Record: %s", row) + if not row: + raise AirflowException("The query returned None") + + part_json = json.dumps(self.partition, sort_keys=True) + + self.log.info("Deleting rows from previous runs if they exist") + mysql = MySqlHook(self.mysql_conn_id) + sql = """ + SELECT 1 FROM hive_stats + WHERE + table_name='{self.table}' AND + partition_repr='{part_json}' AND + dttm='{self.dttm}' + LIMIT 1; + """.format(**locals()) + if mysql.get_records(sql): + sql = """ + DELETE FROM hive_stats + WHERE + table_name='{self.table}' AND + partition_repr='{part_json}' AND + dttm='{self.dttm}'; + """.format(**locals()) + mysql.run(sql) + + self.log.info("Pivoting and loading cells into the Airflow db") + rows = [ + (self.ds, self.dttm, self.table, part_json) + + (r[0][0], r[0][1], r[1]) + for r in zip(exprs, row)] + mysql.insert_rows( + table='hive_stats', + rows=rows, + target_fields=[ + 'ds', + 'dttm', + 'table_name', + 'partition_repr', + 'col', + 'metric', + 'value', + ] + ) diff --git a/airflow/operators/hive_to_druid.py b/airflow/operators/hive_to_druid.py new file mode 100644 index 0000000000000..e420dfd0375e1 --- /dev/null +++ b/airflow/operators/hive_to_druid.py @@ -0,0 +1,227 @@ +# -*- coding: utf-8 -*- +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +from airflow.hooks.hive_hooks import HiveCliHook, HiveMetastoreHook +from airflow.hooks.druid_hook import DruidHook +from airflow.models import BaseOperator +from airflow.utils.decorators import apply_defaults + +LOAD_CHECK_INTERVAL = 5 +DEFAULT_TARGET_PARTITION_SIZE = 5000000 + + +class HiveToDruidTransfer(BaseOperator): + """ + Moves data from Hive to Druid, [del]note that for now the data is loaded + into memory before being pushed to Druid, so this operator should + be used for smallish amount of data.[/del] + + :param sql: SQL query to execute against the Druid database + :type sql: str + :param druid_datasource: the datasource you want to ingest into in druid + :type druid_datasource: str + :param ts_dim: the timestamp dimension + :type ts_dim: str + :param metric_spec: the metrics you want to define for your data + :type metric_spec: list + :param hive_cli_conn_id: the hive connection id + :type hive_cli_conn_id: str + :param druid_ingest_conn_id: the druid ingest connection id + :type druid_ingest_conn_id: str + :param metastore_conn_id: the metastore connection id + :type metastore_conn_id: str + :param hadoop_dependency_coordinates: list of coordinates to squeeze + int the ingest json + :type hadoop_dependency_coordinates: list of str + :param intervals: list of time intervals that defines segments, this + is passed as is to the json object + :type intervals: list + :param hive_tblproperties: additional properties for tblproperties in + hive for the staging table + :type hive_tblproperties: dict + """ + + template_fields = ('sql', 'intervals') + template_ext = ('.sql',) + + @apply_defaults + def __init__( + self, + sql, + druid_datasource, + ts_dim, + metric_spec=None, + hive_cli_conn_id='hive_cli_default', + druid_ingest_conn_id='druid_ingest_default', + metastore_conn_id='metastore_default', + hadoop_dependency_coordinates=None, + intervals=None, + num_shards=-1, + target_partition_size=-1, + query_granularity="NONE", + segment_granularity="DAY", + hive_tblproperties=None, + *args, **kwargs): + super(HiveToDruidTransfer, self).__init__(*args, **kwargs) + self.sql = sql + self.druid_datasource = druid_datasource + self.ts_dim = ts_dim + self.intervals = intervals or ['{{ ds }}/{{ tomorrow_ds }}'] + self.num_shards = num_shards + self.target_partition_size = target_partition_size + self.query_granularity = query_granularity + self.segment_granularity = segment_granularity + self.metric_spec = metric_spec or [{ + "name": "count", + "type": "count"}] + self.hive_cli_conn_id = hive_cli_conn_id + self.hadoop_dependency_coordinates = hadoop_dependency_coordinates + self.druid_ingest_conn_id = druid_ingest_conn_id + self.metastore_conn_id = metastore_conn_id + self.hive_tblproperties = hive_tblproperties + + def execute(self, context): + hive = HiveCliHook(hive_cli_conn_id=self.hive_cli_conn_id) + self.log.info("Extracting data from Hive") + hive_table = 'druid.' + context['task_instance_key_str'].replace('.', '_') + sql = self.sql.strip().strip(';') + tblproperties = ''.join([", '{}' = '{}'".format(k, v) for k, v in self.hive_tblproperties.items()]) + hql = """\ + SET mapred.output.compress=false; + SET hive.exec.compress.output=false; + DROP TABLE IF EXISTS {hive_table}; + CREATE TABLE {hive_table} + ROW FORMAT DELIMITED FIELDS TERMINATED BY '\t' + STORED AS TEXTFILE + TBLPROPERTIES ('serialization.null.format' = ''{tblproperties}) + AS + {sql} + """.format(**locals()) + self.log.info("Running command:\n %s", hql) + hive.run_cli(hql) + + m = HiveMetastoreHook(self.metastore_conn_id) + + # Get the Hive table and extract the columns + t = m.get_table(hive_table) + columns = [col.name for col in t.sd.cols] + + # Get the path on hdfs + hdfs_uri = m.get_table(hive_table).sd.location + pos = hdfs_uri.find('/user') + static_path = hdfs_uri[pos:] + + schema, table = hive_table.split('.') + + druid = DruidHook(druid_ingest_conn_id=self.druid_ingest_conn_id) + + try: + index_spec = self.construct_ingest_query( + static_path=static_path, + columns=columns, + ) + + self.log.info("Inserting rows into Druid, hdfs path: %s", static_path) + + druid.submit_indexing_job(index_spec) + + self.log.info("Load seems to have succeeded!") + finally: + self.log.info( + "Cleaning up by dropping the temp Hive table %s", + hive_table + ) + hql = "DROP TABLE IF EXISTS {}".format(hive_table) + hive.run_cli(hql) + + def construct_ingest_query(self, static_path, columns): + """ + Builds an ingest query for an HDFS TSV load. + + :param static_path: The path on hdfs where the data is + :type static_path: str + :param columns: List of all the columns that are available + :type columns: list + """ + + # backward compatibilty for num_shards, but target_partition_size is the default setting + # and overwrites the num_shards + num_shards = self.num_shards + target_partition_size = self.target_partition_size + if self.target_partition_size == -1: + if self.num_shards == -1: + target_partition_size = DEFAULT_TARGET_PARTITION_SIZE + else: + num_shards = -1 + + metric_names = [m['fieldName'] for m in self.metric_spec if m['type'] != 'count'] + + # Take all the columns, which are not the time dimension or a metric, as the dimension columns + dimensions = [c for c in columns if c not in metric_names and c != self.ts_dim] + + ingest_query_dict = { + "type": "index_hadoop", + "spec": { + "dataSchema": { + "metricsSpec": self.metric_spec, + "granularitySpec": { + "queryGranularity": self.query_granularity, + "intervals": self.intervals, + "type": "uniform", + "segmentGranularity": self.segment_granularity, + }, + "parser": { + "type": "string", + "parseSpec": { + "columns": columns, + "dimensionsSpec": { + "dimensionExclusions": [], + "dimensions": dimensions, # list of names + "spatialDimensions": [] + }, + "timestampSpec": { + "column": self.ts_dim, + "format": "auto" + }, + "format": "tsv" + } + }, + "dataSource": self.druid_datasource + }, + "tuningConfig": { + "type": "hadoop", + "jobProperties": { + "mapreduce.job.user.classpath.first": "false", + "mapreduce.map.output.compress": "false", + "mapreduce.output.fileoutputformat.compress": "false", + }, + "partitionsSpec": { + "type": "hashed", + "targetPartitionSize": target_partition_size, + "numShards": num_shards, + }, + }, + "ioConfig": { + "inputSpec": { + "paths": static_path, + "type": "static" + }, + "type": "hadoop" + } + } + } + + if self.hadoop_dependency_coordinates: + ingest_query_dict['hadoopDependencyCoordinates'] = self.hadoop_dependency_coordinates + + return ingest_query_dict diff --git a/airflow/operators/hive_to_mysql.py b/airflow/operators/hive_to_mysql.py new file mode 100644 index 0000000000000..d2d9d0c9d6468 --- /dev/null +++ b/airflow/operators/hive_to_mysql.py @@ -0,0 +1,106 @@ +# -*- coding: utf-8 -*- +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +from airflow.hooks.hive_hooks import HiveServer2Hook +from airflow.hooks.mysql_hook import MySqlHook +from airflow.models import BaseOperator +from airflow.utils.decorators import apply_defaults + +from tempfile import NamedTemporaryFile + + +class HiveToMySqlTransfer(BaseOperator): + """ + Moves data from Hive to MySQL, note that for now the data is loaded + into memory before being pushed to MySQL, so this operator should + be used for smallish amount of data. + + :param sql: SQL query to execute against the MySQL database + :type sql: str + :param mysql_table: target MySQL table, use dot notation to target a + specific database + :type mysql_table: str + :param mysql_conn_id: source mysql connection + :type mysql_conn_id: str + :param hiveserver2_conn_id: destination hive connection + :type hiveserver2_conn_id: str + :param mysql_preoperator: sql statement to run against mysql prior to + import, typically use to truncate of delete in place of the data + coming in, allowing the task to be idempotent (running the task + twice won't double load data) + :type mysql_preoperator: str + :param mysql_postoperator: sql statement to run against mysql after the + import, typically used to move data from staging to production + and issue cleanup commands. + :type mysql_postoperator: str + :param bulk_load: flag to use bulk_load option. This loads mysql directly + from a tab-delimited text file using the LOAD DATA LOCAL INFILE command. + This option requires an extra connection parameter for the + destination MySQL connection: {'local_infile': true}. + :type bulk_load: bool + """ + + template_fields = ('sql', 'mysql_table', 'mysql_preoperator', + 'mysql_postoperator') + template_ext = ('.sql',) + ui_color = '#a0e08c' + + @apply_defaults + def __init__( + self, + sql, + mysql_table, + hiveserver2_conn_id='hiveserver2_default', + mysql_conn_id='mysql_default', + mysql_preoperator=None, + mysql_postoperator=None, + bulk_load=False, + *args, **kwargs): + super(HiveToMySqlTransfer, self).__init__(*args, **kwargs) + self.sql = sql + self.mysql_table = mysql_table + self.mysql_conn_id = mysql_conn_id + self.mysql_preoperator = mysql_preoperator + self.mysql_postoperator = mysql_postoperator + self.hiveserver2_conn_id = hiveserver2_conn_id + self.bulk_load = bulk_load + + def execute(self, context): + hive = HiveServer2Hook(hiveserver2_conn_id=self.hiveserver2_conn_id) + self.log.info("Extracting data from Hive: %s", self.sql) + + if self.bulk_load: + tmpfile = NamedTemporaryFile() + hive.to_csv(self.sql, tmpfile.name, delimiter='\t', + lineterminator='\n', output_header=False) + else: + results = hive.get_records(self.sql) + + mysql = MySqlHook(mysql_conn_id=self.mysql_conn_id) + if self.mysql_preoperator: + self.log.info("Running MySQL preoperator") + mysql.run(self.mysql_preoperator) + + self.log.info("Inserting rows into MySQL") + + if self.bulk_load: + mysql.bulk_load(table=self.mysql_table, tmp_file=tmpfile.name) + tmpfile.close() + else: + mysql.insert_rows(table=self.mysql_table, rows=results) + + if self.mysql_postoperator: + self.log.info("Running MySQL postoperator") + mysql.run(self.mysql_postoperator) + + self.log.info("Done.") diff --git a/airflow/operators/hive_to_samba_operator.py b/airflow/operators/hive_to_samba_operator.py new file mode 100644 index 0000000000000..93ebec119e7d7 --- /dev/null +++ b/airflow/operators/hive_to_samba_operator.py @@ -0,0 +1,59 @@ +# -*- coding: utf-8 -*- +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +import tempfile + +from airflow.hooks.hive_hooks import HiveServer2Hook +from airflow.hooks.samba_hook import SambaHook +from airflow.models import BaseOperator +from airflow.utils.decorators import apply_defaults + + +class Hive2SambaOperator(BaseOperator): + """ + Executes hql code in a specific Hive database and loads the + results of the query as a csv to a Samba location. + + :param hql: the hql to be exported + :type hql: string + :param hiveserver2_conn_id: reference to the hiveserver2 service + :type hiveserver2_conn_id: string + :param samba_conn_id: reference to the samba destination + :type samba_conn_id: string + """ + + template_fields = ('hql', 'destination_filepath') + template_ext = ('.hql', '.sql',) + + @apply_defaults + def __init__( + self, hql, + destination_filepath, + samba_conn_id='samba_default', + hiveserver2_conn_id='hiveserver2_default', + *args, **kwargs): + super(Hive2SambaOperator, self).__init__(*args, **kwargs) + + self.hiveserver2_conn_id = hiveserver2_conn_id + self.samba_conn_id = samba_conn_id + self.destination_filepath = destination_filepath + self.hql = hql.strip().rstrip(';') + + def execute(self, context): + samba = SambaHook(samba_conn_id=self.samba_conn_id) + hive = HiveServer2Hook(hiveserver2_conn_id=self.hiveserver2_conn_id) + tmpfile = tempfile.NamedTemporaryFile() + self.log.info("Fetching file from Hive") + hive.to_csv(hql=self.hql, csv_filepath=tmpfile.name) + self.log.info("Pushing to samba") + samba.push_from_local(self.destination_filepath, tmpfile.name) diff --git a/airflow/operators/http_operator.py b/airflow/operators/http_operator.py new file mode 100644 index 0000000000000..63b892c5bf5cb --- /dev/null +++ b/airflow/operators/http_operator.py @@ -0,0 +1,87 @@ +# -*- coding: utf-8 -*- +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from airflow.exceptions import AirflowException +from airflow.hooks.http_hook import HttpHook +from airflow.models import BaseOperator +from airflow.utils.decorators import apply_defaults + + +class SimpleHttpOperator(BaseOperator): + """ + Calls an endpoint on an HTTP system to execute an action + + :param http_conn_id: The connection to run the sensor against + :type http_conn_id: string + :param endpoint: The relative part of the full url + :type endpoint: string + :param method: The HTTP method to use, default = "POST" + :type method: string + :param data: The data to pass. POST-data in POST/PUT and params + in the URL for a GET request. + :type data: For POST/PUT, depends on the content-type parameter, + for GET a dictionary of key/value string pairs + :param headers: The HTTP headers to be added to the GET request + :type headers: a dictionary of string key/value pairs + :param response_check: A check against the 'requests' response object. + Returns True for 'pass' and False otherwise. + :type response_check: A lambda or defined function. + :param extra_options: Extra options for the 'requests' library, see the + 'requests' documentation (options to modify timeout, ssl, etc.) + :type extra_options: A dictionary of options, where key is string and value + depends on the option that's being modified. + """ + + template_fields = ('endpoint', 'data',) + template_ext = () + ui_color = '#f4a460' + + @apply_defaults + def __init__(self, + endpoint, + method='POST', + data=None, + headers=None, + response_check=None, + extra_options=None, + xcom_push=False, + http_conn_id='http_default', *args, **kwargs): + """ + If xcom_push is True, response of an HTTP request will also + be pushed to an XCom. + """ + super(SimpleHttpOperator, self).__init__(*args, **kwargs) + self.http_conn_id = http_conn_id + self.method = method + self.endpoint = endpoint + self.headers = headers or {} + self.data = data or {} + self.response_check = response_check + self.extra_options = extra_options or {} + self.xcom_push_flag = xcom_push + + def execute(self, context): + http = HttpHook(self.method, http_conn_id=self.http_conn_id) + + self.log.info("Calling HTTP method") + + response = http.run(self.endpoint, + self.data, + self.headers, + self.extra_options) + if self.response_check: + if not self.response_check(response): + raise AirflowException("Response check returned False.") + if self.xcom_push_flag: + return response.text diff --git a/airflow/operators/jdbc_operator.py b/airflow/operators/jdbc_operator.py new file mode 100644 index 0000000000000..4ec2fa0e79b31 --- /dev/null +++ b/airflow/operators/jdbc_operator.py @@ -0,0 +1,60 @@ +# -*- coding: utf-8 -*- +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +from airflow.hooks.jdbc_hook import JdbcHook +from airflow.models import BaseOperator +from airflow.utils.decorators import apply_defaults + + +class JdbcOperator(BaseOperator): + """ + Executes sql code in a database using jdbc driver. + + Requires jaydebeapi. + + :param jdbc_url: driver specific connection url with string variables, e.g. for exasol jdbc:exa:{0}:{1};schema={2} + Template vars are defined like this: {0} = hostname, {1} = port, {2} = dbschema, {3} = extra + :type jdbc_url: string + :param jdbc_driver_name: classname of the specific jdbc driver, for exasol com.exasol.jdbc.EXADriver + :type jdbc_driver_name: string + :param jdbc_driver_loc: absolute path to jdbc driver location, for example /var/exasol/exajdbc.jar + :type jdbc_driver_loc: string + + :param conn_id: reference to a predefined database + :type conn_id: string + :param sql: the sql code to be executed + :type sql: Can receive a str representing a sql statement, + a list of str (sql statements), or reference to a template file. + Template reference are recognized by str ending in '.sql' + """ + + template_fields = ('sql',) + template_ext = ('.sql',) + ui_color = '#ededed' + + @apply_defaults + def __init__( + self, sql, + jdbc_conn_id='jdbc_default', autocommit=False, parameters=None, + *args, **kwargs): + super(JdbcOperator, self).__init__(*args, **kwargs) + self.parameters = parameters + + self.sql = sql + self.jdbc_conn_id = jdbc_conn_id + self.autocommit = autocommit + + def execute(self, context): + self.log.info('Executing: %s', self.sql) + self.hook = JdbcHook(jdbc_conn_id=self.jdbc_conn_id) + self.hook.run(self.sql, self.autocommit, parameters=self.parameters) diff --git a/airflow/operators/latest_only_operator.py b/airflow/operators/latest_only_operator.py new file mode 100644 index 0000000000000..7abd92d4519d6 --- /dev/null +++ b/airflow/operators/latest_only_operator.py @@ -0,0 +1,60 @@ +# -*- coding: utf-8 -*- +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import datetime + +from airflow.models import BaseOperator, SkipMixin + + +class LatestOnlyOperator(BaseOperator, SkipMixin): + """ + Allows a workflow to skip tasks that are not running during the most + recent schedule interval. + + If the task is run outside of the latest schedule interval, all + directly downstream tasks will be skipped. + """ + + ui_color = '#e9ffdb' # nyanza + + def execute(self, context): + # If the DAG Run is externally triggered, then return without + # skipping downstream tasks + if context['dag_run'] and context['dag_run'].external_trigger: + self.log.info("Externally triggered DAG_Run: allowing execution to proceed.") + return + + now = datetime.datetime.utcnow() + left_window = context['dag'].following_schedule( + context['execution_date']) + right_window = context['dag'].following_schedule(left_window) + self.log.info( + 'Checking latest only with left_window: %s right_window: %s now: %s', + left_window, right_window, now + ) + + if not left_window < now <= right_window: + self.log.info('Not latest execution, skipping downstream.') + + downstream_tasks = context['task'].get_flat_relatives(upstream=False) + self.log.debug("Downstream task_ids %s", downstream_tasks) + + if downstream_tasks: + self.skip(context['dag_run'], + context['ti'].execution_date, + downstream_tasks) + + self.log.info('Done.') + else: + self.log.info('Latest, allowing execution to proceed.') diff --git a/airflow/operators/mssql_operator.py b/airflow/operators/mssql_operator.py new file mode 100644 index 0000000000000..3455232541667 --- /dev/null +++ b/airflow/operators/mssql_operator.py @@ -0,0 +1,51 @@ +# -*- coding: utf-8 -*- +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +from airflow.hooks.mssql_hook import MsSqlHook +from airflow.models import BaseOperator +from airflow.utils.decorators import apply_defaults + + +class MsSqlOperator(BaseOperator): + """ + Executes sql code in a specific Microsoft SQL database + + :param mssql_conn_id: reference to a specific mssql database + :type mssql_conn_id: string + :param sql: the sql code to be executed + :type sql: string or string pointing to a template file with .sql extension + :param database: name of database which overwrite defined one in connection + :type database: string + """ + + template_fields = ('sql',) + template_ext = ('.sql',) + ui_color = '#ededed' + + @apply_defaults + def __init__( + self, sql, mssql_conn_id='mssql_default', parameters=None, + autocommit=False, database=None, *args, **kwargs): + super(MsSqlOperator, self).__init__(*args, **kwargs) + self.mssql_conn_id = mssql_conn_id + self.sql = sql + self.parameters = parameters + self.autocommit = autocommit + self.database = database + + def execute(self, context): + self.log.info('Executing: %s', self.sql) + hook = MsSqlHook(mssql_conn_id=self.mssql_conn_id, + schema=self.database) + hook.run(self.sql, autocommit=self.autocommit, + parameters=self.parameters) diff --git a/airflow/operators/mssql_to_hive.py b/airflow/operators/mssql_to_hive.py new file mode 100644 index 0000000000000..c2c858d50150e --- /dev/null +++ b/airflow/operators/mssql_to_hive.py @@ -0,0 +1,130 @@ +# -*- coding: utf-8 -*- +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from builtins import chr +from collections import OrderedDict +import unicodecsv as csv +from tempfile import NamedTemporaryFile +import pymssql + +from airflow.hooks.hive_hooks import HiveCliHook +from airflow.hooks.mssql_hook import MsSqlHook +from airflow.models import BaseOperator +from airflow.utils.decorators import apply_defaults + + +class MsSqlToHiveTransfer(BaseOperator): + """ + Moves data from Microsoft SQL Server to Hive. The operator runs + your query against Microsoft SQL Server, stores the file locally + before loading it into a Hive table. If the ``create`` or + ``recreate`` arguments are set to ``True``, + a ``CREATE TABLE`` and ``DROP TABLE`` statements are generated. + Hive data types are inferred from the cursor's metadata. + Note that the table generated in Hive uses ``STORED AS textfile`` + which isn't the most efficient serialization format. If a + large amount of data is loaded and/or if the table gets + queried considerably, you may want to use this operator only to + stage the data into a temporary table before loading it into its + final destination using a ``HiveOperator``. + + :param sql: SQL query to execute against the Microsoft SQL Server database + :type sql: str + :param hive_table: target Hive table, use dot notation to target a specific database + :type hive_table: str + :param create: whether to create the table if it doesn't exist + :type create: bool + :param recreate: whether to drop and recreate the table at every execution + :type recreate: bool + :param partition: target partition as a dict of partition columns and values + :type partition: dict + :param delimiter: field delimiter in the file + :type delimiter: str + :param mssql_conn_id: source Microsoft SQL Server connection + :type mssql_conn_id: str + :param hive_conn_id: destination hive connection + :type hive_conn_id: str + :param tblproperties: TBLPROPERTIES of the hive table being created + :type tblproperties: dict + """ + + template_fields = ('sql', 'partition', 'hive_table') + template_ext = ('.sql',) + ui_color = '#a0e08c' + + @apply_defaults + def __init__( + self, + sql, + hive_table, + create=True, + recreate=False, + partition=None, + delimiter=chr(1), + mssql_conn_id='mssql_default', + hive_cli_conn_id='hive_cli_default', + tblproperties=None, + *args, **kwargs): + super(MsSqlToHiveTransfer, self).__init__(*args, **kwargs) + self.sql = sql + self.hive_table = hive_table + self.partition = partition + self.create = create + self.recreate = recreate + self.delimiter = delimiter + self.mssql_conn_id = mssql_conn_id + self.hive_cli_conn_id = hive_cli_conn_id + self.partition = partition or {} + self.tblproperties = tblproperties + + @classmethod + def type_map(cls, mssql_type): + t = pymssql + d = { + t.BINARY.value: 'INT', + t.DECIMAL.value: 'FLOAT', + t.NUMBER.value: 'INT', + } + return d[mssql_type] if mssql_type in d else 'STRING' + + def execute(self, context): + hive = HiveCliHook(hive_cli_conn_id=self.hive_cli_conn_id) + mssql = MsSqlHook(mssql_conn_id=self.mssql_conn_id) + + self.log.info("Dumping Microsoft SQL Server query results to local file") + conn = mssql.get_conn() + cursor = conn.cursor() + cursor.execute(self.sql) + with NamedTemporaryFile("w") as f: + csv_writer = csv.writer(f, delimiter=self.delimiter, encoding='utf-8') + field_dict = OrderedDict() + col_count = 0 + for field in cursor.description: + col_count += 1 + col_position = "Column{position}".format(position=col_count) + field_dict[col_position if field[0] == '' else field[0]] = self.type_map(field[1]) + csv_writer.writerows(cursor) + f.flush() + cursor.close() + conn.close() + self.log.info("Loading file into Hive") + hive.load_file( + f.name, + self.hive_table, + field_dict=field_dict, + create=self.create, + partition=self.partition, + delimiter=self.delimiter, + recreate=self.recreate, + tblproperties=self.tblproperties) diff --git a/airflow/operators/mysql_operator.py b/airflow/operators/mysql_operator.py new file mode 100644 index 0000000000000..20f1b7e31da77 --- /dev/null +++ b/airflow/operators/mysql_operator.py @@ -0,0 +1,55 @@ +# -*- coding: utf-8 -*- +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +from airflow.hooks.mysql_hook import MySqlHook +from airflow.models import BaseOperator +from airflow.utils.decorators import apply_defaults + + +class MySqlOperator(BaseOperator): + """ + Executes sql code in a specific MySQL database + + :param mysql_conn_id: reference to a specific mysql database + :type mysql_conn_id: string + :param sql: the sql code to be executed + :type sql: Can receive a str representing a sql statement, + a list of str (sql statements), or reference to a template file. + Template reference are recognized by str ending in '.sql' + :param database: name of database which overwrite defined one in connection + :type database: string + """ + + template_fields = ('sql',) + template_ext = ('.sql',) + ui_color = '#ededed' + + @apply_defaults + def __init__( + self, sql, mysql_conn_id='mysql_default', parameters=None, + autocommit=False, database=None, *args, **kwargs): + super(MySqlOperator, self).__init__(*args, **kwargs) + self.mysql_conn_id = mysql_conn_id + self.sql = sql + self.autocommit = autocommit + self.parameters = parameters + self.database = database + + def execute(self, context): + self.log.info('Executing: %s', self.sql) + hook = MySqlHook(mysql_conn_id=self.mysql_conn_id, + schema=self.database) + hook.run( + self.sql, + autocommit=self.autocommit, + parameters=self.parameters) diff --git a/airflow/operators/mysql_to_hive.py b/airflow/operators/mysql_to_hive.py new file mode 100644 index 0000000000000..cd472a84d8f6d --- /dev/null +++ b/airflow/operators/mysql_to_hive.py @@ -0,0 +1,135 @@ +# -*- coding: utf-8 -*- +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from builtins import chr +from collections import OrderedDict +import unicodecsv as csv +from tempfile import NamedTemporaryFile +import MySQLdb + +from airflow.hooks.hive_hooks import HiveCliHook +from airflow.hooks.mysql_hook import MySqlHook +from airflow.models import BaseOperator +from airflow.utils.decorators import apply_defaults + + +class MySqlToHiveTransfer(BaseOperator): + """ + Moves data from MySql to Hive. The operator runs your query against + MySQL, stores the file locally before loading it into a Hive table. + If the ``create`` or ``recreate`` arguments are set to ``True``, + a ``CREATE TABLE`` and ``DROP TABLE`` statements are generated. + Hive data types are inferred from the cursor's metadata. Note that the + table generated in Hive uses ``STORED AS textfile`` + which isn't the most efficient serialization format. If a + large amount of data is loaded and/or if the table gets + queried considerably, you may want to use this operator only to + stage the data into a temporary table before loading it into its + final destination using a ``HiveOperator``. + + :param sql: SQL query to execute against the MySQL database + :type sql: str + :param hive_table: target Hive table, use dot notation to target a + specific database + :type hive_table: str + :param create: whether to create the table if it doesn't exist + :type create: bool + :param recreate: whether to drop and recreate the table at every + execution + :type recreate: bool + :param partition: target partition as a dict of partition columns + and values + :type partition: dict + :param delimiter: field delimiter in the file + :type delimiter: str + :param mysql_conn_id: source mysql connection + :type mysql_conn_id: str + :param hive_conn_id: destination hive connection + :type hive_conn_id: str + :param tblproperties: TBLPROPERTIES of the hive table being created + :type tblproperties: dict + """ + + template_fields = ('sql', 'partition', 'hive_table') + template_ext = ('.sql',) + ui_color = '#a0e08c' + + @apply_defaults + def __init__( + self, + sql, + hive_table, + create=True, + recreate=False, + partition=None, + delimiter=chr(1), + mysql_conn_id='mysql_default', + hive_cli_conn_id='hive_cli_default', + tblproperties=None, + *args, **kwargs): + super(MySqlToHiveTransfer, self).__init__(*args, **kwargs) + self.sql = sql + self.hive_table = hive_table + self.partition = partition + self.create = create + self.recreate = recreate + self.delimiter = str(delimiter) + self.mysql_conn_id = mysql_conn_id + self.hive_cli_conn_id = hive_cli_conn_id + self.partition = partition or {} + self.tblproperties = tblproperties + + @classmethod + def type_map(cls, mysql_type): + t = MySQLdb.constants.FIELD_TYPE + d = { + t.BIT: 'INT', + t.DECIMAL: 'DOUBLE', + t.DOUBLE: 'DOUBLE', + t.FLOAT: 'DOUBLE', + t.INT24: 'INT', + t.LONG: 'INT', + t.LONGLONG: 'BIGINT', + t.SHORT: 'INT', + t.YEAR: 'INT', + } + return d[mysql_type] if mysql_type in d else 'STRING' + + def execute(self, context): + hive = HiveCliHook(hive_cli_conn_id=self.hive_cli_conn_id) + mysql = MySqlHook(mysql_conn_id=self.mysql_conn_id) + + self.log.info("Dumping MySQL query results to local file") + conn = mysql.get_conn() + cursor = conn.cursor() + cursor.execute(self.sql) + with NamedTemporaryFile("wb") as f: + csv_writer = csv.writer(f, delimiter=self.delimiter, encoding="utf-8") + field_dict = OrderedDict() + for field in cursor.description: + field_dict[field[0]] = self.type_map(field[1]) + csv_writer.writerows(cursor) + f.flush() + cursor.close() + conn.close() + self.log.info("Loading file into Hive") + hive.load_file( + f.name, + self.hive_table, + field_dict=field_dict, + create=self.create, + partition=self.partition, + delimiter=self.delimiter, + recreate=self.recreate, + tblproperties=self.tblproperties) diff --git a/airflow/operators/oracle_operator.py b/airflow/operators/oracle_operator.py new file mode 100644 index 0000000000000..9a35267947320 --- /dev/null +++ b/airflow/operators/oracle_operator.py @@ -0,0 +1,50 @@ +# -*- coding: utf-8 -*- +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +from airflow.hooks.oracle_hook import OracleHook +from airflow.models import BaseOperator +from airflow.utils.decorators import apply_defaults + + +class OracleOperator(BaseOperator): + """ + Executes sql code in a specific Oracle database + :param oracle_conn_id: reference to a specific Oracle database + :type oracle_conn_id: string + :param sql: the sql code to be executed + :type sql: Can receive a str representing a sql statement, + a list of str (sql statements), or reference to a template file. + Template reference are recognized by str ending in '.sql' + """ + + template_fields = ('sql',) + template_ext = ('.sql',) + ui_color = '#ededed' + + @apply_defaults + def __init__( + self, sql, oracle_conn_id='oracle_default', parameters=None, + autocommit=False, *args, **kwargs): + super(OracleOperator, self).__init__(*args, **kwargs) + self.oracle_conn_id = oracle_conn_id + self.sql = sql + self.autocommit = autocommit + self.parameters = parameters + + def execute(self, context): + self.log.info('Executing: %s', self.sql) + hook = OracleHook(oracle_conn_id=self.oracle_conn_id) + hook.run( + self.sql, + autocommit=self.autocommit, + parameters=self.parameters) diff --git a/airflow/operators/pig_operator.py b/airflow/operators/pig_operator.py new file mode 100644 index 0000000000000..a4e4e5cb34dd4 --- /dev/null +++ b/airflow/operators/pig_operator.py @@ -0,0 +1,67 @@ +# -*- coding: utf-8 -*- +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +import re + +from airflow.hooks.pig_hook import PigCliHook +from airflow.models import BaseOperator +from airflow.utils.decorators import apply_defaults + + +class PigOperator(BaseOperator): + """ + Executes pig script. + + :param pig: the pig latin script to be executed + :type pig: string + :param pig_cli_conn_id: reference to the Hive database + :type pig_cli_conn_id: string + :param pigparams_jinja_translate: when True, pig params-type templating + ${var} gets translated into jinja-type templating {{ var }}. Note that + you may want to use this along with the + ``DAG(user_defined_macros=myargs)`` parameter. View the DAG + object documentation for more details. + :type pigparams_jinja_translate: boolean + """ + + template_fields = ('pig',) + template_ext = ('.pig', '.piglatin',) + ui_color = '#f0e4ec' + + @apply_defaults + def __init__( + self, pig, + pig_cli_conn_id='pig_cli_default', + pigparams_jinja_translate=False, + *args, **kwargs): + + super(PigOperator, self).__init__(*args, **kwargs) + self.pigparams_jinja_translate = pigparams_jinja_translate + self.pig = pig + self.pig_cli_conn_id = pig_cli_conn_id + + def get_hook(self): + return PigCliHook(pig_cli_conn_id=self.pig_cli_conn_id) + + def prepare_template(self): + if self.pigparams_jinja_translate: + self.pig = re.sub( + "(\$([a-zA-Z_][a-zA-Z0-9_]*))", "{{ \g<2> }}", self.pig) + + def execute(self, context): + self.log.info('Executing: %s', self.pig) + self.hook = self.get_hook() + self.hook.run_cli(pig=self.pig) + + def on_kill(self): + self.hook.kill() diff --git a/airflow/operators/postgres_operator.py b/airflow/operators/postgres_operator.py new file mode 100644 index 0000000000000..c93dc7bb5266e --- /dev/null +++ b/airflow/operators/postgres_operator.py @@ -0,0 +1,55 @@ +# -*- coding: utf-8 -*- +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +from airflow.hooks.postgres_hook import PostgresHook +from airflow.models import BaseOperator +from airflow.utils.decorators import apply_defaults + + +class PostgresOperator(BaseOperator): + """ + Executes sql code in a specific Postgres database + + :param postgres_conn_id: reference to a specific postgres database + :type postgres_conn_id: string + :param sql: the sql code to be executed + :type sql: Can receive a str representing a sql statement, + a list of str (sql statements), or reference to a template file. + Template reference are recognized by str ending in '.sql' + :param database: name of database which overwrite defined one in connection + :type database: string + """ + + template_fields = ('sql',) + template_ext = ('.sql',) + ui_color = '#ededed' + + @apply_defaults + def __init__( + self, sql, + postgres_conn_id='postgres_default', autocommit=False, + parameters=None, + database=None, + *args, **kwargs): + super(PostgresOperator, self).__init__(*args, **kwargs) + self.sql = sql + self.postgres_conn_id = postgres_conn_id + self.autocommit = autocommit + self.parameters = parameters + self.database = database + + def execute(self, context): + self.log.info('Executing: %s', self.sql) + self.hook = PostgresHook(postgres_conn_id=self.postgres_conn_id, + schema=self.database) + self.hook.run(self.sql, self.autocommit, parameters=self.parameters) diff --git a/airflow/operators/presto_check_operator.py b/airflow/operators/presto_check_operator.py new file mode 100644 index 0000000000000..e6e1fd81c35de --- /dev/null +++ b/airflow/operators/presto_check_operator.py @@ -0,0 +1,121 @@ +# -*- coding: utf-8 -*- +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from airflow.hooks.presto_hook import PrestoHook +from airflow.operators.check_operator import CheckOperator, ValueCheckOperator, IntervalCheckOperator +from airflow.utils.decorators import apply_defaults + + +class PrestoCheckOperator(CheckOperator): + """ + Performs checks against Presto. The ``PrestoCheckOperator`` expects + a sql query that will return a single row. Each value on that + first row is evaluated using python ``bool`` casting. If any of the + values return ``False`` the check is failed and errors out. + + Note that Python bool casting evals the following as ``False``: + + * ``False`` + * ``0`` + * Empty string (``""``) + * Empty list (``[]``) + * Empty dictionary or set (``{}``) + + Given a query like ``SELECT COUNT(*) FROM foo``, it will fail only if + the count ``== 0``. You can craft much more complex query that could, + for instance, check that the table has the same number of rows as + the source table upstream, or that the count of today's partition is + greater than yesterday's partition, or that a set of metrics are less + than 3 standard deviation for the 7 day average. + + This operator can be used as a data quality check in your pipeline, and + depending on where you put it in your DAG, you have the choice to + stop the critical path, preventing from + publishing dubious data, or on the side and receive email alterts + without stopping the progress of the DAG. + + :param sql: the sql to be executed + :type sql: string + :param presto_conn_id: reference to the Presto database + :type presto_conn_id: string + """ + + @apply_defaults + def __init__( + self, sql, + presto_conn_id='presto_default', + *args, **kwargs): + super(PrestoCheckOperator, self).__init__(sql=sql, *args, **kwargs) + + self.presto_conn_id = presto_conn_id + self.sql = sql + + def get_db_hook(self): + return PrestoHook(presto_conn_id=self.presto_conn_id) + + +class PrestoValueCheckOperator(ValueCheckOperator): + """ + Performs a simple value check using sql code. + + :param sql: the sql to be executed + :type sql: string + :param presto_conn_id: reference to the Presto database + :type presto_conn_id: string + """ + + @apply_defaults + def __init__( + self, sql, pass_value, tolerance=None, + presto_conn_id='presto_default', + *args, **kwargs): + super(PrestoValueCheckOperator, self).__init__( + sql=sql, pass_value=pass_value, tolerance=tolerance, + *args, **kwargs) + self.presto_conn_id = presto_conn_id + + def get_db_hook(self): + return PrestoHook(presto_conn_id=self.presto_conn_id) + + +class PrestoIntervalCheckOperator(IntervalCheckOperator): + """ + Checks that the values of metrics given as SQL expressions are within + a certain tolerance of the ones from days_back before. + + :param table: the table name + :type table: str + :param days_back: number of days between ds and the ds we want to check + against. Defaults to 7 days + :type days_back: int + :param metrics_threshold: a dictionary of ratios indexed by metrics + :type metrics_threshold: dict + :param presto_conn_id: reference to the Presto database + :type presto_conn_id: string + """ + + @apply_defaults + def __init__( + self, table, metrics_thresholds, + date_filter_column='ds', days_back=-7, + presto_conn_id='presto_default', + *args, **kwargs): + super(PrestoIntervalCheckOperator, self).__init__( + table=table, metrics_thresholds=metrics_thresholds, + date_filter_column=date_filter_column, days_back=days_back, + *args, **kwargs) + self.presto_conn_id = presto_conn_id + + def get_db_hook(self): + return PrestoHook(presto_conn_id=self.presto_conn_id) diff --git a/airflow/operators/presto_to_mysql.py b/airflow/operators/presto_to_mysql.py new file mode 100644 index 0000000000000..d0c323a77cd8a --- /dev/null +++ b/airflow/operators/presto_to_mysql.py @@ -0,0 +1,74 @@ +# -*- coding: utf-8 -*- +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +from airflow.hooks.presto_hook import PrestoHook +from airflow.hooks.mysql_hook import MySqlHook +from airflow.models import BaseOperator +from airflow.utils.decorators import apply_defaults + + +class PrestoToMySqlTransfer(BaseOperator): + """ + Moves data from Presto to MySQL, note that for now the data is loaded + into memory before being pushed to MySQL, so this operator should + be used for smallish amount of data. + + :param sql: SQL query to execute against the MySQL database + :type sql: str + :param mysql_table: target MySQL table, use dot notation to target a + specific database + :type mysql_table: str + :param mysql_conn_id: source mysql connection + :type mysql_conn_id: str + :param presto_conn_id: source presto connection + :type presto_conn_id: str + :param mysql_preoperator: sql statement to run against mysql prior to + import, typically use to truncate of delete in place of the data + coming in, allowing the task to be idempotent (running the task + twice won't double load data) + :type mysql_preoperator: str + """ + + template_fields = ('sql', 'mysql_table', 'mysql_preoperator') + template_ext = ('.sql',) + ui_color = '#a0e08c' + + @apply_defaults + def __init__( + self, + sql, + mysql_table, + presto_conn_id='presto_default', + mysql_conn_id='mysql_default', + mysql_preoperator=None, + *args, **kwargs): + super(PrestoToMySqlTransfer, self).__init__(*args, **kwargs) + self.sql = sql + self.mysql_table = mysql_table + self.mysql_conn_id = mysql_conn_id + self.mysql_preoperator = mysql_preoperator + self.presto_conn_id = presto_conn_id + + def execute(self, context): + presto = PrestoHook(presto_conn_id=self.presto_conn_id) + self.log.info("Extracting data from Presto: %s", self.sql) + results = presto.get_records(self.sql) + + mysql = MySqlHook(mysql_conn_id=self.mysql_conn_id) + if self.mysql_preoperator: + self.log.info("Running MySQL preoperator") + self.log.info(self.mysql_preoperator) + mysql.run(self.mysql_preoperator) + + self.log.info("Inserting rows into MySQL") + mysql.insert_rows(table=self.mysql_table, rows=results) diff --git a/airflow/operators/python_operator.py b/airflow/operators/python_operator.py new file mode 100644 index 0000000000000..718c88f23ee6b --- /dev/null +++ b/airflow/operators/python_operator.py @@ -0,0 +1,358 @@ +# -*- coding: utf-8 -*- +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from builtins import str +import dill +import inspect +import os +import pickle +import subprocess +import sys +import types + +from airflow.exceptions import AirflowException +from airflow.models import BaseOperator, SkipMixin +from airflow.utils.decorators import apply_defaults +from airflow.utils.file import TemporaryDirectory + +from textwrap import dedent + + +class PythonOperator(BaseOperator): + """ + Executes a Python callable + + :param python_callable: A reference to an object that is callable + :type python_callable: python callable + :param op_kwargs: a dictionary of keyword arguments that will get unpacked + in your function + :type op_kwargs: dict + :param op_args: a list of positional arguments that will get unpacked when + calling your callable + :type op_args: list + :param provide_context: if set to true, Airflow will pass a set of + keyword arguments that can be used in your function. This set of + kwargs correspond exactly to what you can use in your jinja + templates. For this to work, you need to define `**kwargs` in your + function header. + :type provide_context: bool + :param templates_dict: a dictionary where the values are templates that + will get templated by the Airflow engine sometime between + ``__init__`` and ``execute`` takes place and are made available + in your callable's context after the template has been applied + :type templates_dict: dict of str + :param templates_exts: a list of file extensions to resolve while + processing templated fields, for examples ``['.sql', '.hql']`` + """ + template_fields = ('templates_dict',) + template_ext = tuple() + ui_color = '#ffefeb' + + @apply_defaults + def __init__( + self, + python_callable, + op_args=None, + op_kwargs=None, + provide_context=False, + templates_dict=None, + templates_exts=None, + *args, **kwargs): + super(PythonOperator, self).__init__(*args, **kwargs) + if not callable(python_callable): + raise AirflowException('`python_callable` param must be callable') + self.python_callable = python_callable + self.op_args = op_args or [] + self.op_kwargs = op_kwargs or {} + self.provide_context = provide_context + self.templates_dict = templates_dict + if templates_exts: + self.template_ext = templates_exts + + def execute(self, context): + if self.provide_context: + context.update(self.op_kwargs) + context['templates_dict'] = self.templates_dict + self.op_kwargs = context + + return_value = self.execute_callable() + self.log.info("Done. Returned value was: %s", return_value) + return return_value + + def execute_callable(self): + return self.python_callable(*self.op_args, **self.op_kwargs) + + +class BranchPythonOperator(PythonOperator, SkipMixin): + """ + Allows a workflow to "branch" or follow a single path following the + execution of this task. + + It derives the PythonOperator and expects a Python function that returns + the task_id to follow. The task_id returned should point to a task + directly downstream from {self}. All other "branches" or + directly downstream tasks are marked with a state of ``skipped`` so that + these paths can't move forward. The ``skipped`` states are propageted + downstream to allow for the DAG state to fill up and the DAG run's state + to be inferred. + + Note that using tasks with ``depends_on_past=True`` downstream from + ``BranchPythonOperator`` is logically unsound as ``skipped`` status + will invariably lead to block tasks that depend on their past successes. + ``skipped`` states propagates where all directly upstream tasks are + ``skipped``. + """ + def execute(self, context): + branch = super(BranchPythonOperator, self).execute(context) + self.log.info("Following branch %s", branch) + self.log.info("Marking other directly downstream tasks as skipped") + + downstream_tasks = context['task'].downstream_list + self.log.debug("Downstream task_ids %s", downstream_tasks) + + skip_tasks = [t for t in downstream_tasks if t.task_id != branch] + if downstream_tasks: + self.skip(context['dag_run'], context['ti'].execution_date, skip_tasks) + + self.log.info("Done.") + + +class ShortCircuitOperator(PythonOperator, SkipMixin): + """ + Allows a workflow to continue only if a condition is met. Otherwise, the + workflow "short-circuits" and downstream tasks are skipped. + + The ShortCircuitOperator is derived from the PythonOperator. It evaluates a + condition and short-circuits the workflow if the condition is False. Any + downstream tasks are marked with a state of "skipped". If the condition is + True, downstream tasks proceed as normal. + + The condition is determined by the result of `python_callable`. + """ + def execute(self, context): + condition = super(ShortCircuitOperator, self).execute(context) + self.log.info("Condition result is %s", condition) + + if condition: + self.log.info('Proceeding with downstream tasks...') + return + + self.log.info('Skipping downstream tasks...') + + downstream_tasks = context['task'].get_flat_relatives(upstream=False) + self.log.debug("Downstream task_ids %s", downstream_tasks) + + if downstream_tasks: + self.skip(context['dag_run'], context['ti'].execution_date, downstream_tasks) + + self.log.info("Done.") + +class PythonVirtualenvOperator(PythonOperator): + """ + Allows one to run a function in a virtualenv that is created and destroyed + automatically (with certain caveats). + + The function must be defined using def, and not be part of a class. All imports + must happen inside the function and no variables outside of the scope may be referenced. + A global scope variable named virtualenv_string_args will be available (populated by + string_args). In addition, one can pass stuff through op_args and op_kwargs, and one + can use a return value. + + Note that if your virtualenv runs in a different Python major version than Airflow, + you cannot use return values, op_args, or op_kwargs. You can use string_args though. + + :param python_callable: A python function with no references to outside variables, + defined with def, which will be run in a virtualenv + :type python_callable: function + :param requirements: A list of requirements as specified in a pip install command + :type requirements: list(str) + :param python_version: The Python version to run the virtualenv with. Note that + both 2 and 2.7 are acceptable forms. + :type python_version: str + :param use_dill: Whether to use dill to serialize the args and result (pickle is default). + This allow more complex types but requires you to include dill in your requirements. + :type use_dill: bool + :param system_site_packages: Whether to include system_site_packages in your virtualenv. + See virtualenv documentation for more information. + :type system_site_packages: bool + :param op_args: A list of positional arguments to pass to python_callable. + :type op_kwargs: list + :param op_kwargs: A dict of keyword arguments to pass to python_callable. + :type op_kwargs: dict + :param string_args: Strings that are present in the global var virtualenv_string_args, + available to python_callable at runtime as a list(str). Note that args are split + by newline. + :type string_args: list(str) + + """ + def __init__(self, python_callable, requirements=None, python_version=None, use_dill=False, + system_site_packages=True, op_args=None, op_kwargs=None, string_args=None, + *args, **kwargs): + super(PythonVirtualenvOperator, self).__init__( + python_callable=python_callable, + op_args=op_args, + op_kwargs=op_kwargs, + *args, + **kwargs) + self.requirements = requirements or [] + self.string_args = string_args or [] + self.python_version = python_version + self.use_dill = use_dill + self.system_site_packages = system_site_packages + # check that dill is present if needed + dill_in_requirements = map(lambda x: x.lower().startswith('dill'), self.requirements) + if (not system_site_packages) and use_dill and not any(dill_in_requirements): + raise AirflowException('If using dill, dill must be in the environment ' + + 'either via system_site_packages or requirements') + # check that a function is passed, and that it is not a lambda + if (not isinstance(self.python_callable, types.FunctionType) + or self.python_callable.__name__ == (lambda x: 0).__name__): + raise AirflowException('{} only supports functions for python_callable arg', + self.__class__.__name__) + # check that args are passed iff python major version matches + if (python_version is not None + and str(python_version)[0] != str(sys.version_info[0]) + and self._pass_op_args()): + raise AirflowException("Passing op_args or op_kwargs is not supported across " + "different Python major versions " + "for PythonVirtualenvOperator. Please use string_args.") + + def execute_callable(self): + with TemporaryDirectory(prefix='venv') as tmp_dir: + # generate filenames + input_filename = os.path.join(tmp_dir, 'script.in') + output_filename = os.path.join(tmp_dir, 'script.out') + string_args_filename = os.path.join(tmp_dir, 'string_args.txt') + script_filename = os.path.join(tmp_dir, 'script.py') + + # set up virtualenv + self._execute_in_subprocess(self._generate_virtualenv_cmd(tmp_dir)) + cmd = self._generate_pip_install_cmd(tmp_dir) + if cmd: + self._execute_in_subprocess(cmd) + + self._write_args(input_filename) + self._write_script(script_filename) + self._write_string_args(string_args_filename) + + # execute command in virtualenv + self._execute_in_subprocess( + self._generate_python_cmd(tmp_dir, + script_filename, + input_filename, + output_filename, + string_args_filename)) + return self._read_result(output_filename) + + def _pass_op_args(self): + # we should only pass op_args if any are given to us + return len(self.op_args) + len(self.op_kwargs) > 0 + + def _execute_in_subprocess(self, cmd): + try: + self.log.info("Executing cmd\n{}".format(cmd)) + output = subprocess.check_output(cmd, stderr=subprocess.STDOUT) + if output: + self.log.info("Got output\n{}".format(output)) + except subprocess.CalledProcessError as e: + self.log.info("Got error output\n{}".format(e.output)) + raise + + def _write_string_args(self, filename): + # writes string_args to a file, which are read line by line + with open(filename, 'w') as f: + f.write('\n'.join(map(str, self.string_args))) + + def _write_args(self, input_filename): + # serialize args to file + if self._pass_op_args(): + with open(input_filename, 'wb') as f: + arg_dict = ({'args': self.op_args, 'kwargs': self.op_kwargs}) + if self.use_dill: + dill.dump(arg_dict, f) + else: + pickle.dump(arg_dict, f) + + def _read_result(self, output_filename): + if os.stat(output_filename).st_size == 0: + return None + with open(output_filename, 'rb') as f: + try: + if self.use_dill: + return dill.load(f) + else: + return pickle.load(f) + except ValueError: + self.log.error("Error deserializing result. Note that result deserialization " + "is not supported across major Python versions.") + raise + + def _write_script(self, script_filename): + with open(script_filename, 'w') as f: + python_code = self._generate_python_code() + self.log.debug('Writing code to file\n{}'.format(python_code)) + f.write(python_code) + + def _generate_virtualenv_cmd(self, tmp_dir): + cmd = ['virtualenv', tmp_dir] + if self.system_site_packages: + cmd.append('--system-site-packages') + if self.python_version is not None: + cmd.append('--python=python{}'.format(self.python_version)) + return cmd + + def _generate_pip_install_cmd(self, tmp_dir): + if len(self.requirements) == 0: + return [] + else: + # direct path alleviates need to activate + cmd = ['{}/bin/pip'.format(tmp_dir), 'install'] + return cmd + self.requirements + + def _generate_python_cmd(self, tmp_dir, script_filename, input_filename, output_filename, string_args_filename): + # direct path alleviates need to activate + return ['{}/bin/python'.format(tmp_dir), script_filename, input_filename, output_filename, string_args_filename] + + def _generate_python_code(self): + if self.use_dill: + pickling_library = 'dill' + else: + pickling_library = 'pickle' + fn = self.python_callable + # dont try to read pickle if we didnt pass anything + if self._pass_op_args(): + load_args_line = 'with open(sys.argv[1], "rb") as f: arg_dict = {}.load(f)'.format(pickling_library) + else: + load_args_line = 'arg_dict = {"args": [], "kwargs": {}}' + + # no indents in original code so we can accept any type of indents in the original function + # we deserialize args, call function, serialize result if necessary + return dedent("""\ + import {pickling_library} + import sys + {load_args_code} + args = arg_dict["args"] + kwargs = arg_dict["kwargs"] + with open(sys.argv[3], 'r') as f: virtualenv_string_args = list(map(lambda x: x.strip(), list(f))) + {python_callable_lines} + res = {python_callable_name}(*args, **kwargs) + with open(sys.argv[2], 'wb') as f: res is not None and {pickling_library}.dump(res, f) + """).format( + load_args_code=load_args_line, + python_callable_lines=dedent(inspect.getsource(fn)), + python_callable_name=fn.__name__, + pickling_library=pickling_library) + + self.log.info("Done.") + diff --git a/airflow/operators/redshift_to_s3_operator.py b/airflow/operators/redshift_to_s3_operator.py new file mode 100644 index 0000000000000..683ff9c1eb69a --- /dev/null +++ b/airflow/operators/redshift_to_s3_operator.py @@ -0,0 +1,104 @@ +# -*- coding: utf-8 -*- +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +from airflow.hooks.postgres_hook import PostgresHook +from airflow.hooks.S3_hook import S3Hook +from airflow.models import BaseOperator +from airflow.utils.decorators import apply_defaults + + +class RedshiftToS3Transfer(BaseOperator): + """ + Executes an UNLOAD command to s3 as a CSV with headers + :param schema: reference to a specific schema in redshift database + :type schema: string + :param table: reference to a specific table in redshift database + :type table: string + :param s3_bucket: reference to a specific S3 bucket + :type s3_bucket: string + :param s3_key: reference to a specific S3 key + :type s3_key: string + :param redshift_conn_id: reference to a specific redshift database + :type redshift_conn_id: string + :param s3_conn_id: reference to a specific S3 connection + :type s3_conn_id: string + :param options: reference to a list of UNLOAD options + :type options: list + """ + + template_fields = () + template_ext = () + ui_color = '#ededed' + + @apply_defaults + def __init__( + self, + schema, + table, + s3_bucket, + s3_key, + redshift_conn_id='redshift_default', + s3_conn_id='s3_default', + unload_options=tuple(), + autocommit=False, + parameters=None, + *args, **kwargs): + super(RedshiftToS3Transfer, self).__init__(*args, **kwargs) + self.schema = schema + self.table = table + self.s3_bucket = s3_bucket + self.s3_key = s3_key + self.redshift_conn_id = redshift_conn_id + self.s3_conn_id = s3_conn_id + self.unload_options = unload_options + self.autocommit = autocommit + self.parameters = parameters + + def execute(self, context): + self.hook = PostgresHook(postgres_conn_id=self.redshift_conn_id) + self.s3 = S3Hook(s3_conn_id=self.s3_conn_id) + a_key, s_key = self.s3.get_credentials() + unload_options = '\n\t\t\t'.join(self.unload_options) + + self.log.info("Retrieving headers from %s.%s...", self.schema, self.table) + + columns_query = """SELECT column_name + FROM information_schema.columns + WHERE table_schema = '{0}' + AND table_name = '{1}' + ORDER BY ordinal_position + """.format(self.schema, self.table) + + cursor = self.hook.get_conn().cursor() + cursor.execute(columns_query) + rows = cursor.fetchall() + columns = map(lambda row: row[0], rows) + column_names = ', '.join(map(lambda c: "\\'{0}\\'".format(c), columns)) + column_castings = ', '.join(map(lambda c: "CAST({0} AS text) AS {0}".format(c), + columns)) + + unload_query = """ + UNLOAD ('SELECT {0} + UNION ALL + SELECT {1} FROM {2}.{3} + ORDER BY 1 DESC') + TO 's3://{4}/{5}/{3}_' + with + credentials 'aws_access_key_id={6};aws_secret_access_key={7}' + {8}; + """.format(column_names, column_castings, self.schema, self.table, + self.s3_bucket, self.s3_key, a_key, s_key, unload_options) + + self.log.info('Executing UNLOAD command...') + self.hook.run(unload_query, self.autocommit) + self.log.info("UNLOAD command complete...") diff --git a/airflow/operators/s3_file_transform_operator.py b/airflow/operators/s3_file_transform_operator.py new file mode 100644 index 0000000000000..68c733cc7e541 --- /dev/null +++ b/airflow/operators/s3_file_transform_operator.py @@ -0,0 +1,109 @@ +# -*- coding: utf-8 -*- +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from tempfile import NamedTemporaryFile +import subprocess + +from airflow.exceptions import AirflowException +from airflow.hooks.S3_hook import S3Hook +from airflow.models import BaseOperator +from airflow.utils.decorators import apply_defaults + + +class S3FileTransformOperator(BaseOperator): + """ + Copies data from a source S3 location to a temporary location on the + local filesystem. Runs a transformation on this file as specified by + the transformation script and uploads the output to a destination S3 + location. + + The locations of the source and the destination files in the local + filesystem is provided as an first and second arguments to the + transformation script. The transformation script is expected to read the + data from source , transform it and write the output to the local + destination file. The operator then takes over control and uploads the + local destination file to S3. + + :param source_s3_key: The key to be retrieved from S3 + :type source_s3_key: str + :param source_s3_conn_id: source s3 connection + :type source_s3_conn_id: str + :param dest_s3_key: The key to be written from S3 + :type dest_s3_key: str + :param dest_s3_conn_id: destination s3 connection + :type dest_s3_conn_id: str + :param replace: Replace dest S3 key if it already exists + :type replace: bool + :param transform_script: location of the executable transformation script + :type transform_script: str + """ + + template_fields = ('source_s3_key', 'dest_s3_key') + template_ext = () + ui_color = '#f9c915' + + @apply_defaults + def __init__( + self, + source_s3_key, + dest_s3_key, + transform_script, + source_s3_conn_id='s3_default', + dest_s3_conn_id='s3_default', + replace=False, + *args, **kwargs): + super(S3FileTransformOperator, self).__init__(*args, **kwargs) + self.source_s3_key = source_s3_key + self.source_s3_conn_id = source_s3_conn_id + self.dest_s3_key = dest_s3_key + self.dest_s3_conn_id = dest_s3_conn_id + self.replace = replace + self.transform_script = transform_script + + def execute(self, context): + source_s3 = S3Hook(s3_conn_id=self.source_s3_conn_id) + dest_s3 = S3Hook(s3_conn_id=self.dest_s3_conn_id) + self.log.info("Downloading source S3 file %s", self.source_s3_key) + if not source_s3.check_for_key(self.source_s3_key): + raise AirflowException("The source key {0} does not exist".format(self.source_s3_key)) + source_s3_key_object = source_s3.get_key(self.source_s3_key) + with NamedTemporaryFile("w") as f_source, NamedTemporaryFile("w") as f_dest: + self.log.info( + "Dumping S3 file %s contents to local file %s", + self.source_s3_key, f_source.name + ) + source_s3_key_object.get_contents_to_file(f_source) + f_source.flush() + source_s3.connection.close() + transform_script_process = subprocess.Popen( + [self.transform_script, f_source.name, f_dest.name], + stdout=subprocess.PIPE, stderr=subprocess.PIPE) + (transform_script_stdoutdata, transform_script_stderrdata) = transform_script_process.communicate() + self.log.info("Transform script stdout %s", transform_script_stdoutdata) + if transform_script_process.returncode > 0: + raise AirflowException("Transform script failed %s", transform_script_stderrdata) + else: + self.log.info( + "Transform script successful. Output temporarily located at %s", + f_dest.name + ) + self.log.info("Uploading transformed file to S3") + f_dest.flush() + dest_s3.load_file( + filename=f_dest.name, + key=self.dest_s3_key, + replace=self.replace + ) + self.log.info("Upload successful") + dest_s3.connection.close() diff --git a/airflow/operators/s3_to_hive_operator.py b/airflow/operators/s3_to_hive_operator.py new file mode 100644 index 0000000000000..2b4aceb99cfd8 --- /dev/null +++ b/airflow/operators/s3_to_hive_operator.py @@ -0,0 +1,250 @@ +# -*- coding: utf-8 -*- +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from builtins import next +from builtins import zip +from tempfile import NamedTemporaryFile +from airflow.utils.file import TemporaryDirectory +import gzip +import bz2 +import tempfile +import os + +from airflow.exceptions import AirflowException +from airflow.hooks.S3_hook import S3Hook +from airflow.hooks.hive_hooks import HiveCliHook +from airflow.models import BaseOperator +from airflow.utils.decorators import apply_defaults +from airflow.utils.compression import uncompress_file + + +class S3ToHiveTransfer(BaseOperator): + """ + Moves data from S3 to Hive. The operator downloads a file from S3, + stores the file locally before loading it into a Hive table. + If the ``create`` or ``recreate`` arguments are set to ``True``, + a ``CREATE TABLE`` and ``DROP TABLE`` statements are generated. + Hive data types are inferred from the cursor's metadata from. + + Note that the table generated in Hive uses ``STORED AS textfile`` + which isn't the most efficient serialization format. If a + large amount of data is loaded and/or if the tables gets + queried considerably, you may want to use this operator only to + stage the data into a temporary table before loading it into its + final destination using a ``HiveOperator``. + + :param s3_key: The key to be retrieved from S3 + :type s3_key: str + :param field_dict: A dictionary of the fields name in the file + as keys and their Hive types as values + :type field_dict: dict + :param hive_table: target Hive table, use dot notation to target a + specific database + :type hive_table: str + :param create: whether to create the table if it doesn't exist + :type create: bool + :param recreate: whether to drop and recreate the table at every + execution + :type recreate: bool + :param partition: target partition as a dict of partition columns + and values + :type partition: dict + :param headers: whether the file contains column names on the first + line + :type headers: bool + :param check_headers: whether the column names on the first line should be + checked against the keys of field_dict + :type check_headers: bool + :param wildcard_match: whether the s3_key should be interpreted as a Unix + wildcard pattern + :type wildcard_match: bool + :param delimiter: field delimiter in the file + :type delimiter: str + :param s3_conn_id: source s3 connection + :type s3_conn_id: str + :param hive_cli_conn_id: destination hive connection + :type hive_cli_conn_id: str + :param input_compressed: Boolean to determine if file decompression is + required to process headers + :type input_compressed: bool + :param tblproperties: TBLPROPERTIES of the hive table being created + :type tblproperties: dict + """ + + template_fields = ('s3_key', 'partition', 'hive_table') + template_ext = () + ui_color = '#a0e08c' + + @apply_defaults + def __init__( + self, + s3_key, + field_dict, + hive_table, + delimiter=',', + create=True, + recreate=False, + partition=None, + headers=False, + check_headers=False, + wildcard_match=False, + s3_conn_id='s3_default', + hive_cli_conn_id='hive_cli_default', + input_compressed=False, + tblproperties=None, + *args, **kwargs): + super(S3ToHiveTransfer, self).__init__(*args, **kwargs) + self.s3_key = s3_key + self.field_dict = field_dict + self.hive_table = hive_table + self.delimiter = delimiter + self.create = create + self.recreate = recreate + self.partition = partition + self.headers = headers + self.check_headers = check_headers + self.wildcard_match = wildcard_match + self.hive_cli_conn_id = hive_cli_conn_id + self.s3_conn_id = s3_conn_id + self.input_compressed = input_compressed + self.tblproperties = tblproperties + + if (self.check_headers and + not (self.field_dict is not None and self.headers)): + raise AirflowException("To check_headers provide " + + "field_dict and headers") + + def execute(self, context): + # Downloading file from S3 + self.s3 = S3Hook(s3_conn_id=self.s3_conn_id) + self.hive = HiveCliHook(hive_cli_conn_id=self.hive_cli_conn_id) + self.log.info("Downloading S3 file") + + if self.wildcard_match: + if not self.s3.check_for_wildcard_key(self.s3_key): + raise AirflowException("No key matches {0}" + .format(self.s3_key)) + s3_key_object = self.s3.get_wildcard_key(self.s3_key) + else: + if not self.s3.check_for_key(self.s3_key): + raise AirflowException( + "The key {0} does not exists".format(self.s3_key)) + s3_key_object = self.s3.get_key(self.s3_key) + root, file_ext = os.path.splitext(s3_key_object.key) + with TemporaryDirectory(prefix='tmps32hive_') as tmp_dir,\ + NamedTemporaryFile(mode="w", + dir=tmp_dir, + suffix=file_ext) as f: + self.log.info("Dumping S3 key {0} contents to local file {1}" + .format(s3_key_object.key, f.name)) + s3_key_object.get_contents_to_file(f) + f.flush() + self.s3.connection.close() + if not self.headers: + self.log.info("Loading file %s into Hive", f.name) + self.hive.load_file( + f.name, + self.hive_table, + field_dict=self.field_dict, + create=self.create, + partition=self.partition, + delimiter=self.delimiter, + recreate=self.recreate, + tblproperties=self.tblproperties) + else: + # Decompressing file + if self.input_compressed: + self.log.info("Uncompressing file %s", f.name) + fn_uncompressed = uncompress_file(f.name, + file_ext, + tmp_dir) + self.log.info("Uncompressed to %s", fn_uncompressed) + # uncompressed file available now so deleting + # compressed file to save disk space + f.close() + else: + fn_uncompressed = f.name + + # Testing if header matches field_dict + if self.check_headers: + self.log.info("Matching file header against field_dict") + header_list = self._get_top_row_as_list(fn_uncompressed) + if not self._match_headers(header_list): + raise AirflowException("Header check failed") + + # Deleting top header row + self.log.info("Removing header from file %s", fn_uncompressed) + headless_file = ( + self._delete_top_row_and_compress(fn_uncompressed, + file_ext, + tmp_dir)) + self.log.info("Headless file %s", headless_file) + self.log.info("Loading file %s into Hive", headless_file) + self.hive.load_file(headless_file, + self.hive_table, + field_dict=self.field_dict, + create=self.create, + partition=self.partition, + delimiter=self.delimiter, + recreate=self.recreate, + tblproperties=self.tblproperties) + + def _get_top_row_as_list(self, file_name): + with open(file_name, 'rt') as f: + header_line = f.readline().strip() + header_list = header_line.split(self.delimiter) + return header_list + + def _match_headers(self, header_list): + if not header_list: + raise AirflowException("Unable to retrieve header row from file") + field_names = self.field_dict.keys() + if len(field_names) != len(header_list): + self.log.warning("Headers count mismatch" + "File headers:\n {header_list}\n" + "Field names: \n {field_names}\n" + "".format(**locals())) + return False + test_field_match = [h1.lower() == h2.lower() + for h1, h2 in zip(header_list, field_names)] + if not all(test_field_match): + self.log.warning("Headers do not match field names" + "File headers:\n {header_list}\n" + "Field names: \n {field_names}\n" + "".format(**locals())) + return False + else: + return True + + def _delete_top_row_and_compress( + self, + input_file_name, + output_file_ext, + dest_dir): + # When output_file_ext is not defined, file is not compressed + open_fn = open + if output_file_ext.lower() == '.gz': + open_fn = gzip.GzipFile + elif output_file_ext.lower() == '.bz2': + open_fn = bz2.BZ2File + + os_fh_output, fn_output = \ + tempfile.mkstemp(suffix=output_file_ext, dir=dest_dir) + with open(input_file_name, 'rb') as f_in,\ + open_fn(fn_output, 'wb') as f_out: + f_in.seek(0) + next(f_in) + for line in f_in: + f_out.write(line) + return fn_output diff --git a/airflow/operators/sensors.py b/airflow/operators/sensors.py new file mode 100644 index 0000000000000..14daa6db26c66 --- /dev/null +++ b/airflow/operators/sensors.py @@ -0,0 +1,696 @@ +# -*- coding: utf-8 -*- +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from __future__ import print_function +from future import standard_library + +from airflow.utils.log.logging_mixin import LoggingMixin + +standard_library.install_aliases() +from builtins import str +from past.builtins import basestring + +from datetime import datetime +from urllib.parse import urlparse +from time import sleep +import re +import sys + +from airflow import settings +from airflow.exceptions import AirflowException, AirflowSensorTimeout, AirflowSkipException +from airflow.models import BaseOperator, TaskInstance +from airflow.hooks.base_hook import BaseHook +from airflow.hooks.hdfs_hook import HDFSHook +from airflow.hooks.http_hook import HttpHook +from airflow.utils.state import State +from airflow.utils.decorators import apply_defaults + + +class BaseSensorOperator(BaseOperator): + ''' + Sensor operators are derived from this class an inherit these attributes. + + Sensor operators keep executing at a time interval and succeed when + a criteria is met and fail if and when they time out. + + :param soft_fail: Set to true to mark the task as SKIPPED on failure + :type soft_fail: bool + :param poke_interval: Time in seconds that the job should wait in + between each tries + :type poke_interval: int + :param timeout: Time, in seconds before the task times out and fails. + :type timeout: int + ''' + ui_color = '#e6f1f2' + + @apply_defaults + def __init__( + self, + poke_interval=60, + timeout=60*60*24*7, + soft_fail=False, + *args, **kwargs): + super(BaseSensorOperator, self).__init__(*args, **kwargs) + self.poke_interval = poke_interval + self.soft_fail = soft_fail + self.timeout = timeout + + def poke(self, context): + ''' + Function that the sensors defined while deriving this class should + override. + ''' + raise AirflowException('Override me.') + + def execute(self, context): + started_at = datetime.utcnow() + while not self.poke(context): + if (datetime.utcnow() - started_at).total_seconds() > self.timeout: + if self.soft_fail: + raise AirflowSkipException('Snap. Time is OUT.') + else: + raise AirflowSensorTimeout('Snap. Time is OUT.') + sleep(self.poke_interval) + self.log.info("Success criteria met. Exiting.") + + +class SqlSensor(BaseSensorOperator): + """ + Runs a sql statement until a criteria is met. It will keep trying while + sql returns no row, or if the first cell in (0, '0', ''). + + :param conn_id: The connection to run the sensor against + :type conn_id: string + :param sql: The sql to run. To pass, it needs to return at least one cell + that contains a non-zero / empty string value. + """ + template_fields = ('sql',) + template_ext = ('.hql', '.sql',) + ui_color = '#7c7287' + + @apply_defaults + def __init__(self, conn_id, sql, *args, **kwargs): + self.sql = sql + self.conn_id = conn_id + super(SqlSensor, self).__init__(*args, **kwargs) + + def poke(self, context): + hook = BaseHook.get_connection(self.conn_id).get_hook() + + self.log.info('Poking: %s', self.sql) + records = hook.get_records(self.sql) + if not records: + return False + else: + if str(records[0][0]) in ('0', '',): + return False + else: + return True + + +class MetastorePartitionSensor(SqlSensor): + """ + An alternative to the HivePartitionSensor that talk directly to the + MySQL db. This was created as a result of observing sub optimal + queries generated by the Metastore thrift service when hitting + subpartitioned tables. The Thrift service's queries were written in a + way that wouldn't leverage the indexes. + + :param schema: the schema + :type schema: str + :param table: the table + :type table: str + :param partition_name: the partition name, as defined in the PARTITIONS + table of the Metastore. Order of the fields does matter. + Examples: ``ds=2016-01-01`` or + ``ds=2016-01-01/sub=foo`` for a sub partitioned table + :type partition_name: str + :param mysql_conn_id: a reference to the MySQL conn_id for the metastore + :type mysql_conn_id: str + """ + template_fields = ('partition_name', 'table', 'schema') + ui_color = '#8da7be' + + @apply_defaults + def __init__( + self, table, partition_name, schema="default", + mysql_conn_id="metastore_mysql", + *args, **kwargs): + + self.partition_name = partition_name + self.table = table + self.schema = schema + self.first_poke = True + self.conn_id = mysql_conn_id + # TODO(aoen): We shouldn't be using SqlSensor here but MetastorePartitionSensor. + # The problem is the way apply_defaults works isn't compatible with inheritance. + # The inheritance model needs to be reworked in order to support overriding args/ + # kwargs with arguments here, then 'conn_id' and 'sql' can be passed into the + # constructor below and apply_defaults will no longer throw an exception. + super(SqlSensor, self).__init__(*args, **kwargs) + + def poke(self, context): + if self.first_poke: + self.first_poke = False + if '.' in self.table: + self.schema, self.table = self.table.split('.') + self.sql = """ + SELECT 'X' + FROM PARTITIONS A0 + LEFT OUTER JOIN TBLS B0 ON A0.TBL_ID = B0.TBL_ID + LEFT OUTER JOIN DBS C0 ON B0.DB_ID = C0.DB_ID + WHERE + B0.TBL_NAME = '{self.table}' AND + C0.NAME = '{self.schema}' AND + A0.PART_NAME = '{self.partition_name}'; + """.format(self=self) + return super(MetastorePartitionSensor, self).poke(context) + + +class ExternalTaskSensor(BaseSensorOperator): + """ + Waits for a task to complete in a different DAG + + :param external_dag_id: The dag_id that contains the task you want to + wait for + :type external_dag_id: string + :param external_task_id: The task_id that contains the task you want to + wait for + :type external_task_id: string + :param allowed_states: list of allowed states, default is ``['success']`` + :type allowed_states: list + :param execution_delta: time difference with the previous execution to + look at, the default is the same execution_date as the current task. + For yesterday, use [positive!] datetime.timedelta(days=1). Either + execution_delta or execution_date_fn can be passed to + ExternalTaskSensor, but not both. + :type execution_delta: datetime.timedelta + :param execution_date_fn: function that receives the current execution date + and returns the desired execution dates to query. Either execution_delta + or execution_date_fn can be passed to ExternalTaskSensor, but not both. + :type execution_date_fn: callable + """ + ui_color = '#19647e' + + @apply_defaults + def __init__( + self, + external_dag_id, + external_task_id, + allowed_states=None, + execution_delta=None, + execution_date_fn=None, + *args, **kwargs): + super(ExternalTaskSensor, self).__init__(*args, **kwargs) + self.allowed_states = allowed_states or [State.SUCCESS] + if execution_delta is not None and execution_date_fn is not None: + raise ValueError( + 'Only one of `execution_date` or `execution_date_fn` may' + 'be provided to ExternalTaskSensor; not both.') + + self.execution_delta = execution_delta + self.execution_date_fn = execution_date_fn + self.external_dag_id = external_dag_id + self.external_task_id = external_task_id + + def poke(self, context): + if self.execution_delta: + dttm = context['execution_date'] - self.execution_delta + elif self.execution_date_fn: + dttm = self.execution_date_fn(context['execution_date']) + else: + dttm = context['execution_date'] + + dttm_filter = dttm if isinstance(dttm, list) else [dttm] + serialized_dttm_filter = ','.join( + [datetime.isoformat() for datetime in dttm_filter]) + + self.log.info( + 'Poking for ' + '{self.external_dag_id}.' + '{self.external_task_id} on ' + '{} ... '.format(serialized_dttm_filter, **locals())) + TI = TaskInstance + + session = settings.Session() + count = session.query(TI).filter( + TI.dag_id == self.external_dag_id, + TI.task_id == self.external_task_id, + TI.state.in_(self.allowed_states), + TI.execution_date.in_(dttm_filter), + ).count() + session.commit() + session.close() + return count == len(dttm_filter) + + +class NamedHivePartitionSensor(BaseSensorOperator): + """ + Waits for a set of partitions to show up in Hive. + + :param partition_names: List of fully qualified names of the + partitions to wait for. A fully qualified name is of the + form ``schema.table/pk1=pv1/pk2=pv2``, for example, + default.users/ds=2016-01-01. This is passed as is to the metastore + Thrift client ``get_partitions_by_name`` method. Note that + you cannot use logical or comparison operators as in + HivePartitionSensor. + :type partition_names: list of strings + :param metastore_conn_id: reference to the metastore thrift service + connection id + :type metastore_conn_id: str + """ + + template_fields = ('partition_names', ) + ui_color = '#8d99ae' + + @apply_defaults + def __init__( + self, + partition_names, + metastore_conn_id='metastore_default', + poke_interval=60 * 3, + *args, + **kwargs): + super(NamedHivePartitionSensor, self).__init__( + poke_interval=poke_interval, *args, **kwargs) + + if isinstance(partition_names, basestring): + raise TypeError('partition_names must be an array of strings') + + self.metastore_conn_id = metastore_conn_id + self.partition_names = partition_names + self.next_poke_idx = 0 + + @classmethod + def parse_partition_name(self, partition): + try: + schema, table_partition = partition.split('.', 1) + table, partition = table_partition.split('/', 1) + return schema, table, partition + except ValueError as e: + raise ValueError('Could not parse ' + partition) + + def poke(self, context): + if not hasattr(self, 'hook'): + from airflow.hooks.hive_hooks import HiveMetastoreHook + self.hook = HiveMetastoreHook( + metastore_conn_id=self.metastore_conn_id) + + def poke_partition(partition): + + schema, table, partition = self.parse_partition_name(partition) + + self.log.info( + 'Poking for {schema}.{table}/{partition}'.format(**locals()) + ) + return self.hook.check_for_named_partition( + schema, table, partition) + + while self.next_poke_idx < len(self.partition_names): + if poke_partition(self.partition_names[self.next_poke_idx]): + self.next_poke_idx += 1 + else: + return False + + return True + + +class HivePartitionSensor(BaseSensorOperator): + """ + Waits for a partition to show up in Hive. + + Note: Because ``partition`` supports general logical operators, it + can be inefficient. Consider using NamedHivePartitionSensor instead if + you don't need the full flexibility of HivePartitionSensor. + + :param table: The name of the table to wait for, supports the dot + notation (my_database.my_table) + :type table: string + :param partition: The partition clause to wait for. This is passed as + is to the metastore Thrift client ``get_partitions_by_filter`` method, + and apparently supports SQL like notation as in ``ds='2015-01-01' + AND type='value'`` and comparison operators as in ``"ds>=2015-01-01"`` + :type partition: string + :param metastore_conn_id: reference to the metastore thrift service + connection id + :type metastore_conn_id: str + """ + template_fields = ('schema', 'table', 'partition',) + ui_color = '#C5CAE9' + + @apply_defaults + def __init__( + self, + table, partition="ds='{{ ds }}'", + metastore_conn_id='metastore_default', + schema='default', + poke_interval=60*3, + *args, **kwargs): + super(HivePartitionSensor, self).__init__( + poke_interval=poke_interval, *args, **kwargs) + if not partition: + partition = "ds='{{ ds }}'" + self.metastore_conn_id = metastore_conn_id + self.table = table + self.partition = partition + self.schema = schema + + def poke(self, context): + if '.' in self.table: + self.schema, self.table = self.table.split('.') + self.log.info( + 'Poking for table {self.schema}.{self.table}, ' + 'partition {self.partition}'.format(**locals())) + if not hasattr(self, 'hook'): + from airflow.hooks.hive_hooks import HiveMetastoreHook + self.hook = HiveMetastoreHook( + metastore_conn_id=self.metastore_conn_id) + return self.hook.check_for_partition( + self.schema, self.table, self.partition) + + +class HdfsSensor(BaseSensorOperator): + """ + Waits for a file or folder to land in HDFS + """ + template_fields = ('filepath',) + ui_color = settings.WEB_COLORS['LIGHTBLUE'] + + @apply_defaults + def __init__( + self, + filepath, + hdfs_conn_id='hdfs_default', + ignored_ext=['_COPYING_'], + ignore_copying=True, + file_size=None, + hook=HDFSHook, + *args, **kwargs): + super(HdfsSensor, self).__init__(*args, **kwargs) + self.filepath = filepath + self.hdfs_conn_id = hdfs_conn_id + self.file_size = file_size + self.ignored_ext = ignored_ext + self.ignore_copying = ignore_copying + self.hook = hook + + @staticmethod + def filter_for_filesize(result, size=None): + """ + Will test the filepath result and test if its size is at least self.filesize + + :param result: a list of dicts returned by Snakebite ls + :param size: the file size in MB a file should be at least to trigger True + :return: (bool) depending on the matching criteria + """ + if size: + log = LoggingMixin().log + log.debug('Filtering for file size >= %s in files: %s', size, map(lambda x: x['path'], result)) + size *= settings.MEGABYTE + result = [x for x in result if x['length'] >= size] + log.debug('HdfsSensor.poke: after size filter result is %s', result) + return result + + @staticmethod + def filter_for_ignored_ext(result, ignored_ext, ignore_copying): + """ + Will filter if instructed to do so the result to remove matching criteria + + :param result: (list) of dicts returned by Snakebite ls + :param ignored_ext: (list) of ignored extensions + :param ignore_copying: (bool) shall we ignore ? + :return: (list) of dicts which were not removed + """ + if ignore_copying: + log = LoggingMixin().log + regex_builder = "^.*\.(%s$)$" % '$|'.join(ignored_ext) + ignored_extentions_regex = re.compile(regex_builder) + log.debug( + 'Filtering result for ignored extensions: %s in files %s', + ignored_extentions_regex.pattern, map(lambda x: x['path'], result) + ) + result = [x for x in result if not ignored_extentions_regex.match(x['path'])] + log.debug('HdfsSensor.poke: after ext filter result is %s', result) + return result + + def poke(self, context): + sb = self.hook(self.hdfs_conn_id).get_conn() + self.log.info('Poking for file {self.filepath}'.format(**locals())) + try: + # IMOO it's not right here, as there no raise of any kind. + # if the filepath is let's say '/data/mydirectory', it's correct but if it is '/data/mydirectory/*', + # it's not correct as the directory exists and sb does not raise any error + # here is a quick fix + result = [f for f in sb.ls([self.filepath], include_toplevel=False)] + self.log.debug('HdfsSensor.poke: result is %s', result) + result = self.filter_for_ignored_ext(result, self.ignored_ext, self.ignore_copying) + result = self.filter_for_filesize(result, self.file_size) + return bool(result) + except: + e = sys.exc_info() + self.log.debug("Caught an exception !: %s", str(e)) + return False + + +class WebHdfsSensor(BaseSensorOperator): + """ + Waits for a file or folder to land in HDFS + """ + template_fields = ('filepath',) + + @apply_defaults + def __init__( + self, + filepath, + webhdfs_conn_id='webhdfs_default', + *args, **kwargs): + super(WebHdfsSensor, self).__init__(*args, **kwargs) + self.filepath = filepath + self.webhdfs_conn_id = webhdfs_conn_id + + def poke(self, context): + from airflow.hooks.webhdfs_hook import WebHDFSHook + c = WebHDFSHook(self.webhdfs_conn_id) + self.log.info('Poking for file {self.filepath}'.format(**locals())) + return c.check_for_path(hdfs_path=self.filepath) + + +class S3KeySensor(BaseSensorOperator): + """ + Waits for a key (a file-like instance on S3) to be present in a S3 bucket. + S3 being a key/value it does not support folders. The path is just a key + a resource. + + :param bucket_key: The key being waited on. Supports full s3:// style url + or relative path from root level. + :type bucket_key: str + :param bucket_name: Name of the S3 bucket + :type bucket_name: str + :param wildcard_match: whether the bucket_key should be interpreted as a + Unix wildcard pattern + :type wildcard_match: bool + :param s3_conn_id: a reference to the s3 connection + :type s3_conn_id: str + """ + template_fields = ('bucket_key', 'bucket_name') + + @apply_defaults + def __init__( + self, bucket_key, + bucket_name=None, + wildcard_match=False, + s3_conn_id='s3_default', + *args, **kwargs): + super(S3KeySensor, self).__init__(*args, **kwargs) + # Parse + if bucket_name is None: + parsed_url = urlparse(bucket_key) + if parsed_url.netloc == '': + raise AirflowException('Please provide a bucket_name') + else: + bucket_name = parsed_url.netloc + if parsed_url.path[0] == '/': + bucket_key = parsed_url.path[1:] + else: + bucket_key = parsed_url.path + self.bucket_name = bucket_name + self.bucket_key = bucket_key + self.wildcard_match = wildcard_match + self.s3_conn_id = s3_conn_id + + def poke(self, context): + from airflow.hooks.S3_hook import S3Hook + hook = S3Hook(s3_conn_id=self.s3_conn_id) + full_url = "s3://" + self.bucket_name + "/" + self.bucket_key + self.log.info('Poking for key : {full_url}'.format(**locals())) + if self.wildcard_match: + return hook.check_for_wildcard_key(self.bucket_key, + self.bucket_name) + else: + return hook.check_for_key(self.bucket_key, self.bucket_name) + + +class S3PrefixSensor(BaseSensorOperator): + """ + Waits for a prefix to exist. A prefix is the first part of a key, + thus enabling checking of constructs similar to glob airfl* or + SQL LIKE 'airfl%'. There is the possibility to precise a delimiter to + indicate the hierarchy or keys, meaning that the match will stop at that + delimiter. Current code accepts sane delimiters, i.e. characters that + are NOT special characters in the Python regex engine. + + :param bucket_name: Name of the S3 bucket + :type bucket_name: str + :param prefix: The prefix being waited on. Relative path from bucket root level. + :type prefix: str + :param delimiter: The delimiter intended to show hierarchy. + Defaults to '/'. + :type delimiter: str + """ + template_fields = ('prefix', 'bucket_name') + + @apply_defaults + def __init__( + self, bucket_name, + prefix, delimiter='/', + s3_conn_id='s3_default', + *args, **kwargs): + super(S3PrefixSensor, self).__init__(*args, **kwargs) + # Parse + self.bucket_name = bucket_name + self.prefix = prefix + self.delimiter = delimiter + self.full_url = "s3://" + bucket_name + '/' + prefix + self.s3_conn_id = s3_conn_id + + def poke(self, context): + self.log.info('Poking for prefix : {self.prefix}\n' + 'in bucket s3://{self.bucket_name}'.format(**locals())) + from airflow.hooks.S3_hook import S3Hook + hook = S3Hook(s3_conn_id=self.s3_conn_id) + return hook.check_for_prefix( + prefix=self.prefix, + delimiter=self.delimiter, + bucket_name=self.bucket_name) + + +class TimeSensor(BaseSensorOperator): + """ + Waits until the specified time of the day. + + :param target_time: time after which the job succeeds + :type target_time: datetime.time + """ + template_fields = tuple() + + @apply_defaults + def __init__(self, target_time, *args, **kwargs): + super(TimeSensor, self).__init__(*args, **kwargs) + self.target_time = target_time + + def poke(self, context): + self.log.info('Checking if the time (%s) has come', self.target_time) + return datetime.utcnow().time() > self.target_time + + +class TimeDeltaSensor(BaseSensorOperator): + """ + Waits for a timedelta after the task's execution_date + schedule_interval. + In Airflow, the daily task stamped with ``execution_date`` + 2016-01-01 can only start running on 2016-01-02. The timedelta here + represents the time after the execution period has closed. + + :param delta: time length to wait after execution_date before succeeding + :type delta: datetime.timedelta + """ + template_fields = tuple() + + @apply_defaults + def __init__(self, delta, *args, **kwargs): + super(TimeDeltaSensor, self).__init__(*args, **kwargs) + self.delta = delta + + def poke(self, context): + dag = context['dag'] + target_dttm = dag.following_schedule(context['execution_date']) + target_dttm += self.delta + self.log.info('Checking if the time (%s) has come', target_dttm) + return datetime.utcnow() > target_dttm + + +class HttpSensor(BaseSensorOperator): + """ + Executes a HTTP get statement and returns False on failure: + 404 not found or response_check function returned False + + :param http_conn_id: The connection to run the sensor against + :type http_conn_id: string + :param method: The HTTP request method to use + :type method: string + :param endpoint: The relative part of the full url + :type endpoint: string + :param request_params: The parameters to be added to the GET url + :type request_params: a dictionary of string key/value pairs + :param headers: The HTTP headers to be added to the GET request + :type headers: a dictionary of string key/value pairs + :param response_check: A check against the 'requests' response object. + Returns True for 'pass' and False otherwise. + :type response_check: A lambda or defined function. + :param extra_options: Extra options for the 'requests' library, see the + 'requests' documentation (options to modify timeout, ssl, etc.) + :type extra_options: A dictionary of options, where key is string and value + depends on the option that's being modified. + """ + + template_fields = ('endpoint', 'request_params') + + @apply_defaults + def __init__(self, + endpoint, + http_conn_id='http_default', + method='GET', + request_params=None, + headers=None, + response_check=None, + extra_options=None, *args, **kwargs): + super(HttpSensor, self).__init__(*args, **kwargs) + self.endpoint = endpoint + self.http_conn_id = http_conn_id + self.request_params = request_params or {} + self.headers = headers or {} + self.extra_options = extra_options or {} + self.response_check = response_check + + self.hook = HttpHook( + method=method, + http_conn_id=http_conn_id) + + def poke(self, context): + self.log.info('Poking: %s', self.endpoint) + try: + response = self.hook.run(self.endpoint, + data=self.request_params, + headers=self.headers, + extra_options=self.extra_options) + if self.response_check: + # run content check on response + return self.response_check(response) + except AirflowException as ae: + if str(ae).startswith("404"): + return False + + raise ae + + return True diff --git a/airflow/operators/slack_operator.py b/airflow/operators/slack_operator.py new file mode 100644 index 0000000000000..8b21211646ede --- /dev/null +++ b/airflow/operators/slack_operator.py @@ -0,0 +1,119 @@ +# -*- coding: utf-8 -*- +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import json + +from slackclient import SlackClient +from airflow.models import BaseOperator +from airflow.utils.decorators import apply_defaults +from airflow.exceptions import AirflowException + + +class SlackAPIOperator(BaseOperator): + """ + Base Slack Operator + The SlackAPIPostOperator is derived from this operator. + In the future additional Slack API Operators will be derived from this class as well + + :param token: Slack API token (https://api.slack.com/web) + :type token: string + :param method: The Slack API Method to Call (https://api.slack.com/methods) + :type method: string + :param api_params: API Method call parameters (https://api.slack.com/methods) + :type api_params: dict + """ + + @apply_defaults + def __init__(self, + token='unset', + method='unset', + api_params=None, + *args, **kwargs): + super(SlackAPIOperator, self).__init__(*args, **kwargs) + self.token = token + self.method = method + self.api_params = api_params + + def construct_api_call_params(self): + """ + Used by the execute function. Allows templating on the source fields of the api_call_params dict before construction + + Override in child classes. + Each SlackAPIOperator child class is responsible for having a construct_api_call_params function + which sets self.api_call_params with a dict of API call parameters (https://api.slack.com/methods) + """ + + pass + + def execute(self, **kwargs): + """ + SlackAPIOperator calls will not fail even if the call is not unsuccessful. + It should not prevent a DAG from completing in success + """ + if not self.api_params: + self.construct_api_call_params() + sc = SlackClient(self.token) + rc = sc.api_call(self.method, **self.api_params) + if not rc['ok']: + msg = "Slack API call failed (%s)".format(rc['error']) + self.log.error(msg) + raise AirflowException(msg) + + +class SlackAPIPostOperator(SlackAPIOperator): + """ + Posts messages to a slack channel + + :param channel: channel in which to post message on slack name (#general) or ID (C12318391) + :type channel: string + :param username: Username that airflow will be posting to Slack as + :type username: string + :param text: message to send to slack + :type text: string + :param icon_url: url to icon used for this message + :type icon_url: string + :param attachments: extra formatting details - see https://api.slack.com/docs/attachments + :type attachments: array of hashes + """ + + template_fields = ('username', 'text', 'attachments', 'channel') + ui_color = '#FFBA40' + + @apply_defaults + def __init__(self, + channel='#general', + username='Airflow', + text='No message has been set.\n' + 'Here is a cat video instead\n' + 'https://www.youtube.com/watch?v=J---aiyznGQ', + icon_url='https://raw.githubusercontent.com/airbnb/airflow/master/airflow/www/static/pin_100.png', + attachments=None, + *args, **kwargs): + self.method = 'chat.postMessage' + self.channel = channel + self.username = username + self.text = text + self.icon_url = icon_url + self.attachments = attachments + super(SlackAPIPostOperator, self).__init__(method=self.method, + *args, **kwargs) + + def construct_api_call_params(self): + self.api_params = { + 'channel': self.channel, + 'username': self.username, + 'text': self.text, + 'icon_url': self.icon_url, + 'attachments': json.dumps(self.attachments), + } diff --git a/airflow/operators/sqlite_operator.py b/airflow/operators/sqlite_operator.py new file mode 100644 index 0000000000000..b32837d9e9707 --- /dev/null +++ b/airflow/operators/sqlite_operator.py @@ -0,0 +1,46 @@ +# -*- coding: utf-8 -*- +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +from airflow.hooks.sqlite_hook import SqliteHook +from airflow.models import BaseOperator +from airflow.utils.decorators import apply_defaults + + +class SqliteOperator(BaseOperator): + """ + Executes sql code in a specific Sqlite database + + :param sqlite_conn_id: reference to a specific sqlite database + :type sqlite_conn_id: string + :param sql: the sql code to be executed + :type sql: string or string pointing to a template file. File must have + a '.sql' extensions. + """ + + template_fields = ('sql',) + template_ext = ('.sql',) + ui_color = '#cdaaed' + + @apply_defaults + def __init__( + self, sql, sqlite_conn_id='sqlite_default', parameters=None, + *args, **kwargs): + super(SqliteOperator, self).__init__(*args, **kwargs) + self.sqlite_conn_id = sqlite_conn_id + self.sql = sql + self.parameters = parameters or [] + + def execute(self, context): + self.log.info('Executing: %s', self.sql) + hook = SqliteHook(sqlite_conn_id=self.sqlite_conn_id) + hook.run(self.sql, parameters=self.parameters) diff --git a/airflow/operators/subdag_operator.py b/airflow/operators/subdag_operator.py new file mode 100644 index 0000000000000..9445c4c96d32c --- /dev/null +++ b/airflow/operators/subdag_operator.py @@ -0,0 +1,92 @@ +# -*- coding: utf-8 -*- +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from airflow.exceptions import AirflowException +from airflow.models import BaseOperator, Pool +from airflow.utils.decorators import apply_defaults +from airflow.utils.db import provide_session +from airflow.executors import GetDefaultExecutor + + +class SubDagOperator(BaseOperator): + + template_fields = tuple() + ui_color = '#555' + ui_fgcolor = '#fff' + + @provide_session + @apply_defaults + def __init__( + self, + subdag, + executor=GetDefaultExecutor(), + *args, **kwargs): + """ + Yo dawg. This runs a sub dag. By convention, a sub dag's dag_id + should be prefixed by its parent and a dot. As in `parent.child`. + + :param subdag: the DAG object to run as a subdag of the current DAG. + :type subdag: airflow.DAG + :param dag: the parent DAG + :type subdag: airflow.DAG + """ + import airflow.models + dag = kwargs.get('dag') or airflow.models._CONTEXT_MANAGER_DAG + if not dag: + raise AirflowException('Please pass in the `dag` param or call ' + 'within a DAG context manager') + session = kwargs.pop('session') + super(SubDagOperator, self).__init__(*args, **kwargs) + + # validate subdag name + if dag.dag_id + '.' + kwargs['task_id'] != subdag.dag_id: + raise AirflowException( + "The subdag's dag_id should have the form " + "'{{parent_dag_id}}.{{this_task_id}}'. Expected " + "'{d}.{t}'; received '{rcvd}'.".format( + d=dag.dag_id, t=kwargs['task_id'], rcvd=subdag.dag_id)) + + # validate that subdag operator and subdag tasks don't have a + # pool conflict + if self.pool: + conflicts = [t for t in subdag.tasks if t.pool == self.pool] + if conflicts: + # only query for pool conflicts if one may exist + pool = ( + session + .query(Pool) + .filter(Pool.slots == 1) + .filter(Pool.pool == self.pool) + .first() + ) + if pool and any(t.pool == self.pool for t in subdag.tasks): + raise AirflowException( + 'SubDagOperator {sd} and subdag task{plural} {t} both ' + 'use pool {p}, but the pool only has 1 slot. The ' + 'subdag tasks will never run.'.format( + sd=self.task_id, + plural=len(conflicts) > 1, + t=', '.join(t.task_id for t in conflicts), + p=self.pool + ) + ) + + self.subdag = subdag + self.executor = executor + + def execute(self, context): + ed = context['execution_date'] + self.subdag.run( + start_date=ed, end_date=ed, donot_pickle=True, + executor=self.executor) diff --git a/airflow/plugins_manager.py b/airflow/plugins_manager.py new file mode 100644 index 0000000000000..d5c3407a86dae --- /dev/null +++ b/airflow/plugins_manager.py @@ -0,0 +1,125 @@ +# -*- coding: utf-8 -*- +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +from __future__ import absolute_import +from __future__ import division +from __future__ import print_function +from __future__ import unicode_literals + +from builtins import object +import imp +import inspect +import os +import re +import sys + +from airflow import configuration +from airflow.utils.log.logging_mixin import LoggingMixin + +log = LoggingMixin().log + +class AirflowPluginException(Exception): + pass + + +class AirflowPlugin(object): + name = None + operators = [] + hooks = [] + executors = [] + macros = [] + admin_views = [] + flask_blueprints = [] + menu_links = [] + + @classmethod + def validate(cls): + if not cls.name: + raise AirflowPluginException("Your plugin needs a name.") + + +plugins_folder = configuration.get('core', 'plugins_folder') +if not plugins_folder: + plugins_folder = configuration.get('core', 'airflow_home') + '/plugins' +plugins_folder = os.path.expanduser(plugins_folder) + +if plugins_folder not in sys.path: + sys.path.append(plugins_folder) + +plugins = [] + +norm_pattern = re.compile(r'[/|.]') + +# Crawl through the plugins folder to find AirflowPlugin derivatives +for root, dirs, files in os.walk(plugins_folder, followlinks=True): + for f in files: + try: + filepath = os.path.join(root, f) + if not os.path.isfile(filepath): + continue + mod_name, file_ext = os.path.splitext( + os.path.split(filepath)[-1]) + if file_ext != '.py': + continue + + log.debug('Importing plugin module %s', filepath) + # normalize root path as namespace + namespace = '_'.join([re.sub(norm_pattern, '__', root), mod_name]) + + m = imp.load_source(namespace, filepath) + for obj in list(m.__dict__.values()): + if ( + inspect.isclass(obj) and + issubclass(obj, AirflowPlugin) and + obj is not AirflowPlugin): + obj.validate() + if obj not in plugins: + plugins.append(obj) + + except Exception as e: + log.exception(e) + log.error('Failed to import plugin %s', filepath) + + +def make_module(name, objects): + log.debug('Creating module %s', name) + name = name.lower() + module = imp.new_module(name) + module._name = name.split('.')[-1] + module._objects = objects + module.__dict__.update((o.__name__, o) for o in objects) + return module + +# Plugin components to integrate as modules +operators_modules = [] +hooks_modules = [] +executors_modules = [] +macros_modules = [] + +# Plugin components to integrate directly +admin_views = [] +flask_blueprints = [] +menu_links = [] + +for p in plugins: + operators_modules.append( + make_module('airflow.operators.' + p.name, p.operators)) + hooks_modules.append(make_module('airflow.hooks.' + p.name, p.hooks)) + executors_modules.append( + make_module('airflow.executors.' + p.name, p.executors)) + macros_modules.append(make_module('airflow.macros.' + p.name, p.macros)) + + admin_views.extend(p.admin_views) + flask_blueprints.extend(p.flask_blueprints) + menu_links.extend(p.menu_links) diff --git a/airflow/security/__init__.py b/airflow/security/__init__.py new file mode 100644 index 0000000000000..c82f5790fe906 --- /dev/null +++ b/airflow/security/__init__.py @@ -0,0 +1,14 @@ +# -*- coding: utf-8 -*- +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + diff --git a/airflow/security/kerberos.py b/airflow/security/kerberos.py new file mode 100644 index 0000000000000..7a169b29d3c1f --- /dev/null +++ b/airflow/security/kerberos.py @@ -0,0 +1,113 @@ +#!/usr/bin/env python +# Licensed to Cloudera, Inc. under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. Cloudera, Inc. licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import socket +import subprocess +import sys +import time + +from airflow import configuration, LoggingMixin + +NEED_KRB181_WORKAROUND = None + +log = LoggingMixin().log + + +def renew_from_kt(): + # The config is specified in seconds. But we ask for that same amount in + # minutes to give ourselves a large renewal buffer. + renewal_lifetime = "%sm" % configuration.getint('kerberos', 'reinit_frequency') + principal = configuration.get('kerberos', 'principal').replace("_HOST", socket.getfqdn()) + + cmdv = [configuration.get('kerberos', 'kinit_path'), + "-r", renewal_lifetime, + "-k", # host ticket + "-t", configuration.get('kerberos', 'keytab'), # specify keytab + "-c", configuration.get('kerberos', 'ccache'), # specify credentials cache + principal] + log.info("Reinitting kerberos from keytab: " + " ".join(cmdv)) + + subp = subprocess.Popen(cmdv, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + close_fds=True, + bufsize=-1, + universal_newlines=True) + subp.wait() + if subp.returncode != 0: + log.error("Couldn't reinit from keytab! `kinit' exited with %s.\n%s\n%s" % ( + subp.returncode, + "\n".join(subp.stdout.readlines()), + "\n".join(subp.stderr.readlines()))) + sys.exit(subp.returncode) + + global NEED_KRB181_WORKAROUND + if NEED_KRB181_WORKAROUND is None: + NEED_KRB181_WORKAROUND = detect_conf_var() + if NEED_KRB181_WORKAROUND: + # (From: HUE-640). Kerberos clock have seconds level granularity. Make sure we + # renew the ticket after the initial valid time. + time.sleep(1.5) + perform_krb181_workaround() + + +def perform_krb181_workaround(): + cmdv = [configuration.get('kerberos', 'kinit_path'), + "-c", configuration.get('kerberos', 'ccache'), + "-R"] # Renew ticket_cache + + log.info("Renewing kerberos ticket to work around kerberos 1.8.1: " + + " ".join(cmdv)) + + ret = subprocess.call(cmdv) + + if ret != 0: + principal = "%s/%s" % (configuration.get('kerberos', 'principal'), socket.getfqdn()) + fmt_dict = dict(princ=principal, + ccache=configuration.get('kerberos', 'principal')) + log.error("Couldn't renew kerberos ticket in order to work around " + "Kerberos 1.8.1 issue. Please check that the ticket for " + "'%(princ)s' is still renewable:\n" + " $ kinit -f -c %(ccache)s\n" + "If the 'renew until' date is the same as the 'valid starting' " + "date, the ticket cannot be renewed. Please check your KDC " + "configuration, and the ticket renewal policy (maxrenewlife) " + "for the '%(princ)s' and `krbtgt' principals." % fmt_dict) + sys.exit(ret) + + +def detect_conf_var(): + """Return true if the ticket cache contains "conf" information as is found + in ticket caches of Kerberos 1.8.1 or later. This is incompatible with the + Sun Java Krb5LoginModule in Java6, so we need to take an action to work + around it. + """ + ticket_cache = configuration.get('kerberos', 'ccache') + + with open(ticket_cache, 'rb') as f: + # Note: this file is binary, so we check against a bytearray. + return b'X-CACHECONF:' in f.read() + + +def run(): + if configuration.get('kerberos', 'keytab') is None: + log.debug("Keytab renewer not starting, no keytab configured") + sys.exit(0) + + while True: + renew_from_kt() + time.sleep(configuration.getint('kerberos', 'reinit_frequency')) diff --git a/airflow/security/utils.py b/airflow/security/utils.py new file mode 100644 index 0000000000000..b2de3e3eea917 --- /dev/null +++ b/airflow/security/utils.py @@ -0,0 +1,80 @@ +#!/usr/bin/env python +# Licensed to Cloudera, Inc. under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. Cloudera, Inc. licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +import re +import socket +import airflow.configuration as conf + +# Pattern to replace with hostname +HOSTNAME_PATTERN = '_HOST' + + +def get_kerberos_principal(principal, host): + components = get_components(principal) + if not components or len(components) != 3 or components[1] != HOSTNAME_PATTERN: + return principal + else: + if not host: + raise IOError("Can't replace %s pattern since host is null." % HOSTNAME_PATTERN) + return replace_hostname_pattern(components, host) + + +def get_components(principal): + """ + get_components(principal) -> (short name, instance (FQDN), realm) + + ``principal`` is the kerberos principal to parse. + """ + if not principal: + return None + return re.split('[\/@]', str(principal)) + + +def replace_hostname_pattern(components, host=None): + fqdn = host + if not fqdn or fqdn == '0.0.0.0': + fqdn = get_localhost_name() + return '%s/%s@%s' % (components[0], fqdn.lower(), components[2]) + + +def get_localhost_name(): + return socket.getfqdn() + + +def get_fqdn(hostname_or_ip=None): + # Get hostname + try: + if hostname_or_ip: + fqdn = socket.gethostbyaddr(hostname_or_ip)[0] + else: + fqdn = get_localhost_name() + except IOError: + fqdn = hostname_or_ip + + if fqdn == 'localhost': + fqdn = get_localhost_name() + + return fqdn + + +def principal_from_username(username): + realm = conf.get("security", "default_realm") + if '@' not in username and realm: + username = "{}@{}".format(username, realm) + + return username diff --git a/airflow/settings.py b/airflow/settings.py new file mode 100644 index 0000000000000..0dfbb15d2caaa --- /dev/null +++ b/airflow/settings.py @@ -0,0 +1,157 @@ +# -*- coding: utf-8 -*- +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +from __future__ import absolute_import +from __future__ import division +from __future__ import print_function +from __future__ import unicode_literals + +import logging +import os +from sqlalchemy import create_engine +from sqlalchemy.orm import scoped_session, sessionmaker +from sqlalchemy.pool import NullPool + +from airflow import configuration as conf +from airflow.logging_config import configure_logging + +log = logging.getLogger(__name__) + + +class DummyStatsLogger(object): + @classmethod + def incr(cls, stat, count=1, rate=1): + pass + + @classmethod + def decr(cls, stat, count=1, rate=1): + pass + + @classmethod + def gauge(cls, stat, value, rate=1, delta=False): + pass + + @classmethod + def timing(cls, stat, dt): + pass + + +Stats = DummyStatsLogger + +if conf.getboolean('scheduler', 'statsd_on'): + from statsd import StatsClient + + statsd = StatsClient( + host=conf.get('scheduler', 'statsd_host'), + port=conf.getint('scheduler', 'statsd_port'), + prefix=conf.get('scheduler', 'statsd_prefix')) + Stats = statsd +else: + Stats = DummyStatsLogger + +HEADER = """\ + ____________ _____________ + ____ |__( )_________ __/__ /________ __ +____ /| |_ /__ ___/_ /_ __ /_ __ \_ | /| / / +___ ___ | / _ / _ __/ _ / / /_/ /_ |/ |/ / + _/_/ |_/_/ /_/ /_/ /_/ \____/____/|__/ + """ + +BASE_LOG_URL = '/admin/airflow/log' +LOGGING_LEVEL = logging.INFO + +# the prefix to append to gunicorn worker processes after init +GUNICORN_WORKER_READY_PREFIX = "[ready] " + +LOG_FORMAT = conf.get('core', 'log_format') +SIMPLE_LOG_FORMAT = conf.get('core', 'simple_log_format') + +AIRFLOW_HOME = None +SQL_ALCHEMY_CONN = None +DAGS_FOLDER = None + +engine = None +Session = None + + +def policy(task_instance): + """ + This policy setting allows altering task instances right before they + are executed. It allows administrator to rewire some task parameters. + + Note that the ``TaskInstance`` object has an attribute ``task`` pointing + to its related task object, that in turns has a reference to the DAG + object. So you can use the attributes of all of these to define your + policy. + + To define policy, add a ``airflow_local_settings`` module + to your PYTHONPATH that defines this ``policy`` function. It receives + a ``TaskInstance`` object and can alter it where needed. + + Here are a few examples of how this can be useful: + + * You could enforce a specific queue (say the ``spark`` queue) + for tasks using the ``SparkOperator`` to make sure that these + task instances get wired to the right workers + * You could force all task instances running on an + ``execution_date`` older than a week old to run in a ``backfill`` + pool. + * ... + """ + pass + + +def configure_vars(): + global AIRFLOW_HOME + global SQL_ALCHEMY_CONN + global DAGS_FOLDER + AIRFLOW_HOME = os.path.expanduser(conf.get('core', 'AIRFLOW_HOME')) + SQL_ALCHEMY_CONN = conf.get('core', 'SQL_ALCHEMY_CONN') + DAGS_FOLDER = os.path.expanduser(conf.get('core', 'DAGS_FOLDER')) + + +def configure_orm(disable_connection_pool=False): + global engine + global Session + engine_args = {} + if disable_connection_pool: + engine_args['poolclass'] = NullPool + elif 'sqlite' not in SQL_ALCHEMY_CONN: + # Engine args not supported by sqlite + engine_args['pool_size'] = conf.getint('core', 'SQL_ALCHEMY_POOL_SIZE') + engine_args['pool_recycle'] = conf.getint('core', + 'SQL_ALCHEMY_POOL_RECYCLE') + + engine = create_engine(SQL_ALCHEMY_CONN, **engine_args) + Session = scoped_session( + sessionmaker(autocommit=False, autoflush=False, bind=engine)) + + +try: + from airflow_local_settings import * + + log.info("Loaded airflow_local_settings.") +except: + pass + +configure_logging() +configure_vars() +configure_orm() + +# Const stuff + +KILOBYTE = 1024 +MEGABYTE = KILOBYTE * KILOBYTE +WEB_COLORS = {'LIGHTBLUE': '#4d9de0', + 'LIGHTORANGE': '#FF9933'} diff --git a/airflow/task_runner/__init__.py b/airflow/task_runner/__init__.py new file mode 100644 index 0000000000000..e0527cb43fa27 --- /dev/null +++ b/airflow/task_runner/__init__.py @@ -0,0 +1,38 @@ +# -*- coding: utf-8 -*- +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from airflow import configuration +from airflow.task_runner.bash_task_runner import BashTaskRunner +from airflow.exceptions import AirflowException + +_TASK_RUNNER = configuration.get('core', 'TASK_RUNNER') + + +def get_task_runner(local_task_job): + """ + Get the task runner that can be used to run the given job. + + :param local_task_job: The LocalTaskJob associated with the TaskInstance + that needs to be executed. + :type local_task_job: airflow.jobs.LocalTaskJob + :return: The task runner to use to run the task. + :rtype: airflow.task_runner.base_task_runner.BaseTaskRunner + """ + if _TASK_RUNNER == "BashTaskRunner": + return BashTaskRunner(local_task_job) + elif _TASK_RUNNER == "CgroupTaskRunner": + from airflow.contrib.task_runner.cgroup_task_runner import CgroupTaskRunner + return CgroupTaskRunner(local_task_job) + else: + raise AirflowException("Unknown task runner type {}".format(_TASK_RUNNER)) diff --git a/airflow/task_runner/base_task_runner.py b/airflow/task_runner/base_task_runner.py new file mode 100644 index 0000000000000..6a07db240641e --- /dev/null +++ b/airflow/task_runner/base_task_runner.py @@ -0,0 +1,157 @@ +# -*- coding: utf-8 -*- +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +from __future__ import unicode_literals + +import getpass +import os +import json +import subprocess +import threading + +from airflow.utils.log.logging_mixin import LoggingMixin + +from airflow import configuration as conf +from tempfile import mkstemp + + +class BaseTaskRunner(LoggingMixin): + """ + Runs Airflow task instances by invoking the `airflow run` command with raw + mode enabled in a subprocess. + """ + + def __init__(self, local_task_job): + """ + :param local_task_job: The local task job associated with running the + associated task instance. + :type local_task_job: airflow.jobs.LocalTaskJob + """ + # Pass task instance context into log handlers to setup the logger. + self._task_instance = local_task_job.task_instance + self.set_log_contexts(self._task_instance) + + popen_prepend = [] + cfg_path = None + if self._task_instance.run_as_user: + self.run_as_user = self._task_instance.run_as_user + else: + try: + self.run_as_user = conf.get('core', 'default_impersonation') + except conf.AirflowConfigException: + self.run_as_user = None + + # Add sudo commands to change user if we need to. Needed to handle SubDagOperator + # case using a SequentialExecutor. + if self.run_as_user and (self.run_as_user != getpass.getuser()): + self.log.debug("Planning to run as the %s user", self.run_as_user) + cfg_dict = conf.as_dict(display_sensitive=True) + cfg_subset = { + 'core': cfg_dict.get('core', {}), + 'smtp': cfg_dict.get('smtp', {}), + 'scheduler': cfg_dict.get('scheduler', {}), + 'webserver': cfg_dict.get('webserver', {}), + } + temp_fd, cfg_path = mkstemp() + + # Give ownership of file to user; only they can read and write + subprocess.call( + ['sudo', 'chown', self.run_as_user, cfg_path] + ) + subprocess.call( + ['sudo', 'chmod', '600', cfg_path] + ) + + with os.fdopen(temp_fd, 'w') as temp_file: + json.dump(cfg_subset, temp_file) + + popen_prepend = ['sudo', '-H', '-u', self.run_as_user] + + self._cfg_path = cfg_path + self._command = popen_prepend + self._task_instance.command_as_list( + raw=True, + pickle_id=local_task_job.pickle_id, + mark_success=local_task_job.mark_success, + job_id=local_task_job.id, + pool=local_task_job.pool, + cfg_path=cfg_path, + ) + self.process = None + + def _read_task_logs(self, stream): + while True: + line = stream.readline() + if isinstance(line, bytes): + line = line.decode('utf-8') + if len(line) == 0: + break + self.log.info('Subtask: %s', line.rstrip('\n')) + + def run_command(self, run_with, join_args=False): + """ + Run the task command + + :param run_with: list of tokens to run the task command with + E.g. ['bash', '-c'] + :type run_with: list + :param join_args: whether to concatenate the list of command tokens + E.g. ['airflow', 'run'] vs ['airflow run'] + :param join_args: bool + :return: the process that was run + :rtype: subprocess.Popen + """ + cmd = [" ".join(self._command)] if join_args else self._command + full_cmd = run_with + cmd + self.log.info('Running: %s', full_cmd) + proc = subprocess.Popen( + full_cmd, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + universal_newlines=True + ) + + # Start daemon thread to read subprocess logging output + log_reader = threading.Thread( + target=self._read_task_logs, + args=(proc.stdout,), + ) + log_reader.daemon = True + log_reader.start() + return proc + + def start(self): + """ + Start running the task instance in a subprocess. + """ + raise NotImplementedError() + + def return_code(self): + """ + :return: The return code associated with running the task instance or + None if the task is not yet done. + :rtype int: + """ + raise NotImplementedError() + + def terminate(self): + """ + Kill the running task instance. + """ + raise NotImplementedError() + + def on_finish(self): + """ + A callback that should be called when this is done running. + """ + if self._cfg_path and os.path.isfile(self._cfg_path): + subprocess.call(['sudo', 'rm', self._cfg_path]) diff --git a/airflow/task_runner/bash_task_runner.py b/airflow/task_runner/bash_task_runner.py new file mode 100644 index 0000000000000..109b44c754420 --- /dev/null +++ b/airflow/task_runner/bash_task_runner.py @@ -0,0 +1,39 @@ +# -*- coding: utf-8 -*- +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import psutil + +from airflow.task_runner.base_task_runner import BaseTaskRunner +from airflow.utils.helpers import kill_process_tree + + +class BashTaskRunner(BaseTaskRunner): + """ + Runs the raw Airflow task by invoking through the Bash shell. + """ + def __init__(self, local_task_job): + super(BashTaskRunner, self).__init__(local_task_job) + + def start(self): + self.process = self.run_command(['bash', '-c'], join_args=True) + + def return_code(self): + return self.process.poll() + + def terminate(self): + if self.process and psutil.pid_exists(self.process.pid): + kill_process_tree(self.log, self.process.pid) + + def on_finish(self): + super(BashTaskRunner, self).on_finish() diff --git a/airflow/ti_deps/__init__.py b/airflow/ti_deps/__init__.py new file mode 100644 index 0000000000000..9d7677a99b293 --- /dev/null +++ b/airflow/ti_deps/__init__.py @@ -0,0 +1,13 @@ +# -*- coding: utf-8 -*- +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. diff --git a/airflow/ti_deps/dep_context.py b/airflow/ti_deps/dep_context.py new file mode 100644 index 0000000000000..f461a81fcfc7b --- /dev/null +++ b/airflow/ti_deps/dep_context.py @@ -0,0 +1,117 @@ +# -*- coding: utf-8 -*- +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +from airflow.ti_deps.deps.dag_ti_slots_available_dep import DagTISlotsAvailableDep +from airflow.ti_deps.deps.dag_unpaused_dep import DagUnpausedDep +from airflow.ti_deps.deps.dagrun_exists_dep import DagrunRunningDep +from airflow.ti_deps.deps.exec_date_after_start_date_dep import ExecDateAfterStartDateDep +from airflow.ti_deps.deps.not_running_dep import NotRunningDep +from airflow.ti_deps.deps.not_skipped_dep import NotSkippedDep +from airflow.ti_deps.deps.runnable_exec_date_dep import RunnableExecDateDep +from airflow.ti_deps.deps.valid_state_dep import ValidStateDep +from airflow.ti_deps.deps.task_concurrency_dep import TaskConcurrencyDep +from airflow.utils.state import State + + +class DepContext(object): + """ + A base class for contexts that specifies which dependencies should be evaluated in + the context for a task instance to satisfy the requirements of the context. Also + stores state related to the context that can be used by dependendency classes. + + For example there could be a SomeRunContext that subclasses this class which has + dependencies for: + - Making sure there are slots available on the infrastructure to run the task instance + - A task-instance's task-specific dependencies are met (e.g. the previous task + instance completed successfully) + - ... + + :param deps: The context-specific dependencies that need to be evaluated for a + task instance to run in this execution context. + :type deps: set(BaseTIDep) + :param flag_upstream_failed: This is a hack to generate the upstream_failed state + creation while checking to see whether the task instance is runnable. It was the + shortest path to add the feature. This is bad since this class should be pure (no + side effects). + :type flag_upstream_failed: boolean + :param ignore_all_deps: Whether or not the context should ignore all ignoreable + dependencies. Overrides the other ignore_* parameters + :type ignore_all_deps: boolean + :param ignore_depends_on_past: Ignore depends_on_past parameter of DAGs (e.g. for + Backfills) + :type ignore_depends_on_past: boolean + :param ignore_in_retry_period: Ignore the retry period for task instances + :type ignore_in_retry_period: boolean + :param ignore_task_deps: Ignore task-specific dependencies such as depends_on_past and + trigger rule + :type ignore_task_deps: boolean + :param ignore_ti_state: Ignore the task instance's previous failure/success + :type ignore_ti_state: boolean + """ + def __init__( + self, + deps=None, + flag_upstream_failed=False, + ignore_all_deps=False, + ignore_depends_on_past=False, + ignore_in_retry_period=False, + ignore_task_deps=False, + ignore_ti_state=False): + self.deps = deps or set() + self.flag_upstream_failed = flag_upstream_failed + self.ignore_all_deps = ignore_all_deps + self.ignore_depends_on_past = ignore_depends_on_past + self.ignore_in_retry_period = ignore_in_retry_period + self.ignore_task_deps = ignore_task_deps + self.ignore_ti_state = ignore_ti_state + +# In order to be able to get queued a task must have one of these states +QUEUEABLE_STATES = { + State.FAILED, + State.NONE, + State.QUEUED, + State.SCHEDULED, + State.SKIPPED, + State.UPSTREAM_FAILED, + State.UP_FOR_RETRY, +} + +# Context to get the dependencies that need to be met in order for a task instance to +# be backfilled. +QUEUE_DEPS = { + NotRunningDep(), + NotSkippedDep(), + RunnableExecDateDep(), + ValidStateDep(QUEUEABLE_STATES), +} + +# Dependencies that need to be met for a given task instance to be able to get run by an +# executor. This class just extends QueueContext by adding dependencies for resources. +RUN_DEPS = QUEUE_DEPS | { + DagTISlotsAvailableDep(), + TaskConcurrencyDep(), +} + +# TODO(aoen): SCHEDULER_DEPS is not coupled to actual execution in any way and +# could easily be modified or removed from the scheduler causing this dependency to become +# outdated and incorrect. This coupling should be created (e.g. via a dag_deps analog of +# ti_deps that will be used in the scheduler code) to ensure that the logic here is +# equivalent to the logic in the scheduler. + +# Dependencies that need to be met for a given task instance to get scheduled by the +# scheduler, then queued by the scheduler, then run by an executor. +SCHEDULER_DEPS = RUN_DEPS | { + DagrunRunningDep(), + DagUnpausedDep(), + ExecDateAfterStartDateDep(), +} diff --git a/airflow/ti_deps/deps/__init__.py b/airflow/ti_deps/deps/__init__.py new file mode 100644 index 0000000000000..9d7677a99b293 --- /dev/null +++ b/airflow/ti_deps/deps/__init__.py @@ -0,0 +1,13 @@ +# -*- coding: utf-8 -*- +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. diff --git a/airflow/ti_deps/deps/base_ti_dep.py b/airflow/ti_deps/deps/base_ti_dep.py new file mode 100644 index 0000000000000..810852ded4107 --- /dev/null +++ b/airflow/ti_deps/deps/base_ti_dep.py @@ -0,0 +1,147 @@ +# -*- coding: utf-8 -*- +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +from collections import namedtuple + +from airflow.utils.db import provide_session + + +class BaseTIDep(object): + """ + Abstract base class for dependencies that must be satisfied in order for task + instances to run. For example, a task that can only run if a certain number of its + upstream tasks succeed. This is an abstract class and must be subclassed to be used. + """ + + # If this dependency can be ignored by a context in which it is added to. Needed + # because some dependencies should never be ignoreable in their contexts. + IGNOREABLE = False + + # Whether this dependency is not a global task instance dependency but specific + # to some tasks (e.g. depends_on_past is not specified by all tasks). + IS_TASK_DEP = False + + def __init__(self): + pass + + def __eq__(self, other): + return type(self) == type(other) + + def __hash__(self): + return hash(type(self)) + + def __repr__(self): + return "".format(self=self) + + @property + def name(self): + """ + The human-readable name for the dependency. Use the classname as the default name + if this method is not overridden in the subclass. + """ + return getattr(self, 'NAME', self.__class__.__name__) + + def _get_dep_statuses(self, ti, session, dep_context=None): + """ + Abstract method that returns an iterable of TIDepStatus objects that describe + whether the given task instance has this dependency met. + + For example a subclass could return an iterable of TIDepStatus objects, each one + representing if each of the passed in task's upstream tasks succeeded or not. + + :param ti: the task instance to get the dependency status for + :type ti: TaskInstance + :param session: database session + :type session: Session + :param dep_context: the context for which this dependency should be evaluated for + :type dep_context: DepContext + """ + raise NotImplementedError + + @provide_session + def get_dep_statuses(self, ti, session, dep_context=None): + """ + Wrapper around the private _get_dep_statuses method that contains some global + checks for all dependencies. + + :param ti: the task instance to get the dependency status for + :type ti: TaskInstance + :param session: database session + :type session: Session + :param dep_context: the context for which this dependency should be evaluated for + :type dep_context: DepContext + """ + # this avoids a circular dependency + from airflow.ti_deps.dep_context import DepContext + + if dep_context is None: + dep_context = DepContext() + + if self.IGNOREABLE and dep_context.ignore_all_deps: + yield self._passing_status( + reason="Context specified all dependencies should be ignored.") + return + + if self.IS_TASK_DEP and dep_context.ignore_task_deps: + yield self._passing_status( + reason="Context specified all task dependencies should be ignored.") + return + + for dep_status in self._get_dep_statuses(ti, session, dep_context): + yield dep_status + + @provide_session + def is_met(self, ti, session, dep_context=None): + """ + Returns whether or not this dependency is met for a given task instance. A + dependency is considered met if all of the dependency statuses it reports are + passing. + + :param ti: the task instance to see if this dependency is met for + :type ti: TaskInstance + :param session: database session + :type session: Session + :param dep_context: The context this dependency is being checked under that stores + state that can be used by this dependency. + :type dep_context: BaseDepContext + """ + return all(status.passed for status in + self.get_dep_statuses(ti, session, dep_context)) + + @provide_session + def get_failure_reasons(self, ti, session, dep_context=None): + """ + Returns an iterable of strings that explain why this dependency wasn't met. + + :param ti: the task instance to see if this dependency is met for + :type ti: TaskInstance + :param session: database session + :type session: Session + :param dep_context: The context this dependency is being checked under that stores + state that can be used by this dependency. + :type dep_context: BaseDepContext + """ + for dep_status in self.get_dep_statuses(ti, session, dep_context): + if not dep_status.passed: + yield dep_status.reason + + def _failing_status(self, reason=''): + return TIDepStatus(self.name, False, reason) + + def _passing_status(self, reason=''): + return TIDepStatus(self.name, True, reason) + + +# Dependency status for a specific task instance indicating whether or not the task +# instance passed the dependency. +TIDepStatus = namedtuple('TIDepStatus', ['dep_name', 'passed', 'reason']) diff --git a/airflow/ti_deps/deps/dag_ti_slots_available_dep.py b/airflow/ti_deps/deps/dag_ti_slots_available_dep.py new file mode 100644 index 0000000000000..799d1cc6fc368 --- /dev/null +++ b/airflow/ti_deps/deps/dag_ti_slots_available_dep.py @@ -0,0 +1,28 @@ +# -*- coding: utf-8 -*- +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +from airflow.ti_deps.deps.base_ti_dep import BaseTIDep +from airflow.utils.db import provide_session + + +class DagTISlotsAvailableDep(BaseTIDep): + NAME = "Task Instance Slots Available" + IGNOREABLE = True + + @provide_session + def _get_dep_statuses(self, ti, session, dep_context): + if ti.task.dag.concurrency_reached: + yield self._failing_status( + reason="The maximum number of running tasks ({0}) for this task's DAG " + "'{1}' has been reached.".format(ti.task.dag.concurrency, + ti.dag_id)) diff --git a/airflow/ti_deps/deps/dag_unpaused_dep.py b/airflow/ti_deps/deps/dag_unpaused_dep.py new file mode 100644 index 0000000000000..6d6bf3283c27b --- /dev/null +++ b/airflow/ti_deps/deps/dag_unpaused_dep.py @@ -0,0 +1,26 @@ +# -*- coding: utf-8 -*- +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +from airflow.ti_deps.deps.base_ti_dep import BaseTIDep +from airflow.utils.db import provide_session + + +class DagUnpausedDep(BaseTIDep): + NAME = "Dag Not Paused" + IGNOREABLE = True + + @provide_session + def _get_dep_statuses(self, ti, session, dep_context): + if ti.task.dag.is_paused: + yield self._failing_status( + reason="Task's DAG '{0}' is paused.".format(ti.dag_id)) diff --git a/airflow/ti_deps/deps/dagrun_exists_dep.py b/airflow/ti_deps/deps/dagrun_exists_dep.py new file mode 100644 index 0000000000000..34fb7538c2b46 --- /dev/null +++ b/airflow/ti_deps/deps/dagrun_exists_dep.py @@ -0,0 +1,50 @@ +# -*- coding: utf-8 -*- +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +from airflow.ti_deps.deps.base_ti_dep import BaseTIDep +from airflow.utils.db import provide_session +from airflow.utils.state import State + + +class DagrunRunningDep(BaseTIDep): + NAME = "Dagrun Running" + IGNOREABLE = True + + @provide_session + def _get_dep_statuses(self, ti, session, dep_context): + dag = ti.task.dag + dagrun = ti.get_dagrun(session) + if not dagrun: + # The import is needed here to avoid a circular dependency + from airflow.models import DagRun + running_dagruns = DagRun.find( + dag_id=dag.dag_id, + state=State.RUNNING, + external_trigger=False, + session=session + ) + + if len(running_dagruns) >= dag.max_active_runs: + reason = ("The maximum number of active dag runs ({0}) for this task " + "instance's DAG '{1}' has been reached.".format( + dag.max_active_runs, + ti.dag_id)) + else: + reason = "Unknown reason" + yield self._failing_status( + reason="Task instance's dagrun did not exist: {0}.".format(reason)) + else: + if dagrun.state != State.RUNNING: + yield self._failing_status( + reason="Task instance's dagrun was not in the 'running' state but in " + "the state '{}'.".format(dagrun.state)) diff --git a/airflow/ti_deps/deps/exec_date_after_start_date_dep.py b/airflow/ti_deps/deps/exec_date_after_start_date_dep.py new file mode 100644 index 0000000000000..8cfe3e81b96cc --- /dev/null +++ b/airflow/ti_deps/deps/exec_date_after_start_date_dep.py @@ -0,0 +1,37 @@ +# -*- coding: utf-8 -*- +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +from airflow.ti_deps.deps.base_ti_dep import BaseTIDep +from airflow.utils.db import provide_session + + +class ExecDateAfterStartDateDep(BaseTIDep): + NAME = "Execution Date" + IGNOREABLE = True + + @provide_session + def _get_dep_statuses(self, ti, session, dep_context): + if ti.task.start_date and ti.execution_date < ti.task.start_date: + yield self._failing_status( + reason="The execution date is {0} but this is before the task's start " + "date {1}.".format( + ti.execution_date.isoformat(), + ti.task.start_date.isoformat())) + + if (ti.task.dag and ti.task.dag.start_date and + ti.execution_date < ti.task.dag.start_date): + yield self._failing_status( + reason="The execution date is {0} but this is before the task's " + "DAG's start date {1}.".format( + ti.execution_date.isoformat(), + ti.task.dag.start_date.isoformat())) diff --git a/airflow/ti_deps/deps/not_in_retry_period_dep.py b/airflow/ti_deps/deps/not_in_retry_period_dep.py new file mode 100644 index 0000000000000..7f9bff65f4966 --- /dev/null +++ b/airflow/ti_deps/deps/not_in_retry_period_dep.py @@ -0,0 +1,48 @@ +# -*- coding: utf-8 -*- +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +from datetime import datetime + +from airflow.ti_deps.deps.base_ti_dep import BaseTIDep +from airflow.utils.db import provide_session +from airflow.utils.state import State + + +class NotInRetryPeriodDep(BaseTIDep): + NAME = "Not In Retry Period" + IGNOREABLE = True + IS_TASK_DEP = True + + @provide_session + def _get_dep_statuses(self, ti, session, dep_context): + if dep_context.ignore_in_retry_period: + yield self._passing_status( + reason="The context specified that being in a retry period was " + "permitted.") + return + + if ti.state != State.UP_FOR_RETRY: + yield self._passing_status( + reason="The task instance was not marked for retrying.") + return + + # Calculate the date first so that it is always smaller than the timestamp used by + # ready_for_retry + cur_date = datetime.utcnow() + next_task_retry_date = ti.next_retry_datetime() + if ti.is_premature: + yield self._failing_status( + reason="Task is not ready for retry yet but will be retried " + "automatically. Current date is {0} and task will be retried " + "at {1}.".format(cur_date.isoformat(), + next_task_retry_date.isoformat())) diff --git a/airflow/ti_deps/deps/not_running_dep.py b/airflow/ti_deps/deps/not_running_dep.py new file mode 100644 index 0000000000000..ac4f83f2a30f3 --- /dev/null +++ b/airflow/ti_deps/deps/not_running_dep.py @@ -0,0 +1,32 @@ +# -*- coding: utf-8 -*- +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +from airflow.ti_deps.deps.base_ti_dep import BaseTIDep +from airflow.utils.db import provide_session +from airflow.utils.state import State + + +class NotRunningDep(BaseTIDep): + NAME = "Task Instance Not Already Running" + + # Task instances must not already be running, as running two copies of the same + # task instance at the same time (AKA double-trigger) should be avoided at all + # costs, even if the context specifies that all dependencies should be ignored. + IGNOREABLE = False + + @provide_session + def _get_dep_statuses(self, ti, session, dep_context): + if ti.state == State.RUNNING: + yield self._failing_status( + reason="Task is already running, it started on {0}.".format( + ti.start_date)) diff --git a/airflow/ti_deps/deps/not_skipped_dep.py b/airflow/ti_deps/deps/not_skipped_dep.py new file mode 100644 index 0000000000000..0e0ccdf9bc96d --- /dev/null +++ b/airflow/ti_deps/deps/not_skipped_dep.py @@ -0,0 +1,26 @@ +# -*- coding: utf-8 -*- +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +from airflow.ti_deps.deps.base_ti_dep import BaseTIDep +from airflow.utils.db import provide_session +from airflow.utils.state import State + + +class NotSkippedDep(BaseTIDep): + NAME = "Task Instance Not Skipped" + IGNOREABLE = True + + @provide_session + def _get_dep_statuses(self, ti, session, dep_context): + if ti.state == State.SKIPPED: + yield self._failing_status(reason="The task instance has been skipped.") diff --git a/airflow/ti_deps/deps/prev_dagrun_dep.py b/airflow/ti_deps/deps/prev_dagrun_dep.py new file mode 100644 index 0000000000000..cb46e273caee8 --- /dev/null +++ b/airflow/ti_deps/deps/prev_dagrun_dep.py @@ -0,0 +1,80 @@ +# -*- coding: utf-8 -*- +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +from airflow.ti_deps.deps.base_ti_dep import BaseTIDep +from airflow.utils.db import provide_session +from airflow.utils.state import State + + +class PrevDagrunDep(BaseTIDep): + """ + Is the past dagrun in a state that allows this task instance to run, e.g. did this + task instance's task in the previous dagrun complete if we are depending on past. + """ + NAME = "Previous Dagrun State" + IGNOREABLE = True + IS_TASK_DEP = True + + @provide_session + def _get_dep_statuses(self, ti, session, dep_context): + if dep_context.ignore_depends_on_past: + yield self._passing_status( + reason="The context specified that the state of past DAGs could be " + "ignored.") + return + + if not ti.task.depends_on_past: + yield self._passing_status( + reason="The task did not have depends_on_past set.") + return + + # Don't depend on the previous task instance if we are the first task + dag = ti.task.dag + if dag.catchup: + if dag.previous_schedule(ti.execution_date) is None: + yield self._passing_status( + reason="This task does not have a schedule or is @once" + ) + return + if dag.previous_schedule(ti.execution_date) < ti.task.start_date: + yield self._passing_status( + reason="This task instance was the first task instance for its task.") + return + else: + dr = ti.get_dagrun() + last_dagrun = dr.get_previous_dagrun() if dr else None + + if not last_dagrun: + yield self._passing_status( + reason="This task instance was the first task instance for its task.") + return + + previous_ti = ti.previous_ti + if not previous_ti: + yield self._failing_status( + reason="depends_on_past is true for this task's DAG, but the previous " + "task instance has not run yet.") + return + + if previous_ti.state not in {State.SKIPPED, State.SUCCESS}: + yield self._failing_status( + reason="depends_on_past is true for this task, but the previous task " + "instance {0} is in the state '{1}' which is not a successful " + "state.".format(previous_ti, previous_ti.state)) + + previous_ti.task = ti.task + if (ti.task.wait_for_downstream and + not previous_ti.are_dependents_done(session=session)): + yield self._failing_status( + reason="The tasks downstream of the previous task instance {0} haven't " + "completed.".format(previous_ti)) diff --git a/airflow/ti_deps/deps/runnable_exec_date_dep.py b/airflow/ti_deps/deps/runnable_exec_date_dep.py new file mode 100644 index 0000000000000..13e534583507d --- /dev/null +++ b/airflow/ti_deps/deps/runnable_exec_date_dep.py @@ -0,0 +1,48 @@ +# -*- coding: utf-8 -*- +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +from datetime import datetime + +from airflow.ti_deps.deps.base_ti_dep import BaseTIDep +from airflow.utils.db import provide_session + + +class RunnableExecDateDep(BaseTIDep): + NAME = "Execution Date" + IGNOREABLE = True + + @provide_session + def _get_dep_statuses(self, ti, session, dep_context): + cur_date = datetime.utcnow() + + if ti.execution_date > cur_date: + yield self._failing_status( + reason="Execution date {0} is in the future (the current " + "date is {1}).".format(ti.execution_date.isoformat(), + cur_date.isoformat())) + + if ti.task.end_date and ti.execution_date > ti.task.end_date: + yield self._failing_status( + reason="The execution date is {0} but this is after the task's end date " + "{1}.".format( + ti.execution_date.isoformat(), + ti.task.end_date.isoformat())) + + if (ti.task.dag and + ti.task.dag.end_date and + ti.execution_date > ti.task.dag.end_date): + yield self._failing_status( + reason="The execution date is {0} but this is after the task's DAG's " + "end date {1}.".format( + ti.execution_date.isoformat(), + ti.task.dag.end_date.isoformat())) diff --git a/airflow/ti_deps/deps/task_concurrency_dep.py b/airflow/ti_deps/deps/task_concurrency_dep.py new file mode 100644 index 0000000000000..99df5ac460f0f --- /dev/null +++ b/airflow/ti_deps/deps/task_concurrency_dep.py @@ -0,0 +1,37 @@ +# -*- coding: utf-8 -*- +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +from airflow.ti_deps.deps.base_ti_dep import BaseTIDep +from airflow.utils.db import provide_session + + +class TaskConcurrencyDep(BaseTIDep): + """ + This restricts the number of running task instances for a particular task. + """ + NAME = "Task Concurrency" + IGNOREABLE = True + IS_TASK_DEP = True + + @provide_session + def _get_dep_statuses(self, ti, session, dep_context): + if ti.task.task_concurrency is None: + yield self._passing_status(reason="Task concurrency is not set.") + return + + if ti.get_num_running_task_instances(session) >= ti.task.task_concurrency: + yield self._failing_status(reason="The max task concurrency has been reached.") + return + else: + yield self._passing_status(reason="The max task concurrency has not been reached.") + return diff --git a/airflow/ti_deps/deps/trigger_rule_dep.py b/airflow/ti_deps/deps/trigger_rule_dep.py new file mode 100644 index 0000000000000..5a80314427ea1 --- /dev/null +++ b/airflow/ti_deps/deps/trigger_rule_dep.py @@ -0,0 +1,193 @@ +# -*- coding: utf-8 -*- +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +from sqlalchemy import case, func + +import airflow +from airflow.ti_deps.deps.base_ti_dep import BaseTIDep +from airflow.utils.db import provide_session +from airflow.utils.state import State + + +class TriggerRuleDep(BaseTIDep): + """ + Determines if a task's upstream tasks are in a state that allows a given task instance + to run. + """ + NAME = "Trigger Rule" + IGNOREABLE = True + IS_TASK_DEP = True + + @provide_session + def _get_dep_statuses(self, ti, session, dep_context): + TI = airflow.models.TaskInstance + TR = airflow.models.TriggerRule + + # Checking that all upstream dependencies have succeeded + if not ti.task.upstream_list: + yield self._passing_status( + reason="The task instance did not have any upstream tasks.") + return + + if ti.task.trigger_rule == TR.DUMMY: + yield self._passing_status(reason="The task had a dummy trigger rule set.") + return + + # TODO(unknown): this query becomes quite expensive with dags that have many + # tasks. It should be refactored to let the task report to the dag run and get the + # aggregates from there. + qry = ( + session + .query( + func.coalesce(func.sum( + case([(TI.state == State.SUCCESS, 1)], else_=0)), 0), + func.coalesce(func.sum( + case([(TI.state == State.SKIPPED, 1)], else_=0)), 0), + func.coalesce(func.sum( + case([(TI.state == State.FAILED, 1)], else_=0)), 0), + func.coalesce(func.sum( + case([(TI.state == State.UPSTREAM_FAILED, 1)], else_=0)), 0), + func.count(TI.task_id), + ) + .filter( + TI.dag_id == ti.dag_id, + TI.task_id.in_(ti.task.upstream_task_ids), + TI.execution_date == ti.execution_date, + TI.state.in_([ + State.SUCCESS, State.FAILED, + State.UPSTREAM_FAILED, State.SKIPPED]), + ) + ) + + successes, skipped, failed, upstream_failed, done = qry.first() + for dep_status in self._evaluate_trigger_rule( + ti=ti, + successes=successes, + skipped=skipped, + failed=failed, + upstream_failed=upstream_failed, + done=done, + flag_upstream_failed=dep_context.flag_upstream_failed, + session=session): + yield dep_status + + @provide_session + def _evaluate_trigger_rule( + self, + ti, + successes, + skipped, + failed, + upstream_failed, + done, + flag_upstream_failed, + session): + """ + Yields a dependency status that indicate whether the given task instance's trigger + rule was met. + + :param ti: the task instance to evaluate the trigger rule of + :type ti: TaskInstance + :param successes: Number of successful upstream tasks + :type successes: boolean + :param skipped: Number of skipped upstream tasks + :type skipped: boolean + :param failed: Number of failed upstream tasks + :type failed: boolean + :param upstream_failed: Number of upstream_failed upstream tasks + :type upstream_failed: boolean + :param done: Number of completed upstream tasks + :type done: boolean + :param flag_upstream_failed: This is a hack to generate + the upstream_failed state creation while checking to see + whether the task instance is runnable. It was the shortest + path to add the feature + :type flag_upstream_failed: boolean + :param session: database session + :type session: Session + """ + + TR = airflow.models.TriggerRule + + task = ti.task + upstream = len(task.upstream_task_ids) + tr = task.trigger_rule + upstream_done = done >= upstream + upstream_tasks_state = { + "total": upstream, "successes": successes, "skipped": skipped, + "failed": failed, "upstream_failed": upstream_failed, "done": done + } + # TODO(aoen): Ideally each individual trigger rules would be it's own class, but + # this isn't very feasible at the moment since the database queries need to be + # bundled together for efficiency. + # handling instant state assignment based on trigger rules + if flag_upstream_failed: + if tr == TR.ALL_SUCCESS: + if upstream_failed or failed: + ti.set_state(State.UPSTREAM_FAILED, session) + elif skipped: + ti.set_state(State.SKIPPED, session) + elif tr == TR.ALL_FAILED: + if successes or skipped: + ti.set_state(State.SKIPPED, session) + elif tr == TR.ONE_SUCCESS: + if upstream_done and not successes: + ti.set_state(State.SKIPPED, session) + elif tr == TR.ONE_FAILED: + if upstream_done and not (failed or upstream_failed): + ti.set_state(State.SKIPPED, session) + + if tr == TR.ONE_SUCCESS: + if successes <= 0: + yield self._failing_status( + reason="Task's trigger rule '{0}' requires one upstream " + "task success, but none were found. " + "upstream_tasks_state={1}, upstream_task_ids={2}" + .format(tr, upstream_tasks_state, task.upstream_task_ids)) + elif tr == TR.ONE_FAILED: + if not failed and not upstream_failed: + yield self._failing_status( + reason="Task's trigger rule '{0}' requires one upstream " + "task failure, but none were found. " + "upstream_tasks_state={1}, upstream_task_ids={2}" + .format(tr, upstream_tasks_state, task.upstream_task_ids)) + elif tr == TR.ALL_SUCCESS: + num_failures = upstream - successes + if num_failures > 0: + yield self._failing_status( + reason="Task's trigger rule '{0}' requires all upstream " + "tasks to have succeeded, but found {1} non-success(es). " + "upstream_tasks_state={2}, upstream_task_ids={3}" + .format(tr, num_failures, upstream_tasks_state, + task.upstream_task_ids)) + elif tr == TR.ALL_FAILED: + num_successes = upstream - failed - upstream_failed + if num_successes > 0: + yield self._failing_status( + reason="Task's trigger rule '{0}' requires all upstream " + "tasks to have failed, but found {1} non-failure(s). " + "upstream_tasks_state={2}, upstream_task_ids={3}" + .format(tr, num_successes, upstream_tasks_state, + task.upstream_task_ids)) + elif tr == TR.ALL_DONE: + if not upstream_done: + yield self._failing_status( + reason="Task's trigger rule '{0}' requires all upstream " + "tasks to have completed, but found {1} task(s) that " + "weren't done. upstream_tasks_state={2}, " + "upstream_task_ids={3}" + .format(tr, upstream-done, upstream_tasks_state, + task.upstream_task_ids)) + else: + yield self._failing_status( + reason="No strategy to evaluate trigger rule '{0}'.".format(tr)) diff --git a/airflow/ti_deps/deps/valid_state_dep.py b/airflow/ti_deps/deps/valid_state_dep.py new file mode 100644 index 0000000000000..a9849e0cd46f5 --- /dev/null +++ b/airflow/ti_deps/deps/valid_state_dep.py @@ -0,0 +1,59 @@ +# -*- coding: utf-8 -*- +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +from airflow.exceptions import AirflowException +from airflow.ti_deps.deps.base_ti_dep import BaseTIDep +from airflow.utils.db import provide_session + + +class ValidStateDep(BaseTIDep): + NAME = "Task Instance State" + IGNOREABLE = True + + """ + Ensures that the task instance's state is in a given set of valid states. + + :param valid_states: A list of valid states that a task instance can have to meet + this dependency. + :type valid_states: set(str) + :return: whether or not the task instance's state is valid + """ + def __init__(self, valid_states): + super(ValidStateDep, self).__init__() + + if not valid_states: + raise AirflowException( + 'ValidStatesDep received an empty set of valid states.') + self._valid_states = valid_states + + def __eq__(self, other): + return type(self) == type(other) and self._valid_states == other._valid_states + + def __hash__(self): + return hash((type(self), tuple(self._valid_states))) + + @provide_session + def _get_dep_statuses(self, ti, session, dep_context): + if dep_context.ignore_ti_state: + yield self._passing_status( + reason="Context specified that state should be ignored.") + return + + if ti.state in self._valid_states: + yield self._passing_status(reason="Task state {} was valid.".format(ti.state)) + return + + yield self._failing_status( + reason="Task is in the '{0}' state which is not a valid state for " + "execution. The task must be cleared in order to be run.".format( + ti.state)) diff --git a/airflow/utils/__init__.py b/airflow/utils/__init__.py new file mode 100644 index 0000000000000..476ee1fb77d82 --- /dev/null +++ b/airflow/utils/__init__.py @@ -0,0 +1,35 @@ +# -*- coding: utf-8 -*- +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +from __future__ import absolute_import + +import warnings + +from .decorators import apply_defaults as _apply_defaults + + +def apply_defaults(func): + warnings.warn_explicit( + """ + You are importing apply_defaults from airflow.utils which + will be deprecated in a future version. + Please use : + + from airflow.utils.decorators import apply_defaults + """, + category=PendingDeprecationWarning, + filename=func.__code__.co_filename, + lineno=func.__code__.co_firstlineno + 1 + ) + return _apply_defaults(func) diff --git a/airflow/utils/asciiart.py b/airflow/utils/asciiart.py new file mode 100644 index 0000000000000..9bebb5d1bc7e8 --- /dev/null +++ b/airflow/utils/asciiart.py @@ -0,0 +1,55 @@ +# -*- coding: utf-8 -*- +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +bug = r"""\ + =, .= + =.| ,---. |.= + =.| "-(:::::)-" |.= + \\__/`-.|.-'\__// + `-| .::| .::|-' Pillendreher + _|`-._|_.-'|_ (Scarabaeus sacer) + /.-| | .::|-.\ + // ,| .::|::::|. \\ + || //\::::|::' /\\ || + /'\|| `.__|__.' ||/'\ + ^ \\ // ^ + /'\ /'\ + ^ ^ +""" + +nukular = r""" + ____/ ( ( ) ) \___ + /( ( ( ) _ )) ) )\ + (( ( )( ) ) ( ) ) + ((/ ( _( ) ( _) ) ( () ) ) + ( ( ( (_) (( ( ) .((_ ) . )_ + ( ( ) ( ( ) ) ) . ) ( ) + ( ( ( ( ) ( _ ( _) ). ) . ) ) ( ) + ( ( ( ) ( ) ( )) ) _)( ) ) ) + ( ( ( \ ) ( (_ ( ) ( ) ) ) ) )) ( ) + ( ( ( ( (_ ( ) ( _ ) ) ( ) ) ) + ( ( ( ( ( ) (_ ) ) ) _) ) _( ( ) + (( ( )( ( _ ) _) _(_ ( (_ ) + (_((__(_(__(( ( ( | ) ) ) )_))__))_)___) + ((__) \\||lll|l||/// \_)) + ( /(/ ( ) ) )\ ) + ( ( ( ( | | ) ) )\ ) + ( /(| / ( )) ) ) )) ) + ( ( ((((_(|)_))))) ) + ( ||\(|(|)|/|| ) + ( |(||(||)|||| ) + ( //|/l|||)|\\ \ ) + (/ / // /|//||||\\ \ \ \ _) +------------------------------------------------------------------------------- +""" diff --git a/airflow/utils/compression.py b/airflow/utils/compression.py new file mode 100644 index 0000000000000..9d0785f63bd51 --- /dev/null +++ b/airflow/utils/compression.py @@ -0,0 +1,38 @@ +# -*- coding: utf-8 -*- +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from tempfile import NamedTemporaryFile +import shutil +import gzip +import bz2 + + +def uncompress_file(input_file_name, file_extension, dest_dir): + """ + Uncompress gz and bz2 files + """ + if file_extension.lower() not in ('.gz', '.bz2'): + raise NotImplementedError("Received {} format. Only gz and bz2 " + "files can currently be uncompressed." + .format(file_extension)) + if file_extension.lower() == '.gz': + fmodule = gzip.GzipFile + elif file_extension.lower() == '.bz2': + fmodule = bz2.BZ2File + with fmodule(input_file_name, mode='rb') as f_compressed,\ + NamedTemporaryFile(dir=dest_dir, + mode='wb', + delete=False) as f_uncompressed: + shutil.copyfileobj(f_compressed, f_uncompressed) + return f_uncompressed.name diff --git a/airflow/utils/dag_processing.py b/airflow/utils/dag_processing.py new file mode 100644 index 0000000000000..b80f7016071fa --- /dev/null +++ b/airflow/utils/dag_processing.py @@ -0,0 +1,689 @@ +# -*- coding: utf-8 -*- +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +from __future__ import absolute_import +from __future__ import division +from __future__ import print_function +from __future__ import unicode_literals + +import os +import re +import time +import zipfile +from abc import ABCMeta, abstractmethod +from collections import defaultdict +from datetime import datetime + +from airflow.dag.base_dag import BaseDag, BaseDagBag +from airflow.exceptions import AirflowException +from airflow.utils.log.logging_mixin import LoggingMixin + + +class SimpleDag(BaseDag): + """ + A simplified representation of a DAG that contains all attributes + required for instantiating and scheduling its associated tasks. + """ + + def __init__(self, + dag_id, + task_ids, + full_filepath, + concurrency, + is_paused, + pickle_id, + task_special_args): + """ + :param dag_id: ID of the DAG + :type dag_id: unicode + :param task_ids: task IDs associated with the DAG + :type task_ids: list[unicode] + :param full_filepath: path to the file containing the DAG e.g. + /a/b/c.py + :type full_filepath: unicode + :param concurrency: No more than these many tasks from the + dag should run concurrently + :type concurrency: int + :param is_paused: Whether or not this DAG is paused. Tasks from paused + DAGs are not scheduled + :type is_paused: bool + :param pickle_id: ID associated with the pickled version of this DAG. + :type pickle_id: unicode + """ + self._dag_id = dag_id + self._task_ids = task_ids + self._full_filepath = full_filepath + self._is_paused = is_paused + self._concurrency = concurrency + self._pickle_id = pickle_id + self._task_special_args = task_special_args + + def __init__(self, dag, pickle_id=None): + self._dag_id = dag.dag_id + self._task_ids = [task.task_id for task in dag.tasks] + self._full_filepath = dag.full_filepath + self._is_paused = dag.is_paused + self._concurrency = dag.concurrency + self._pickle_id = pickle_id + self._task_special_args = {} + for task in dag.tasks: + special_args = {} + if task.task_concurrency is not None: + special_args['task_concurrency'] = task.task_concurrency + if len(special_args) > 0: + self._task_special_args[task.task_id] = special_args + + @property + def dag_id(self): + """ + :return: the DAG ID + :rtype: unicode + """ + return self._dag_id + + @property + def task_ids(self): + """ + :return: A list of task IDs that are in this DAG + :rtype: list[unicode] + """ + return self._task_ids + + @property + def full_filepath(self): + """ + :return: The absolute path to the file that contains this DAG's definition + :rtype: unicode + """ + return self._full_filepath + + @property + def concurrency(self): + """ + :return: maximum number of tasks that can run simultaneously from this DAG + :rtype: int + """ + return self._concurrency + + @property + def is_paused(self): + """ + :return: whether this DAG is paused or not + :rtype: bool + """ + return self._is_paused + + @property + def pickle_id(self): + """ + :return: The pickle ID for this DAG, if it has one. Otherwise None. + :rtype: unicode + """ + return self._pickle_id + + @property + def task_special_args(self): + return self._task_special_args + + def get_task_special_arg(self, task_id, special_arg_name): + if task_id in self._task_special_args and special_arg_name in self._task_special_args[task_id]: + return self._task_special_args[task_id][special_arg_name] + else: + return None + + +class SimpleDagBag(BaseDagBag): + """ + A collection of SimpleDag objects with some convenience methods. + """ + + def __init__(self, simple_dags): + """ + Constructor. + + :param simple_dags: SimpleDag objects that should be in this + :type: list(SimpleDag) + """ + self.simple_dags = simple_dags + self.dag_id_to_simple_dag = {} + + for simple_dag in simple_dags: + self.dag_id_to_simple_dag[simple_dag.dag_id] = simple_dag + + @property + def dag_ids(self): + """ + :return: IDs of all the DAGs in this + :rtype: list[unicode] + """ + return self.dag_id_to_simple_dag.keys() + + def get_dag(self, dag_id): + """ + :param dag_id: DAG ID + :type dag_id: unicode + :return: if the given DAG ID exists in the bag, return the BaseDag + corresponding to that ID. Otherwise, throw an Exception + :rtype: SimpleDag + """ + if dag_id not in self.dag_id_to_simple_dag: + raise AirflowException("Unknown DAG ID {}".format(dag_id)) + return self.dag_id_to_simple_dag[dag_id] + + +def list_py_file_paths(directory, safe_mode=True): + """ + Traverse a directory and look for Python files. + + :param directory: the directory to traverse + :type directory: unicode + :param safe_mode: whether to use a heuristic to determine whether a file + contains Airflow DAG definitions + :return: a list of paths to Python files in the specified directory + :rtype: list[unicode] + """ + file_paths = [] + if directory is None: + return [] + elif os.path.isfile(directory): + return [directory] + elif os.path.isdir(directory): + patterns = [] + for root, dirs, files in os.walk(directory, followlinks=True): + ignore_file = [f for f in files if f == '.airflowignore'] + if ignore_file: + f = open(os.path.join(root, ignore_file[0]), 'r') + patterns += [p for p in f.read().split('\n') if p] + f.close() + for f in files: + try: + file_path = os.path.join(root, f) + if not os.path.isfile(file_path): + continue + mod_name, file_ext = os.path.splitext( + os.path.split(file_path)[-1]) + if file_ext != '.py' and not zipfile.is_zipfile(file_path): + continue + if any([re.findall(p, file_path) for p in patterns]): + continue + + # Heuristic that guesses whether a Python file contains an + # Airflow DAG definition. + might_contain_dag = True + if safe_mode and not zipfile.is_zipfile(file_path): + with open(file_path, 'rb') as f: + content = f.read() + might_contain_dag = all( + [s in content for s in (b'DAG', b'airflow')]) + + if not might_contain_dag: + continue + + file_paths.append(file_path) + except Exception: + log = LoggingMixin().log + log.exception("Error while examining %s", f) + return file_paths + + +class AbstractDagFileProcessor(object): + """ + Processes a DAG file. See SchedulerJob.process_file() for more details. + """ + __metaclass__ = ABCMeta + + @abstractmethod + def start(self): + """ + Launch the process to process the file + """ + raise NotImplementedError() + + @abstractmethod + def terminate(self, sigkill=False): + """ + Terminate (and then kill) the process launched to process the file + """ + raise NotImplementedError() + + @property + @abstractmethod + def pid(self): + """ + :return: the PID of the process launched to process the given file + """ + raise NotImplementedError() + + @property + @abstractmethod + def exit_code(self): + """ + After the process is finished, this can be called to get the return code + :return: the exit code of the process + :rtype: int + """ + raise NotImplementedError() + + @property + @abstractmethod + def done(self): + """ + Check if the process launched to process this file is done. + :return: whether the process is finished running + :rtype: bool + """ + raise NotImplementedError() + + @property + @abstractmethod + def result(self): + """ + :return: result of running SchedulerJob.process_file() + :rtype: list[SimpleDag] + """ + raise NotImplementedError() + + @property + @abstractmethod + def start_time(self): + """ + :return: When this started to process the file + :rtype: datetime + """ + raise NotImplementedError() + + @property + @abstractmethod + def log_file(self): + """ + :return: the log file associated with this processor + :rtype: unicode + """ + raise NotImplementedError() + + @property + @abstractmethod + def file_path(self): + """ + :return: the path to the file that this is processing + :rtype: unicode + """ + raise NotImplementedError() + + +class DagFileProcessorManager(LoggingMixin): + """ + Given a list of DAG definition files, this kicks off several processors + in parallel to process them. The parallelism is limited and as the + processors finish, more are launched. The files are processed over and + over again, but no more often than the specified interval. + + :type _file_path_queue: list[unicode] + :type _processors: dict[unicode, AbstractDagFileProcessor] + :type _last_runtime: dict[unicode, float] + :type _last_finish_time: dict[unicode, datetime] + """ + + def __init__(self, + dag_directory, + file_paths, + parallelism, + process_file_interval, + child_process_log_directory, + max_runs, + processor_factory): + """ + :param dag_directory: Directory where DAG definitions are kept. All + files in file_paths should be under this directory + :type dag_directory: unicode + :param file_paths: list of file paths that contain DAG definitions + :type file_paths: list[unicode] + :param parallelism: maximum number of simultaneous process to run at once + :type parallelism: int + :param process_file_interval: process a file at most once every this + many seconds + :type process_file_interval: float + :param max_runs: The number of times to parse and schedule each file. -1 + for unlimited. + :type max_runs: int + :param child_process_log_directory: Store logs for child processes in + this directory + :type child_process_log_directory: unicode + :type process_file_interval: float + :param processor_factory: function that creates processors for DAG + definition files. Arguments are (dag_definition_path, log_file_path) + :type processor_factory: (unicode, unicode) -> (AbstractDagFileProcessor) + + """ + self._file_paths = file_paths + self._file_path_queue = [] + self._parallelism = parallelism + self._dag_directory = dag_directory + self._max_runs = max_runs + self._process_file_interval = process_file_interval + self._child_process_log_directory = child_process_log_directory + self._processor_factory = processor_factory + # Map from file path to the processor + self._processors = {} + # Map from file path to the last runtime + self._last_runtime = {} + # Map from file path to the last finish time + self._last_finish_time = {} + # Map from file path to the number of runs + self._run_count = defaultdict(int) + # Scheduler heartbeat key. + self._heart_beat_key = 'heart-beat' + + @property + def file_paths(self): + return self._file_paths + + def get_pid(self, file_path): + """ + :param file_path: the path to the file that's being processed + :type file_path: unicode + :return: the PID of the process processing the given file or None if + the specified file is not being processed + :rtype: int + """ + if file_path in self._processors: + return self._processors[file_path].pid + return None + + def get_all_pids(self): + """ + :return: a list of the PIDs for the processors that are running + :rtype: List[int] + """ + return [x.pid for x in self._processors.values()] + + def get_runtime(self, file_path): + """ + :param file_path: the path to the file that's being processed + :type file_path: unicode + :return: the current runtime (in seconds) of the process that's + processing the specified file or None if the file is not currently + being processed + """ + if file_path in self._processors: + return (datetime.utcnow() - self._processors[file_path].start_time)\ + .total_seconds() + return None + + def get_last_runtime(self, file_path): + """ + :param file_path: the path to the file that was processed + :type file_path: unicode + :return: the runtime (in seconds) of the process of the last run, or + None if the file was never processed. + :rtype: float + """ + return self._last_runtime.get(file_path) + + def get_last_finish_time(self, file_path): + """ + :param file_path: the path to the file that was processed + :type file_path: unicode + :return: the finish time of the process of the last run, or None if the + file was never processed. + :rtype: datetime + """ + return self._last_finish_time.get(file_path) + + def get_start_time(self, file_path): + """ + :param file_path: the path to the file that's being processed + :type file_path: unicode + :return: the start time of the process that's processing the + specified file or None if the file is not currently being processed + :rtype: datetime + """ + if file_path in self._processors: + return self._processors[file_path].start_time + return None + + def set_file_paths(self, new_file_paths): + """ + Update this with a new set of paths to DAG definition files. + + :param new_file_paths: list of paths to DAG definition files + :type new_file_paths: list[unicode] + :return: None + """ + self._file_paths = new_file_paths + self._file_path_queue = [x for x in self._file_path_queue + if x in new_file_paths] + # Stop processors that are working on deleted files + filtered_processors = {} + for file_path, processor in self._processors.items(): + if file_path in new_file_paths: + filtered_processors[file_path] = processor + else: + self.log.warning("Stopping processor for %s", file_path) + processor.stop() + self._processors = filtered_processors + + @staticmethod + def _split_path(file_path): + """ + Return the path elements of a path as an array. E.g. /a/b/c -> + ['a', 'b', 'c'] + + :param file_path: the file path to split + :return: a list of the elements of the file path + :rtype: list[unicode] + """ + results = [] + while True: + head, tail = os.path.split(file_path) + if len(tail) != 0: + results.append(tail) + if file_path == head: + break + file_path = head + results.reverse() + return results + + def _get_log_directory(self): + """ + Log output from processing DAGs for the current day should go into + this directory. + + :return: the path to the corresponding log directory + :rtype: unicode + """ + now = datetime.utcnow() + return os.path.join(self._child_process_log_directory, + now.strftime("%Y-%m-%d")) + + def _get_log_file_path(self, dag_file_path): + """ + Log output from processing the specified file should go to this + location. + + :param dag_file_path: file containing a DAG + :type dag_file_path: unicode + :return: the path to the corresponding log file + :rtype: unicode + """ + log_directory = self._get_log_directory() + # General approach is to put the log file under the same relative path + # under the log directory as the DAG file in the DAG directory + relative_dag_file_path = os.path.relpath(dag_file_path, start=self._dag_directory) + path_elements = self._split_path(relative_dag_file_path) + + # Add a .log suffix for the log file + path_elements[-1] += ".log" + + return os.path.join(log_directory, *path_elements) + + def symlink_latest_log_directory(self): + """ + Create symbolic link to the current day's log directory to + allow easy access to the latest scheduler log files. + + :return: None + """ + log_directory = self._get_log_directory() + latest_log_directory_path = os.path.join( + self._child_process_log_directory, "latest") + if os.path.isdir(log_directory): + # if symlink exists but is stale, update it + if os.path.islink(latest_log_directory_path): + if os.readlink(latest_log_directory_path) != log_directory: + os.unlink(latest_log_directory_path) + os.symlink(log_directory, latest_log_directory_path) + elif (os.path.isdir(latest_log_directory_path) or + os.path.isfile(latest_log_directory_path)): + self.log.warning( + "%s already exists as a dir/file. Skip creating symlink.", + latest_log_directory_path + ) + else: + os.symlink(log_directory, latest_log_directory_path) + + def processing_count(self): + """ + :return: the number of files currently being processed + :rtype: int + """ + return len(self._processors) + + def wait_until_finished(self): + """ + Sleeps until all the processors are done. + """ + for file_path, processor in self._processors.items(): + while not processor.done: + time.sleep(0.1) + + def heartbeat(self): + """ + This should be periodically called by the scheduler. This method will + kick of new processes to process DAG definition files and read the + results from the finished processors. + + :return: a list of SimpleDags that were produced by processors that + have finished since the last time this was called + :rtype: list[SimpleDag] + """ + finished_processors = {} + """:type : dict[unicode, AbstractDagFileProcessor]""" + running_processors = {} + """:type : dict[unicode, AbstractDagFileProcessor]""" + + for file_path, processor in self._processors.items(): + if processor.done: + self.log.info("Processor for %s finished", file_path) + now = datetime.utcnow() + finished_processors[file_path] = processor + self._last_runtime[file_path] = (now - + processor.start_time).total_seconds() + self._last_finish_time[file_path] = now + self._run_count[file_path] += 1 + else: + running_processors[file_path] = processor + self._processors = running_processors + + # Collect all the DAGs that were found in the processed files + simple_dags = [] + for file_path, processor in finished_processors.items(): + if processor.result is None: + self.log.warning( + "Processor for %s exited with return code %s. See %s for details.", + processor.file_path, processor.exit_code, processor.log_file + ) + else: + for simple_dag in processor.result: + simple_dags.append(simple_dag) + + # Generate more file paths to process if we processed all the files + # already. + if len(self._file_path_queue) == 0: + # If the file path is already being processed, or if a file was + # processed recently, wait until the next batch + file_paths_in_progress = self._processors.keys() + now = datetime.utcnow() + file_paths_recently_processed = [] + for file_path in self._file_paths: + last_finish_time = self.get_last_finish_time(file_path) + if (last_finish_time is not None and + (now - last_finish_time).total_seconds() < + self._process_file_interval): + file_paths_recently_processed.append(file_path) + + files_paths_at_run_limit = [file_path + for file_path, num_runs in self._run_count.items() + if num_runs == self._max_runs] + + files_paths_to_queue = list(set(self._file_paths) - + set(file_paths_in_progress) - + set(file_paths_recently_processed) - + set(files_paths_at_run_limit)) + + for file_path, processor in self._processors.items(): + self.log.debug( + "File path %s is still being processed (started: %s)", + processor.file_path, processor.start_time.isoformat() + ) + + self.log.debug( + "Queuing the following files for processing:\n\t%s", + "\n\t".join(files_paths_to_queue) + ) + + self._file_path_queue.extend(files_paths_to_queue) + + # Start more processors if we have enough slots and files to process + while (self._parallelism - len(self._processors) > 0 and + len(self._file_path_queue) > 0): + file_path = self._file_path_queue.pop(0) + log_file_path = self._get_log_file_path(file_path) + processor = self._processor_factory(file_path, log_file_path) + + processor.start() + self.log.info( + "Started a process (PID: %s) to generate tasks for %s - logging into %s", + processor.pid, file_path, log_file_path + ) + + self._processors[file_path] = processor + + self.symlink_latest_log_directory() + + # Update scheduler heartbeat count. + self._run_count[self._heart_beat_key] += 1 + + return simple_dags + + def max_runs_reached(self): + """ + :return: whether all file paths have been processed max_runs times + """ + if self._max_runs == -1: # Unlimited runs. + return False + for file_path in self._file_paths: + if self._run_count[file_path] != self._max_runs: + return False + if self._run_count[self._heart_beat_key] < self._max_runs: + return False + return True + + def terminate(self): + """ + Stops all running processors + :return: None + """ + for processor in self._processors.values(): + processor.terminate() diff --git a/airflow/utils/dates.py b/airflow/utils/dates.py new file mode 100644 index 0000000000000..43b87f4b00f7d --- /dev/null +++ b/airflow/utils/dates.py @@ -0,0 +1,227 @@ +# -*- coding: utf-8 -*- +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +from __future__ import absolute_import +from __future__ import division +from __future__ import print_function +from __future__ import unicode_literals + +from datetime import datetime, timedelta +from dateutil.relativedelta import relativedelta # for doctest +import six + +from croniter import croniter + + +cron_presets = { + '@hourly': '0 * * * *', + '@daily': '0 0 * * *', + '@weekly': '0 0 * * 0', + '@monthly': '0 0 1 * *', + '@yearly': '0 0 1 1 *', +} + + +def date_range( + start_date, + end_date=None, + num=None, + delta=None): + """ + Get a set of dates as a list based on a start, end and delta, delta + can be something that can be added to ``datetime.datetime`` + or a cron expression as a ``str`` + + :param start_date: anchor date to start the series from + :type start_date: datetime.datetime + :param end_date: right boundary for the date range + :type end_date: datetime.datetime + :param num: alternatively to end_date, you can specify the number of + number of entries you want in the range. This number can be negative, + output will always be sorted regardless + :type num: int + + >>> date_range(datetime(2016, 1, 1), datetime(2016, 1, 3), delta=timedelta(1)) + [datetime.datetime(2016, 1, 1, 0, 0), datetime.datetime(2016, 1, 2, 0, 0), datetime.datetime(2016, 1, 3, 0, 0)] + >>> date_range(datetime(2016, 1, 1), datetime(2016, 1, 3), delta='0 0 * * *') + [datetime.datetime(2016, 1, 1, 0, 0), datetime.datetime(2016, 1, 2, 0, 0), datetime.datetime(2016, 1, 3, 0, 0)] + >>> date_range(datetime(2016, 1, 1), datetime(2016, 3, 3), delta="0 0 0 * *") + [datetime.datetime(2016, 1, 1, 0, 0), datetime.datetime(2016, 2, 1, 0, 0), datetime.datetime(2016, 3, 1, 0, 0)] + """ + if not delta: + return [] + if end_date and start_date > end_date: + raise Exception("Wait. start_date needs to be before end_date") + if end_date and num: + raise Exception("Wait. Either specify end_date OR num") + if not end_date and not num: + end_date = datetime.utcnow() + + delta_iscron = False + if isinstance(delta, six.string_types): + delta_iscron = True + cron = croniter(delta, start_date) + elif isinstance(delta, timedelta): + delta = abs(delta) + l = [] + if end_date: + while start_date <= end_date: + l.append(start_date) + if delta_iscron: + start_date = cron.get_next(datetime) + else: + start_date += delta + else: + for _ in range(abs(num)): + l.append(start_date) + if delta_iscron: + if num > 0: + start_date = cron.get_next(datetime) + else: + start_date = cron.get_prev(datetime) + else: + if num > 0: + start_date += delta + else: + start_date -= delta + return sorted(l) + + +def round_time(dt, delta, start_date=datetime.min): + """ + Returns the datetime of the form start_date + i * delta + which is closest to dt for any non-negative integer i. + + Note that delta may be a datetime.timedelta or a dateutil.relativedelta + + >>> round_time(datetime(2015, 1, 1, 6), timedelta(days=1)) + datetime.datetime(2015, 1, 1, 0, 0) + >>> round_time(datetime(2015, 1, 2), relativedelta(months=1)) + datetime.datetime(2015, 1, 1, 0, 0) + >>> round_time(datetime(2015, 9, 16, 0, 0), timedelta(1), datetime(2015, 9, 14, 0, 0)) + datetime.datetime(2015, 9, 16, 0, 0) + >>> round_time(datetime(2015, 9, 15, 0, 0), timedelta(1), datetime(2015, 9, 14, 0, 0)) + datetime.datetime(2015, 9, 15, 0, 0) + >>> round_time(datetime(2015, 9, 14, 0, 0), timedelta(1), datetime(2015, 9, 14, 0, 0)) + datetime.datetime(2015, 9, 14, 0, 0) + >>> round_time(datetime(2015, 9, 13, 0, 0), timedelta(1), datetime(2015, 9, 14, 0, 0)) + datetime.datetime(2015, 9, 14, 0, 0) + """ + + if isinstance(delta, six.string_types): + # It's cron based, so it's easy + cron = croniter(delta, start_date) + prev = cron.get_prev(datetime) + if prev == start_date: + return start_date + else: + return prev + + # Ignore the microseconds of dt + dt -= timedelta(microseconds=dt.microsecond) + + # We are looking for a datetime in the form start_date + i * delta + # which is as close as possible to dt. Since delta could be a relative + # delta we don't know it's exact length in seconds so we cannot rely on + # division to find i. Instead we employ a binary search algorithm, first + # finding an upper and lower limit and then disecting the interval until + # we have found the closest match. + + # We first search an upper limit for i for which start_date + upper * delta + # exceeds dt. + upper = 1 + while start_date + upper*delta < dt: + # To speed up finding an upper limit we grow this exponentially by a + # factor of 2 + upper *= 2 + + # Since upper is the first value for which start_date + upper * delta + # exceeds dt, upper // 2 is below dt and therefore forms a lower limited + # for the i we are looking for + lower = upper // 2 + + # We now continue to intersect the interval between + # start_date + lower * delta and start_date + upper * delta + # until we find the closest value + while True: + # Invariant: start + lower * delta < dt <= start + upper * delta + # If start_date + (lower + 1)*delta exceeds dt, then either lower or + # lower+1 has to be the solution we are searching for + if start_date + (lower + 1)*delta >= dt: + # Check if start_date + (lower + 1)*delta or + # start_date + lower*delta is closer to dt and return the solution + if ( + (start_date + (lower + 1) * delta) - dt <= + dt - (start_date + lower * delta)): + return start_date + (lower + 1)*delta + else: + return start_date + lower * delta + + # We intersect the interval and either replace the lower or upper + # limit with the candidate + candidate = lower + (upper - lower) // 2 + if start_date + candidate*delta >= dt: + upper = candidate + else: + lower = candidate + + # in the special case when start_date > dt the search for upper will + # immediately stop for upper == 1 which results in lower = upper // 2 = 0 + # and this function returns start_date. + + +def infer_time_unit(time_seconds_arr): + """ + Determine the most appropriate time unit for an array of time durations + specified in seconds. + + e.g. 5400 seconds => 'minutes', 36000 seconds => 'hours' + """ + if len(time_seconds_arr) == 0: + return 'hours' + max_time_seconds = max(time_seconds_arr) + if max_time_seconds <= 60*2: + return 'seconds' + elif max_time_seconds <= 60*60*2: + return 'minutes' + elif max_time_seconds <= 24*60*60*2: + return 'hours' + else: + return 'days' + + +def scale_time_units(time_seconds_arr, unit): + """ + Convert an array of time durations in seconds to the specified time unit. + """ + if unit == 'minutes': + return list(map(lambda x: x*1.0/60, time_seconds_arr)) + elif unit == 'hours': + return list(map(lambda x: x*1.0/(60*60), time_seconds_arr)) + elif unit == 'days': + return list(map(lambda x: x*1.0/(24*60*60), time_seconds_arr)) + return time_seconds_arr + + +def days_ago(n, hour=0, minute=0, second=0, microsecond=0): + """ + Get a datetime object representing `n` days ago. By default the time is + set to midnight. + """ + today = datetime.utcnow().replace( + hour=hour, + minute=minute, + second=second, + microsecond=microsecond) + return today - timedelta(days=n) diff --git a/airflow/utils/db.py b/airflow/utils/db.py new file mode 100644 index 0000000000000..1d086be33a68c --- /dev/null +++ b/airflow/utils/db.py @@ -0,0 +1,341 @@ +# -*- coding: utf-8 -*- +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +from __future__ import absolute_import +from __future__ import division +from __future__ import print_function +from __future__ import unicode_literals + +from functools import wraps + +import os + +from sqlalchemy import event, exc +from sqlalchemy.pool import Pool + +from airflow import settings +from airflow.utils.log.logging_mixin import LoggingMixin + +log = LoggingMixin().log + +def provide_session(func): + """ + Function decorator that provides a session if it isn't provided. + If you want to reuse a session or run the function as part of a + database transaction, you pass it to the function, if not this wrapper + will create one and close it for you. + """ + @wraps(func) + def wrapper(*args, **kwargs): + needs_session = False + arg_session = 'session' + func_params = func.__code__.co_varnames + session_in_args = arg_session in func_params and \ + func_params.index(arg_session) < len(args) + if not (arg_session in kwargs or session_in_args): + needs_session = True + session = settings.Session() + kwargs[arg_session] = session + result = func(*args, **kwargs) + if needs_session: + session.expunge_all() + session.commit() + session.close() + return result + return wrapper + + +def pessimistic_connection_handling(): + @event.listens_for(Pool, "checkout") + def ping_connection(dbapi_connection, connection_record, connection_proxy): + ''' + Disconnect Handling - Pessimistic, taken from: + http://docs.sqlalchemy.org/en/rel_0_9/core/pooling.html + ''' + cursor = dbapi_connection.cursor() + try: + cursor.execute("SELECT 1") + except: + raise exc.DisconnectionError() + cursor.close() + + +@provide_session +def merge_conn(conn, session=None): + from airflow import models + C = models.Connection + if not session.query(C).filter(C.conn_id == conn.conn_id).first(): + session.add(conn) + session.commit() + + +@event.listens_for(settings.engine, "connect") +def connect(dbapi_connection, connection_record): + connection_record.info['pid'] = os.getpid() + + +@event.listens_for(settings.engine, "checkout") +def checkout(dbapi_connection, connection_record, connection_proxy): + pid = os.getpid() + if connection_record.info['pid'] != pid: + connection_record.connection = connection_proxy.connection = None + raise exc.DisconnectionError( + "Connection record belongs to pid {}, " + "attempting to check out in pid {}".format(connection_record.info['pid'], pid) + ) + + +def initdb(): + session = settings.Session() + + from airflow import models + upgradedb() + + merge_conn( + models.Connection( + conn_id='airflow_db', conn_type='mysql', + host='localhost', login='root', password='', + schema='airflow')) + merge_conn( + models.Connection( + conn_id='airflow_ci', conn_type='mysql', + host='localhost', login='root', extra="{\"local_infile\": true}", + schema='airflow_ci')) + merge_conn( + models.Connection( + conn_id='beeline_default', conn_type='beeline', port="10000", + host='localhost', extra="{\"use_beeline\": true, \"auth\": \"\"}", + schema='default')) + merge_conn( + models.Connection( + conn_id='bigquery_default', conn_type='bigquery')) + merge_conn( + models.Connection( + conn_id='local_mysql', conn_type='mysql', + host='localhost', login='airflow', password='airflow', + schema='airflow')) + merge_conn( + models.Connection( + conn_id='presto_default', conn_type='presto', + host='localhost', + schema='hive', port=3400)) + merge_conn( + models.Connection( + conn_id='google_cloud_default', conn_type='google_cloud_platform', + schema='default',)) + merge_conn( + models.Connection( + conn_id='hive_cli_default', conn_type='hive_cli', + schema='default',)) + merge_conn( + models.Connection( + conn_id='hiveserver2_default', conn_type='hiveserver2', + host='localhost', + schema='default', port=10000)) + merge_conn( + models.Connection( + conn_id='metastore_default', conn_type='hive_metastore', + host='localhost', extra="{\"authMechanism\": \"PLAIN\"}", + port=9083)) + merge_conn( + models.Connection( + conn_id='mysql_default', conn_type='mysql', + login='root', + host='localhost')) + merge_conn( + models.Connection( + conn_id='postgres_default', conn_type='postgres', + login='postgres', + schema='airflow', + host='localhost')) + merge_conn( + models.Connection( + conn_id='sqlite_default', conn_type='sqlite', + host='/tmp/sqlite_default.db')) + merge_conn( + models.Connection( + conn_id='http_default', conn_type='http', + host='https://www.google.com/')) + merge_conn( + models.Connection( + conn_id='mssql_default', conn_type='mssql', + host='localhost', port=1433)) + merge_conn( + models.Connection( + conn_id='vertica_default', conn_type='vertica', + host='localhost', port=5433)) + merge_conn( + models.Connection( + conn_id='wasb_default', conn_type='wasb', + extra='{"sas_token": null}')) + merge_conn( + models.Connection( + conn_id='webhdfs_default', conn_type='hdfs', + host='localhost', port=50070)) + merge_conn( + models.Connection( + conn_id='ssh_default', conn_type='ssh', + host='localhost')) + merge_conn( + models.Connection( + conn_id='fs_default', conn_type='fs', + extra='{"path": "/"}')) + merge_conn( + models.Connection( + conn_id='aws_default', conn_type='aws', + extra='{"region_name": "us-east-1"}')) + merge_conn( + models.Connection( + conn_id='spark_default', conn_type='spark', + host='yarn', extra='{"queue": "root.default"}')) + merge_conn( + models.Connection( + conn_id='druid_ingest_default', conn_type='druid', + host='druid-overlord', port=8081, extra='{"endpoint": "druid/indexer/v1/task"}')) + merge_conn( + models.Connection( + conn_id='redis_default', conn_type='redis', + host='localhost', port=6379, + extra='{"db": 0}')) + merge_conn( + models.Connection( + conn_id='sqoop_default', conn_type='sqoop', + host='rmdbs', extra='')) + merge_conn( + models.Connection( + conn_id='emr_default', conn_type='emr', + extra=''' + { "Name": "default_job_flow_name", + "LogUri": "s3://my-emr-log-bucket/default_job_flow_location", + "ReleaseLabel": "emr-4.6.0", + "Instances": { + "InstanceGroups": [ + { + "Name": "Master nodes", + "Market": "ON_DEMAND", + "InstanceRole": "MASTER", + "InstanceType": "r3.2xlarge", + "InstanceCount": 1 + }, + { + "Name": "Slave nodes", + "Market": "ON_DEMAND", + "InstanceRole": "CORE", + "InstanceType": "r3.2xlarge", + "InstanceCount": 1 + } + ] + }, + "Ec2KeyName": "mykey", + "KeepJobFlowAliveWhenNoSteps": false, + "TerminationProtected": false, + "Ec2SubnetId": "somesubnet", + "Applications":[ + { "Name": "Spark" } + ], + "VisibleToAllUsers": true, + "JobFlowRole": "EMR_EC2_DefaultRole", + "ServiceRole": "EMR_DefaultRole", + "Tags": [ + { + "Key": "app", + "Value": "analytics" + }, + { + "Key": "environment", + "Value": "development" + } + ] + } + ''')) + merge_conn( + models.Connection( + conn_id='databricks_default', conn_type='databricks', + host='localhost')) + merge_conn( + models.Connection( + conn_id='qubole_default', conn_type='qubole', + host= 'localhost')) + + # Known event types + KET = models.KnownEventType + if not session.query(KET).filter(KET.know_event_type == 'Holiday').first(): + session.add(KET(know_event_type='Holiday')) + if not session.query(KET).filter(KET.know_event_type == 'Outage').first(): + session.add(KET(know_event_type='Outage')) + if not session.query(KET).filter( + KET.know_event_type == 'Natural Disaster').first(): + session.add(KET(know_event_type='Natural Disaster')) + if not session.query(KET).filter( + KET.know_event_type == 'Marketing Campaign').first(): + session.add(KET(know_event_type='Marketing Campaign')) + session.commit() + + dagbag = models.DagBag() + # Save individual DAGs in the ORM + for dag in dagbag.dags.values(): + dag.sync_to_db() + # Deactivate the unknown ones + models.DAG.deactivate_unknown_dags(dagbag.dags.keys()) + + Chart = models.Chart + chart_label = "Airflow task instance by type" + chart = session.query(Chart).filter(Chart.label == chart_label).first() + if not chart: + chart = Chart( + label=chart_label, + conn_id='airflow_db', + chart_type='bar', + x_is_date=False, + sql=( + "SELECT state, COUNT(1) as number " + "FROM task_instance " + "WHERE dag_id LIKE 'example%' " + "GROUP BY state"), + ) + session.add(chart) + session.commit() + + +def upgradedb(): + # alembic adds significant import time, so we import it lazily + from alembic import command + from alembic.config import Config + + log.info("Creating tables") + + current_dir = os.path.dirname(os.path.abspath(__file__)) + package_dir = os.path.normpath(os.path.join(current_dir, '..')) + directory = os.path.join(package_dir, 'migrations') + config = Config(os.path.join(package_dir, 'alembic.ini')) + config.set_main_option('script_location', directory) + config.set_main_option('sqlalchemy.url', settings.SQL_ALCHEMY_CONN) + command.upgrade(config, 'heads') + + +def resetdb(): + ''' + Clear out the database + ''' + from airflow import models + # alembic adds significant import time, so we import it lazily + from alembic.migration import MigrationContext + + log.info("Dropping tables that exist") + + models.Base.metadata.drop_all(settings.engine) + mc = MigrationContext.configure(settings.engine) + if mc._version.exists(settings.engine): + mc._version.drop(settings.engine) + initdb() diff --git a/airflow/utils/decorators.py b/airflow/utils/decorators.py new file mode 100644 index 0000000000000..995e60f16aa06 --- /dev/null +++ b/airflow/utils/decorators.py @@ -0,0 +1,92 @@ +# -*- coding: utf-8 -*- +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +import os + +# inspect.signature is only available in Python 3. funcsigs.signature is +# a backport. +try: + import inspect + signature = inspect.signature +except AttributeError: + import funcsigs + signature = funcsigs.signature + +from copy import copy +from functools import wraps + +from airflow.exceptions import AirflowException + + +def apply_defaults(func): + """ + Function decorator that Looks for an argument named "default_args", and + fills the unspecified arguments from it. + + Since python2.* isn't clear about which arguments are missing when + calling a function, and that this can be quite confusing with multi-level + inheritance and argument defaults, this decorator also alerts with + specific information about the missing arguments. + """ + @wraps(func) + def wrapper(*args, **kwargs): + if len(args) > 1: + raise AirflowException( + "Use keyword arguments when initializing operators") + dag_args = {} + dag_params = {} + import airflow.models + if kwargs.get('dag', None) or airflow.models._CONTEXT_MANAGER_DAG: + dag = kwargs.get('dag', None) or airflow.models._CONTEXT_MANAGER_DAG + dag_args = copy(dag.default_args) or {} + dag_params = copy(dag.params) or {} + + params = {} + if 'params' in kwargs: + params = kwargs['params'] + dag_params.update(params) + + default_args = {} + if 'default_args' in kwargs: + default_args = kwargs['default_args'] + if 'params' in default_args: + dag_params.update(default_args['params']) + del default_args['params'] + + dag_args.update(default_args) + default_args = dag_args + + sig = signature(func) + non_optional_args = [ + name for (name, param) in sig.parameters.items() + if param.default == param.empty and + param.name != 'self' and + param.kind not in (param.VAR_POSITIONAL, param.VAR_KEYWORD)] + for arg in sig.parameters: + if arg in default_args and arg not in kwargs: + kwargs[arg] = default_args[arg] + missing_args = list(set(non_optional_args) - set(kwargs)) + if missing_args: + msg = "Argument {0} is required".format(missing_args) + raise AirflowException(msg) + + kwargs['params'] = dag_params + + result = func(*args, **kwargs) + return result + return wrapper + +if 'BUILDING_AIRFLOW_DOCS' in os.environ: + # Monkey patch hook to get good function headers while building docs + apply_defaults = lambda x: x diff --git a/airflow/utils/email.py b/airflow/utils/email.py new file mode 100644 index 0000000000000..21ae707db51e5 --- /dev/null +++ b/airflow/utils/email.py @@ -0,0 +1,169 @@ +# -*- coding: utf-8 -*- +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from __future__ import absolute_import +from __future__ import division +from __future__ import print_function +from __future__ import unicode_literals + +from builtins import str +from past.builtins import basestring + +import importlib +import mimetypes +import os +import sendgrid +import smtplib + +from email.mime.text import MIMEText +from email.mime.multipart import MIMEMultipart +from email.mime.application import MIMEApplication +from email.utils import formatdate +from sendgrid.helpers.mail import Attachment, Content, Email, Mail, Personalization + +from airflow import configuration +from airflow.exceptions import AirflowConfigException +from airflow.utils.log.logging_mixin import LoggingMixin + + +def send_email(to, subject, html_content, files=None, dryrun=False, cc=None, bcc=None, mime_subtype='mixed'): + """ + Send email using backend specified in EMAIL_BACKEND. + """ + path, attr = configuration.get('email', 'EMAIL_BACKEND').rsplit('.', 1) + module = importlib.import_module(path) + backend = getattr(module, attr) + return backend(to, subject, html_content, files=files, dryrun=dryrun, cc=cc, bcc=bcc, mime_subtype=mime_subtype) + + +def send_email_sendgrid(to, subject, html_content, files=None, dryrun=False, cc=None, bcc=None, mime_subtype='mixed'): + """ + Send an email with html content using sendgrid. + """ + mail = Mail() + mail.from_email = Email(configuration.get('sendgrid', 'SENDGRID_MAIL_FROM')) + mail.subject = subject + + # Add the list of to emails. + to = get_email_address_list(to) + personalization = Personalization() + for to_address in to: + personalization.add_to(Email(to_address)) + mail.add_personalization(personalization) + mail.add_content(Content('text/html', html_content)) + + # Add email attachment. + for fname in files or []: + basename = os.path.basename(fname) + attachment = Attachment() + with open(fname, "rb") as f: + attachment.content = base64.b64encode(f.read()) + attachment.type = mimetypes.guess_type(basename)[0] + attachment.filename = basename + attachment.disposition = "attachment" + attachment.content_id = '<%s>' % basename + mail.add_attachment(attachment) + _post_sendgrid_mail(mail.get()) + + +def _post_sendgrid_mail(mail_data): + log = LoggingMixin().log + sg = sendgrid.SendGridAPIClient(apikey=configuration.get('sendgrid', 'SENDGRID_API_KEY')) + response = sg.client.mail.send.post(request_body=mail_data) + # 2xx status code. + if response.status_code >= 200 and response.status_code < 300: + log.info('The following email with subject %s is successfully sent to sendgrid.' % subject) + else: + log.warning('Failed to send out email with subject %s, status code: %s' % (subject, response.status_code)) + +def send_email_smtp(to, subject, html_content, files=None, dryrun=False, cc=None, bcc=None, mime_subtype='mixed'): + """ + Send an email with html content + + >>> send_email('test@example.com', 'foo', 'Foo bar', ['/dev/null'], dryrun=True) + """ + SMTP_MAIL_FROM = configuration.get('smtp', 'SMTP_MAIL_FROM') + + to = get_email_address_list(to) + + msg = MIMEMultipart(mime_subtype) + msg['Subject'] = subject + msg['From'] = SMTP_MAIL_FROM + msg['To'] = ", ".join(to) + recipients = to + if cc: + cc = get_email_address_list(cc) + msg['CC'] = ", ".join(cc) + recipients = recipients + cc + + if bcc: + # don't add bcc in header + bcc = get_email_address_list(bcc) + recipients = recipients + bcc + + msg['Date'] = formatdate(localtime=True) + mime_text = MIMEText(html_content, 'html') + msg.attach(mime_text) + + for fname in files or []: + basename = os.path.basename(fname) + with open(fname, "rb") as f: + part = MIMEApplication( + f.read(), + Name=basename + ) + part['Content-Disposition'] = 'attachment; filename="%s"' % basename + part['Content-ID'] = '<%s>' % basename + msg.attach(part) + + send_MIME_email(SMTP_MAIL_FROM, recipients, msg, dryrun) + + +def send_MIME_email(e_from, e_to, mime_msg, dryrun=False): + log = LoggingMixin().log + + SMTP_HOST = configuration.get('smtp', 'SMTP_HOST') + SMTP_PORT = configuration.getint('smtp', 'SMTP_PORT') + SMTP_STARTTLS = configuration.getboolean('smtp', 'SMTP_STARTTLS') + SMTP_SSL = configuration.getboolean('smtp', 'SMTP_SSL') + SMTP_USER = None + SMTP_PASSWORD = None + + try: + SMTP_USER = configuration.get('smtp', 'SMTP_USER') + SMTP_PASSWORD = configuration.get('smtp', 'SMTP_PASSWORD') + except AirflowConfigException: + log.debug("No user/password found for SMTP, so logging in with no authentication.") + + if not dryrun: + s = smtplib.SMTP_SSL(SMTP_HOST, SMTP_PORT) if SMTP_SSL else smtplib.SMTP(SMTP_HOST, SMTP_PORT) + if SMTP_STARTTLS: + s.starttls() + if SMTP_USER and SMTP_PASSWORD: + s.login(SMTP_USER, SMTP_PASSWORD) + log.info("Sent an alert email to %s", e_to) + s.sendmail(e_from, e_to, mime_msg.as_string()) + s.quit() + + +def get_email_address_list(address_string): + if isinstance(address_string, basestring): + if ',' in address_string: + address_string = address_string.split(',') + elif ';' in address_string: + address_string = address_string.split(';') + else: + address_string = [address_string] + + return address_string diff --git a/airflow/utils/file.py b/airflow/utils/file.py new file mode 100644 index 0000000000000..352755e4057e7 --- /dev/null +++ b/airflow/utils/file.py @@ -0,0 +1,57 @@ +# -*- coding: utf-8 -*- +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +from __future__ import absolute_import +from __future__ import unicode_literals + +import errno +import os +import shutil +from tempfile import mkdtemp + +from contextlib import contextmanager + + +@contextmanager +def TemporaryDirectory(suffix='', prefix=None, dir=None): + name = mkdtemp(suffix=suffix, prefix=prefix, dir=dir) + try: + yield name + finally: + try: + shutil.rmtree(name) + except OSError as e: + # ENOENT - no such file or directory + if e.errno != errno.ENOENT: + raise e + + +def mkdirs(path, mode): + """ + Creates the directory specified by path, creating intermediate directories + as necessary. If directory already exists, this is a no-op. + + :param path: The directory to create + :type path: str + :param mode: The mode to give to the directory e.g. 0o755, ignores umask + :type mode: int + """ + try: + o_umask = os.umask(0) + os.makedirs(path, mode) + except OSError: + if not os.path.isdir(path): + raise + finally: + os.umask(o_umask) diff --git a/airflow/utils/helpers.py b/airflow/utils/helpers.py new file mode 100644 index 0000000000000..4b8944e59d185 --- /dev/null +++ b/airflow/utils/helpers.py @@ -0,0 +1,381 @@ +# -*- coding: utf-8 -*- +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +from __future__ import absolute_import +from __future__ import division +from __future__ import print_function +from __future__ import unicode_literals + +import psutil + +from builtins import input +from past.builtins import basestring +from datetime import datetime +import getpass +import imp +import os +import re +import signal +import subprocess +import sys +import warnings + +from airflow import configuration +from airflow.exceptions import AirflowException + +# When killing processes, time to wait after issuing a SIGTERM before issuing a +# SIGKILL. +DEFAULT_TIME_TO_WAIT_AFTER_SIGTERM = configuration.getint('core', 'KILLED_TASK_CLEANUP_TIME') + + +def validate_key(k, max_length=250): + if not isinstance(k, basestring): + raise TypeError("The key has to be a string") + elif len(k) > max_length: + raise AirflowException( + "The key has to be less than {0} characters".format(max_length)) + elif not re.match(r'^[A-Za-z0-9_\-\.]+$', k): + raise AirflowException( + "The key ({k}) has to be made of alphanumeric characters, dashes, " + "dots and underscores exclusively".format(**locals())) + else: + return True + + +def alchemy_to_dict(obj): + """ + Transforms a SQLAlchemy model instance into a dictionary + """ + if not obj: + return None + d = {} + for c in obj.__table__.columns: + value = getattr(obj, c.name) + if type(value) == datetime: + value = value.isoformat() + d[c.name] = value + return d + + +def ask_yesno(question): + yes = set(['yes', 'y']) + no = set(['no', 'n']) + + done = False + print(question) + while not done: + choice = input().lower() + if choice in yes: + return True + elif choice in no: + return False + else: + print("Please respond by yes or no.") + + +def is_in(obj, l): + """ + Checks whether an object is one of the item in the list. + This is different from ``in`` because ``in`` uses __cmp__ when + present. Here we change based on the object itself + """ + for item in l: + if item is obj: + return True + return False + + +def is_container(obj): + """ + Test if an object is a container (iterable) but not a string + """ + return hasattr(obj, '__iter__') and not isinstance(obj, basestring) + + +def as_tuple(obj): + """ + If obj is a container, returns obj as a tuple. + Otherwise, returns a tuple containing obj. + """ + if is_container(obj): + return tuple(obj) + else: + return tuple([obj]) + + +def as_flattened_list(iterable): + """ + Return an iterable with one level flattened + + >>> as_flattened_list((('blue', 'red'), ('green', 'yellow', 'pink'))) + ['blue', 'red', 'green', 'yellow', 'pink'] + """ + return [e for i in iterable for e in i] + + +def chain(*tasks): + """ + Given a number of tasks, builds a dependency chain. + + chain(task_1, task_2, task_3, task_4) + + is equivalent to + + task_1.set_downstream(task_2) + task_2.set_downstream(task_3) + task_3.set_downstream(task_4) + """ + for up_task, down_task in zip(tasks[:-1], tasks[1:]): + up_task.set_downstream(down_task) + + +def pprinttable(rows): + """Returns a pretty ascii table from tuples + + If namedtuple are used, the table will have headers + """ + if not rows: + return + if hasattr(rows[0], '_fields'): # if namedtuple + headers = rows[0]._fields + else: + headers = ["col{}".format(i) for i in range(len(rows[0]))] + lens = [len(s) for s in headers] + + for row in rows: + for i in range(len(rows[0])): + slenght = len("{}".format(row[i])) + if slenght > lens[i]: + lens[i] = slenght + formats = [] + hformats = [] + for i in range(len(rows[0])): + if isinstance(rows[0][i], int): + formats.append("%%%dd" % lens[i]) + else: + formats.append("%%-%ds" % lens[i]) + hformats.append("%%-%ds" % lens[i]) + pattern = " | ".join(formats) + hpattern = " | ".join(hformats) + separator = "-+-".join(['-' * n for n in lens]) + s = "" + s += separator + '\n' + s += (hpattern % tuple(headers)) + '\n' + s += separator + '\n' + + def f(t): + return "{}".format(t) if isinstance(t, basestring) else t + + for line in rows: + s += pattern % tuple(f(t) for t in line) + '\n' + s += separator + '\n' + return s + + +def kill_using_shell(logger, pid, signal=signal.SIGTERM): + try: + process = psutil.Process(pid) + # Use sudo only when necessary - consider SubDagOperator and SequentialExecutor case. + if process.username() != getpass.getuser(): + args = ["sudo", "kill", "-{}".format(int(signal)), str(pid)] + else: + args = ["kill", "-{}".format(int(signal)), str(pid)] + # PID may not exist and return a non-zero error code + logger.error(subprocess.check_output(args)) + logger.info("Killed process {} with signal {}".format(pid, signal)) + return True + except psutil.NoSuchProcess as e: + logger.warning("Process {} no longer exists".format(pid)) + return False + except subprocess.CalledProcessError as e: + logger.warning("Failed to kill process {} with signal {}. Output: {}" + .format(pid, signal, e.output)) + return False + + +def kill_process_tree(logger, pid, timeout=DEFAULT_TIME_TO_WAIT_AFTER_SIGTERM): + """ + TODO(saguziel): also kill the root process after killing descendants + + Kills the process's descendants. Kills using the `kill` + shell command so that it can change users. Note: killing via PIDs + has the potential to the wrong process if the process dies and the + PID gets recycled in a narrow time window. + + :param logger: logger + :type logger: logging.Logger + """ + try: + root_process = psutil.Process(pid) + except psutil.NoSuchProcess: + logger.warning("PID: {} does not exist".format(pid)) + return + + # Check child processes to reduce cases where a child process died but + # the PID got reused. + descendant_processes = [x for x in root_process.children(recursive=True) + if x.is_running()] + + if len(descendant_processes) != 0: + logger.info("Terminating descendant processes of {} PID: {}" + .format(root_process.cmdline(), + root_process.pid)) + temp_processes = descendant_processes[:] + for descendant in temp_processes: + logger.info("Terminating descendant process {} PID: {}" + .format(descendant.cmdline(), descendant.pid)) + if not kill_using_shell(logger, descendant.pid, signal.SIGTERM): + descendant_processes.remove(descendant) + + logger.info("Waiting up to {}s for processes to exit..." + .format(timeout)) + try: + psutil.wait_procs(descendant_processes, timeout) + logger.info("Done waiting") + except psutil.TimeoutExpired: + logger.warning("Ran out of time while waiting for " + "processes to exit") + # Then SIGKILL + descendant_processes = [x for x in root_process.children(recursive=True) + if x.is_running()] + + if len(descendant_processes) > 0: + temp_processes = descendant_processes[:] + for descendant in temp_processes: + logger.info("Killing descendant process {} PID: {}" + .format(descendant.cmdline(), descendant.pid)) + if not kill_using_shell(logger, descendant.pid, signal.SIGKILL): + descendant_processes.remove(descendant) + else: + descendant.wait() + logger.info("Killed all descendant processes of {} PID: {}" + .format(root_process.cmdline(), + root_process.pid)) + else: + logger.debug("There are no descendant processes to kill") + + +class AirflowImporter(object): + """ + Importer that dynamically loads a class and module from its parent. This + allows Airflow to support ``from airflow.operators import BashOperator`` + even though BashOperator is actually in + ``airflow.operators.bash_operator``. + + The importer also takes over for the parent_module by wrapping it. This is + required to support attribute-based usage: + + .. code:: python + + from airflow import operators + operators.BashOperator(...) + """ + + def __init__(self, parent_module, module_attributes): + """ + :param parent_module: The string package name of the parent module. For + example, 'airflow.operators' + :type parent_module: string + :param module_attributes: The file to class mappings for all importable + classes. + :type module_attributes: string + """ + self._parent_module = parent_module + self._attribute_modules = self._build_attribute_modules(module_attributes) + self._loaded_modules = {} + + # Wrap the module so we can take over __getattr__. + sys.modules[parent_module.__name__] = self + + @staticmethod + def _build_attribute_modules(module_attributes): + """ + Flips and flattens the module_attributes dictionary from: + + module => [Attribute, ...] + + To: + + Attribute => module + + This is useful so that we can find the module to use, given an + attribute. + """ + attribute_modules = {} + + for module, attributes in list(module_attributes.items()): + for attribute in attributes: + attribute_modules[attribute] = module + + return attribute_modules + + def _load_attribute(self, attribute): + """ + Load the class attribute if it hasn't been loaded yet, and return it. + """ + module = self._attribute_modules.get(attribute, False) + + if not module: + # This shouldn't happen. The check happens in find_modules, too. + raise ImportError(attribute) + elif module not in self._loaded_modules: + # Note that it's very important to only load a given modules once. + # If they are loaded more than once, the memory reference to the + # class objects changes, and Python thinks that an object of type + # Foo that was declared before Foo's module was reloaded is no + # longer the same type as Foo after it's reloaded. + path = os.path.realpath(self._parent_module.__file__) + folder = os.path.dirname(path) + f, filename, description = imp.find_module(module, [folder]) + self._loaded_modules[module] = imp.load_module(module, f, filename, description) + + # This functionality is deprecated, and AirflowImporter should be + # removed in 2.0. + warnings.warn( + "Importing {i} directly from {m} has been " + "deprecated. Please import from " + "'{m}.[operator_module]' instead. Support for direct " + "imports will be dropped entirely in Airflow 2.0.".format( + i=attribute, m=self._parent_module), + DeprecationWarning) + + loaded_module = self._loaded_modules[module] + + return getattr(loaded_module, attribute) + + def __getattr__(self, attribute): + """ + Get an attribute from the wrapped module. If the attribute doesn't + exist, try and import it as a class from a submodule. + + This is a Python trick that allows the class to pretend it's a module, + so that attribute-based usage works: + + from airflow import operators + operators.BashOperator(...) + + It also allows normal from imports to work: + + from airflow.operators.bash_operator import BashOperator + """ + if hasattr(self._parent_module, attribute): + # Always default to the parent module if the attribute exists. + return getattr(self._parent_module, attribute) + elif attribute in self._attribute_modules: + # Try and import the attribute if it's got a module defined. + loaded_attribute = self._load_attribute(attribute) + setattr(self, attribute, loaded_attribute) + return loaded_attribute + + raise AttributeError diff --git a/airflow/utils/json.py b/airflow/utils/json.py new file mode 100644 index 0000000000000..5984ac6565c30 --- /dev/null +++ b/airflow/utils/json.py @@ -0,0 +1,55 @@ +# -*- coding: utf-8 -*- +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +from __future__ import absolute_import +from __future__ import division +from __future__ import print_function +from __future__ import unicode_literals + +from datetime import datetime, date +import json +import numpy as np + + +# Dates and JSON encoding/decoding + +def json_ser(obj): + """json serializer that deals with dates. + + usage: json.dumps(object, default=utils.json.json_ser) + """ + if isinstance(obj, (datetime, date)): + return obj.isoformat() + + +class AirflowJsonEncoder(json.JSONEncoder): + + def default(self, obj): + # convert dates and numpy objects in a json serializable format + if isinstance(obj, datetime): + return obj.strftime('%Y-%m-%dT%H:%M:%SZ') + elif isinstance(obj, date): + return obj.strftime('%Y-%m-%d') + elif type(obj) in (np.int_, np.intc, np.intp, np.int8, np.int16, + np.int32, np.int64, np.uint8, np.uint16, + np.uint32, np.uint64): + return int(obj) + elif type(obj) in (np.bool_,): + return bool(obj) + elif type(obj) in (np.float_, np.float16, np.float32, np.float64, + np.complex_, np.complex64, np.complex128): + return float(obj) + + # Let the base class default method raise the TypeError + return json.JSONEncoder.default(self, obj) diff --git a/airflow/utils/log/__init__.py b/airflow/utils/log/__init__.py new file mode 100644 index 0000000000000..9d7677a99b293 --- /dev/null +++ b/airflow/utils/log/__init__.py @@ -0,0 +1,13 @@ +# -*- coding: utf-8 -*- +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. diff --git a/airflow/utils/log/file_task_handler.py b/airflow/utils/log/file_task_handler.py new file mode 100644 index 0000000000000..6038fbfae936b --- /dev/null +++ b/airflow/utils/log/file_task_handler.py @@ -0,0 +1,195 @@ +# -*- coding: utf-8 -*- +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import logging +import os +import requests + +from jinja2 import Template + +from airflow import configuration as conf +from airflow.configuration import AirflowConfigException +from airflow.utils.file import mkdirs + + +class FileTaskHandler(logging.Handler): + """ + FileTaskHandler is a python log handler that handles and reads + task instance logs. It creates and delegates log handling + to `logging.FileHandler` after receiving task instance context. + It reads logs from task instance's host machine. + """ + + def __init__(self, base_log_folder, filename_template): + """ + :param base_log_folder: Base log folder to place logs. + :param filename_template: template filename string + """ + super(FileTaskHandler, self).__init__() + self.handler = None + self.local_base = base_log_folder + self.filename_template = filename_template + self.filename_jinja_template = None + + if "{{" in self.filename_template: #jinja mode + self.filename_jinja_template = Template(self.filename_template) + + def set_context(self, ti): + """ + Provide task_instance context to airflow task handler. + :param ti: task instance object + """ + local_loc = self._init_file(ti) + self.handler = logging.FileHandler(local_loc) + self.handler.setFormatter(self.formatter) + self.handler.setLevel(self.level) + + def emit(self, record): + if self.handler is not None: + self.handler.emit(record) + + def flush(self): + if self.handler is not None: + self.handler.flush() + + def close(self): + if self.handler is not None: + self.handler.close() + + def _render_filename(self, ti, try_number): + if self.filename_jinja_template: + jinja_context = ti.get_template_context() + jinja_context['try_number'] = try_number + return self.filename_jinja_template.render(**jinja_context) + + return self.filename_template.format(dag_id=ti.dag_id, + task_id=ti.task_id, + execution_date=ti.execution_date.isoformat(), + try_number=try_number) + + def _read(self, ti, try_number): + """ + Template method that contains custom logic of reading + logs given the try_number. + :param ti: task instance record + :param try_number: current try_number to read log from + :return: log message as a string + """ + # Task instance here might be different from task instance when + # initializing the handler. Thus explicitly getting log location + # is needed to get correct log path. + log_relative_path = self._render_filename(ti, try_number + 1) + location = os.path.join(self.local_base, log_relative_path) + + log = "" + + if os.path.exists(location): + try: + with open(location) as f: + log += "*** Reading local log.\n" + "".join(f.readlines()) + except Exception as e: + log = "*** Failed to load local log file: {}. {}\n".format(location, str(e)) + else: + url = os.path.join( + "http://{ti.hostname}:{worker_log_server_port}/log", log_relative_path + ).format( + ti=ti, + worker_log_server_port=conf.get('celery', 'WORKER_LOG_SERVER_PORT') + ) + log += "*** Log file isn't local.\n" + log += "*** Fetching here: {url}\n".format(**locals()) + try: + timeout = None # No timeout + try: + timeout = conf.getint('webserver', 'log_fetch_timeout_sec') + except (AirflowConfigException, ValueError): + pass + + response = requests.get(url, timeout=timeout) + + # Check if the resource was properly fetched + response.raise_for_status() + + log += '\n' + response.text + except Exception as e: + log += "*** Failed to fetch log file from worker. {}\n".format(str(e)) + + return log + + def read(self, task_instance, try_number=None): + """ + Read logs of given task instance from local machine. + :param task_instance: task instance object + :param try_number: task instance try_number to read logs from. If None + it returns all logs separated by try_number + :return: a list of logs + """ + # Task instance increments its try number when it starts to run. + # So the log for a particular task try will only show up when + # try number gets incremented in DB, i.e logs produced the time + # after cli run and before try_number + 1 in DB will not be displayed. + next_try = task_instance.try_number + + if try_number is None: + try_numbers = list(range(next_try)) + elif try_number < 0: + logs = ['Error fetching the logs. Try number {} is invalid.'.format(try_number)] + return logs + else: + try_numbers = [try_number] + + logs = [''] * len(try_numbers) + for i, try_number in enumerate(try_numbers): + logs[i] += self._read(task_instance, try_number) + + return logs + + def _init_file(self, ti): + """ + Create log directory and give it correct permissions. + :param ti: task instance object + :return relative log path of the given task instance + """ + # To handle log writing when tasks are impersonated, the log files need to + # be writable by the user that runs the Airflow command and the user + # that is impersonated. This is mainly to handle corner cases with the + # SubDagOperator. When the SubDagOperator is run, all of the operators + # run under the impersonated user and create appropriate log files + # as the impersonated user. However, if the user manually runs tasks + # of the SubDagOperator through the UI, then the log files are created + # by the user that runs the Airflow command. For example, the Airflow + # run command may be run by the `airflow_sudoable` user, but the Airflow + # tasks may be run by the `airflow` user. If the log files are not + # writable by both users, then it's possible that re-running a task + # via the UI (or vice versa) results in a permission error as the task + # tries to write to a log file created by the other user. + relative_path = self._render_filename(ti, ti.try_number + 1) + full_path = os.path.join(self.local_base, relative_path) + directory = os.path.dirname(full_path) + # Create the log file and give it group writable permissions + # TODO(aoen): Make log dirs and logs globally readable for now since the SubDag + # operator is not compatible with impersonation (e.g. if a Celery executor is used + # for a SubDag operator and the SubDag operator has a different owner than the + # parent DAG) + if not os.path.exists(directory): + # Create the directory as globally writable using custom mkdirs + # as os.makedirs doesn't set mode properly. + mkdirs(directory, 0o775) + + if not os.path.exists(full_path): + open(full_path, "a").close() + # TODO: Investigate using 444 instead of 666. + os.chmod(full_path, 0o666) + + return full_path diff --git a/airflow/utils/log/gcs_task_handler.py b/airflow/utils/log/gcs_task_handler.py new file mode 100644 index 0000000000000..c11e7adac76db --- /dev/null +++ b/airflow/utils/log/gcs_task_handler.py @@ -0,0 +1,191 @@ +# -*- coding: utf-8 -*- +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +import os + +from airflow import configuration +from airflow.exceptions import AirflowException +from airflow.utils.log.logging_mixin import LoggingMixin +from airflow.utils.log.file_task_handler import FileTaskHandler + + +class GCSTaskHandler(FileTaskHandler, LoggingMixin): + """ + GCSTaskHandler is a python log handler that handles and reads + task instance logs. It extends airflow FileTaskHandler and + uploads to and reads from GCS remote storage. Upon log reading + failure, it reads from host machine's local disk. + """ + def __init__(self, base_log_folder, gcs_log_folder, filename_template): + super(GCSTaskHandler, self).__init__(base_log_folder, filename_template) + self.remote_base = gcs_log_folder + self.log_relative_path = '' + self._hook = None + self.closed = False + + def _build_hook(self): + remote_conn_id = configuration.get('core', 'REMOTE_LOG_CONN_ID') + try: + from airflow.contrib.hooks.gcs_hook import GoogleCloudStorageHook + return GoogleCloudStorageHook( + google_cloud_storage_conn_id=remote_conn_id + ) + except: + self.log.error( + 'Could not create a GoogleCloudStorageHook with connection id ' + '"%s". Please make sure that airflow[gcp_api] is installed ' + 'and the GCS connection exists.', remote_conn_id + ) + + @property + def hook(self): + if self._hook is None: + self._hook = self._build_hook() + return self._hook + + def set_context(self, ti): + super(GCSTaskHandler, self).set_context(ti) + # Log relative path is used to construct local and remote + # log path to upload log files into GCS and read from the + # remote location. + self.log_relative_path = self._render_filename(ti, ti.try_number + 1) + + def close(self): + """ + Close and upload local log file to remote storage S3. + """ + # When application exit, system shuts down all handlers by + # calling close method. Here we check if logger is already + # closed to prevent uploading the log to remote storage multiple + # times when `logging.shutdown` is called. + if self.closed: + return + + super(GCSTaskHandler, self).close() + + local_loc = os.path.join(self.local_base, self.log_relative_path) + remote_loc = os.path.join(self.remote_base, self.log_relative_path) + if os.path.exists(local_loc): + # read log and remove old logs to get just the latest additions + with open(local_loc, 'r') as logfile: + log = logfile.read() + self.gcs_write(log, remote_loc) + + # Mark closed so we don't double write if close is called twice + self.closed = True + + def _read(self, ti, try_number): + """ + Read logs of given task instance and try_number from GCS. + If failed, read the log from task instance host machine. + :param ti: task instance object + :param try_number: task instance try_number to read logs from + """ + # Explicitly getting log relative path is necessary as the given + # task instance might be different than task instance passed in + # in set_context method. + log_relative_path = self._render_filename(ti, try_number + 1) + remote_loc = os.path.join(self.remote_base, log_relative_path) + + if self.gcs_log_exists(remote_loc): + # If GCS remote file exists, we do not fetch logs from task instance + # local machine even if there are errors reading remote logs, as + # remote_log will contain error message. + remote_log = self.gcs_read(remote_loc, return_error=True) + log = '*** Reading remote log from {}.\n{}\n'.format( + remote_loc, remote_log) + else: + log = super(GCSTaskHandler, self)._read(ti, try_number) + + return log + + def gcs_log_exists(self, remote_log_location): + """ + Check if remote_log_location exists in remote storage + :param remote_log_location: log's location in remote storage + :return: True if location exists else False + """ + try: + bkt, blob = self.parse_gcs_url(remote_log_location) + return self.hook.exists(bkt, blob) + except Exception: + pass + return False + + def gcs_read(self, remote_log_location, return_error=False): + """ + Returns the log found at the remote_log_location. + :param remote_log_location: the log's location in remote storage + :type remote_log_location: string (path) + :param return_error: if True, returns a string error message if an + error occurs. Otherwise returns '' when an error occurs. + :type return_error: bool + """ + try: + bkt, blob = self.parse_gcs_url(remote_log_location) + return self.hook.download(bkt, blob).decode() + except: + # return error if needed + if return_error: + msg = 'Could not read logs from {}'.format(remote_log_location) + self.log.error(msg) + return msg + + def gcs_write(self, log, remote_log_location, append=True): + """ + Writes the log to the remote_log_location. Fails silently if no hook + was created. + :param log: the log to write to the remote_log_location + :type log: string + :param remote_log_location: the log's location in remote storage + :type remote_log_location: string (path) + :param append: if False, any existing log file is overwritten. If True, + the new log is appended to any existing logs. + :type append: bool + """ + if append: + old_log = self.gcs_read(remote_log_location) + log = '\n'.join([old_log, log]) if old_log else log + + try: + bkt, blob = self.parse_gcs_url(remote_log_location) + from tempfile import NamedTemporaryFile + with NamedTemporaryFile(mode='w+') as tmpfile: + tmpfile.write(log) + # Force the file to be flushed, since we're doing the + # upload from within the file context (it hasn't been + # closed). + tmpfile.flush() + self.hook.upload(bkt, blob, tmpfile.name) + except Exception as e: + self.log.error('Could not write logs to %s: %s', remote_log_location, e) + + def parse_gcs_url(self, gsurl): + """ + Given a Google Cloud Storage URL (gs:///), returns a + tuple containing the corresponding bucket and blob. + """ + # Python 3 + try: + from urllib.parse import urlparse + # Python 2 + except ImportError: + from urlparse import urlparse + + parsed_url = urlparse(gsurl) + if not parsed_url.netloc: + raise AirflowException('Please provide a bucket name') + else: + bucket = parsed_url.netloc + blob = parsed_url.path.strip('/') + return bucket, blob diff --git a/airflow/utils/log/logging_mixin.py b/airflow/utils/log/logging_mixin.py new file mode 100644 index 0000000000000..a3aad5b33580b --- /dev/null +++ b/airflow/utils/log/logging_mixin.py @@ -0,0 +1,61 @@ +# -*- coding: utf-8 -*- +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +from __future__ import absolute_import +from __future__ import division +from __future__ import print_function +from __future__ import unicode_literals + +import logging +import warnings +from builtins import object + + +class LoggingMixin(object): + """ + Convenience super-class to have a logger configured with the class name + """ + + # We want to deprecate the logger property in Airflow 2.0 + # The log property is the de facto standard in most programming languages + @property + def logger(self): + warnings.warn( + 'Initializing logger for {} using logger(), which will ' + 'be replaced by .log in Airflow 2.0'.format( + self.__class__.__module__ + '.' + self.__class__.__name__ + ), + DeprecationWarning + ) + return self.log + + @property + def log(self): + try: + return self._log + except AttributeError: + self._log = logging.root.getChild( + self.__class__.__module__ + '.' + self.__class__.__name__ + ) + return self._log + + def set_log_contexts(self, task_instance): + """ + Set the context for all handlers of current logger. + """ + for handler in self.log.handlers: + try: + handler.set_context(task_instance) + except AttributeError: + pass diff --git a/airflow/utils/log/s3_task_handler.py b/airflow/utils/log/s3_task_handler.py new file mode 100644 index 0000000000000..1e56655d69e48 --- /dev/null +++ b/airflow/utils/log/s3_task_handler.py @@ -0,0 +1,164 @@ +# -*- coding: utf-8 -*- +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +import os + +from airflow import configuration +from airflow.utils.log.logging_mixin import LoggingMixin +from airflow.utils.log.file_task_handler import FileTaskHandler + + +class S3TaskHandler(FileTaskHandler, LoggingMixin): + """ + S3TaskHandler is a python log handler that handles and reads + task instance logs. It extends airflow FileTaskHandler and + uploads to and reads from S3 remote storage. + """ + def __init__(self, base_log_folder, s3_log_folder, filename_template): + super(S3TaskHandler, self).__init__(base_log_folder, filename_template) + self.remote_base = s3_log_folder + self.log_relative_path = '' + self._hook = None + self.closed = False + + def _build_hook(self): + remote_conn_id = configuration.get('core', 'REMOTE_LOG_CONN_ID') + try: + from airflow.hooks.S3_hook import S3Hook + return S3Hook(remote_conn_id) + except: + self.log.error( + 'Could not create an S3Hook with connection id "%s". ' + 'Please make sure that airflow[s3] is installed and ' + 'the S3 connection exists.', remote_conn_id + ) + + @property + def hook(self): + if self._hook is None: + self._hook = self._build_hook() + return self._hook + + def set_context(self, ti): + super(S3TaskHandler, self).set_context(ti) + # Local location and remote location is needed to open and + # upload local log file to S3 remote storage. + self.log_relative_path = self._render_filename(ti, ti.try_number + 1) + + def close(self): + """ + Close and upload local log file to remote storage S3. + """ + # When application exit, system shuts down all handlers by + # calling close method. Here we check if logger is already + # closed to prevent uploading the log to remote storage multiple + # times when `logging.shutdown` is called. + if self.closed: + return + + super(S3TaskHandler, self).close() + + local_loc = os.path.join(self.local_base, self.log_relative_path) + remote_loc = os.path.join(self.remote_base, self.log_relative_path) + if os.path.exists(local_loc): + # read log and remove old logs to get just the latest additions + with open(local_loc, 'r') as logfile: + log = logfile.read() + self.s3_write(log, remote_loc) + + # Mark closed so we don't double write if close is called twice + self.closed = True + + def _read(self, ti, try_number): + """ + Read logs of given task instance and try_number from S3 remote storage. + If failed, read the log from task instance host machine. + :param ti: task instance object + :param try_number: task instance try_number to read logs from + """ + # Explicitly getting log relative path is necessary as the given + # task instance might be different than task instance passed in + # in set_context method. + log_relative_path = self._render_filename(ti, try_number + 1) + remote_loc = os.path.join(self.remote_base, log_relative_path) + + if self.s3_log_exists(remote_loc): + # If S3 remote file exists, we do not fetch logs from task instance + # local machine even if there are errors reading remote logs, as + # returned remote_log will contain error messages. + remote_log = self.s3_read(remote_loc, return_error=True) + log = '*** Reading remote log from {}.\n{}\n'.format( + remote_loc, remote_log) + else: + log = super(S3TaskHandler, self)._read(ti, try_number) + + return log + + def s3_log_exists(self, remote_log_location): + """ + Check if remote_log_location exists in remote storage + :param remote_log_location: log's location in remote storage + :return: True if location exists else False + """ + try: + return self.hook.get_key(remote_log_location) is not None + except Exception: + pass + return False + + def s3_read(self, remote_log_location, return_error=False): + """ + Returns the log found at the remote_log_location. Returns '' if no + logs are found or there is an error. + :param remote_log_location: the log's location in remote storage + :type remote_log_location: string (path) + :param return_error: if True, returns a string error message if an + error occurs. Otherwise returns '' when an error occurs. + :type return_error: bool + """ + try: + s3_key = self.hook.get_key(remote_log_location) + if s3_key: + return s3_key.get_contents_as_string().decode() + except: + # return error if needed + if return_error: + msg = 'Could not read logs from {}'.format(remote_log_location) + self.log.error(msg) + return msg + + def s3_write(self, log, remote_log_location, append=True): + """ + Writes the log to the remote_log_location. Fails silently if no hook + was created. + :param log: the log to write to the remote_log_location + :type log: string + :param remote_log_location: the log's location in remote storage + :type remote_log_location: string (path) + :param append: if False, any existing log file is overwritten. If True, + the new log is appended to any existing logs. + :type append: bool + """ + if append: + old_log = self.s3_read(remote_log_location) + log = '\n'.join([old_log, log]) if old_log else log + + try: + self.hook.load_string( + log, + key=remote_log_location, + replace=True, + encrypt=configuration.getboolean('core', 'ENCRYPT_S3_LOGS'), + ) + except: + self.log.error('Could not write logs to %s', remote_log_location) diff --git a/airflow/utils/module_loading.py b/airflow/utils/module_loading.py new file mode 100644 index 0000000000000..f2138eaff2fa4 --- /dev/null +++ b/airflow/utils/module_loading.py @@ -0,0 +1,35 @@ +# -*- coding: utf-8 -*- +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from importlib import import_module + + +def import_string(dotted_path): + """ + Import a dotted module path and return the attribute/class designated by the + last name in the path. Raise ImportError if the import failed. + """ + try: + module_path, class_name = dotted_path.rsplit('.', 1) + except ValueError: + raise ImportError("{} doesn't look like a module path".format(dotted_path)) + + module = import_module(module_path) + + try: + return getattr(module, class_name) + except AttributeError as err: + raise ImportError('Module "{}" does not define a "{}" attribute/class'.format( + module_path, class_name) + ) diff --git a/airflow/utils/operator_helpers.py b/airflow/utils/operator_helpers.py new file mode 100644 index 0000000000000..fb9f74a326bec --- /dev/null +++ b/airflow/utils/operator_helpers.py @@ -0,0 +1,44 @@ +# -*- coding: utf-8 -*- +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + + +def context_to_airflow_vars(context): + """ + Given a context, this function provides a dictionary of values that can be used to + externally reconstruct relations between dags, dag_runs, tasks and task_instances. + + :param context: The context for the task_instance of interest + :type context: dict + """ + params = {} + dag = context.get('dag') + if dag and dag.dag_id: + params['airflow.ctx.dag.dag_id'] = dag.dag_id + + dag_run = context.get('dag_run') + if dag_run and dag_run.execution_date: + params['airflow.ctx.dag_run.execution_date'] = dag_run.execution_date.isoformat() + + task = context.get('task') + if task and task.task_id: + params['airflow.ctx.task.task_id'] = task.task_id + + task_instance = context.get('task_instance') + if task_instance and task_instance.execution_date: + params['airflow.ctx.task_instance.execution_date'] = ( + task_instance.execution_date.isoformat() + ) + + return params diff --git a/airflow/utils/operator_resources.py b/airflow/utils/operator_resources.py new file mode 100644 index 0000000000000..d304170fdad3f --- /dev/null +++ b/airflow/utils/operator_resources.py @@ -0,0 +1,121 @@ +# -*- coding: utf-8 -*- +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from airflow import configuration +from airflow.exceptions import AirflowException + +# Constants for resources (megabytes are the base unit) +MB = 1 +GB = 1024 * MB +TB = 1024 * GB +PB = 1024 * TB +EB = 1024 * PB + + +class Resource(object): + """ + Represents a resource requirement in an execution environment for an operator. + + :param name: Name of the resource + :type name: string + :param units_str: The string representing the units of a resource (e.g. MB for a CPU + resource) to be used for display purposes + :type units_str: string + :param qty: The number of units of the specified resource that are required for + execution of the operator. + :type qty: long + """ + def __init__(self, name, units_str, qty): + if qty < 0: + raise AirflowException( + 'Received resource quantity {} for resource {} but resource quantity ' + 'must be non-negative.'.format(qty, name)) + + self._name = name + self._units_str = units_str + self._qty = qty + + def __eq__(self, other): + return self.__dict__ == other.__dict__ + + def __repr__(self): + return str(self.__dict__) + + @property + def name(self): + return self._name + + @property + def units_str(self): + return self._units_str + + @property + def qty(self): + return self._qty + + +class CpuResource(Resource): + def __init__(self, qty): + super(CpuResource, self).__init__('CPU', 'core(s)', qty) + + +class RamResource(Resource): + def __init__(self, qty): + super(RamResource, self).__init__('RAM', 'MB', qty) + + +class DiskResource(Resource): + def __init__(self, qty): + super(DiskResource, self).__init__('Disk', 'MB', qty) + + +class GpuResource(Resource): + def __init__(self, qty): + super(GpuResource, self).__init__('GPU', 'gpu(s)', qty) + + +class Resources(object): + """ + The resources required by an operator. Resources that are not specified will use the + default values from the airflow config. + + :param cpus: The number of cpu cores that are required + :type cpus: long + :param ram: The amount of RAM required + :type ram: long + :param disk: The amount of disk space required + :type disk: long + :param gpus: The number of gpu units that are required + :type gpus: long + """ + def __init__(self, cpus=None, ram=None, disk=None, gpus=None): + if cpus is None: + cpus = configuration.getint('operators', 'default_cpus') + if ram is None: + ram = configuration.getint('operators', 'default_ram') + if disk is None: + disk = configuration.getint('operators', 'default_disk') + if gpus is None: + gpus = configuration.getint('operators', 'default_gpus') + + self.cpus = CpuResource(cpus) + self.ram = RamResource(ram) + self.disk = DiskResource(disk) + self.gpus = GpuResource(gpus) + + def __eq__(self, other): + return self.__dict__ == other.__dict__ + + def __repr__(self): + return str(self.__dict__) diff --git a/airflow/utils/state.py b/airflow/utils/state.py new file mode 100644 index 0000000000000..4a1dfb620aa87 --- /dev/null +++ b/airflow/utils/state.py @@ -0,0 +1,113 @@ +# -*- coding: utf-8 -*- +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +from __future__ import unicode_literals + +from builtins import object + + +class State(object): + """ + Static class with task instance states constants and color method to + avoid hardcoding. + """ + + # scheduler + NONE = None + REMOVED = "removed" + SCHEDULED = "scheduled" + + # set by the executor (t.b.d.) + # LAUNCHED = "launched" + + # set by a task + QUEUED = "queued" + RUNNING = "running" + SUCCESS = "success" + SHUTDOWN = "shutdown" # External request to shut down + FAILED = "failed" + UP_FOR_RETRY = "up_for_retry" + UPSTREAM_FAILED = "upstream_failed" + SKIPPED = "skipped" + + task_states = ( + SUCCESS, + RUNNING, + FAILED, + UPSTREAM_FAILED, + UP_FOR_RETRY, + QUEUED, + ) + + dag_states = ( + SUCCESS, + RUNNING, + FAILED, + ) + + state_color = { + QUEUED: 'gray', + RUNNING: 'lime', + SUCCESS: 'green', + SHUTDOWN: 'blue', + FAILED: 'red', + UP_FOR_RETRY: 'gold', + UPSTREAM_FAILED: 'orange', + SKIPPED: 'pink', + REMOVED: 'lightgrey', + SCHEDULED: 'white', + } + + @classmethod + def color(cls, state): + if state in cls.state_color: + return cls.state_color[state] + else: + return 'white' + + @classmethod + def color_fg(cls, state): + color = cls.color(state) + if color in ['green', 'red']: + return 'white' + else: + return 'black' + + @classmethod + def finished(cls): + """ + A list of states indicating that a task started and completed a + run attempt. Note that the attempt could have resulted in failure or + have been interrupted; in any case, it is no longer running. + """ + return [ + cls.SUCCESS, + cls.SHUTDOWN, + cls.FAILED, + cls.SKIPPED, + ] + + @classmethod + def unfinished(cls): + """ + A list of states indicating that a task either has not completed + a run or has not even started. + """ + return [ + cls.NONE, + cls.SCHEDULED, + cls.QUEUED, + cls.RUNNING, + cls.UP_FOR_RETRY + ] diff --git a/airflow/utils/tests.py b/airflow/utils/tests.py new file mode 100644 index 0000000000000..83db6e86e3e4d --- /dev/null +++ b/airflow/utils/tests.py @@ -0,0 +1,26 @@ +# -*- coding: utf-8 -*- +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import unittest + +def skipUnlessImported(module, obj): + import importlib + try: + m = importlib.import_module(module) + except ImportError: + m = None + return unittest.skipUnless( + obj in dir(m), + "Skipping test because {} could not be imported from {}".format( + obj, module)) diff --git a/airflow/utils/timeout.py b/airflow/utils/timeout.py new file mode 100644 index 0000000000000..e0b3f9612b723 --- /dev/null +++ b/airflow/utils/timeout.py @@ -0,0 +1,52 @@ +# -*- coding: utf-8 -*- +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +from __future__ import absolute_import +from __future__ import division +from __future__ import print_function +from __future__ import unicode_literals + +import signal + +from airflow.exceptions import AirflowTaskTimeout +from airflow.utils.log.logging_mixin import LoggingMixin + + +class timeout(LoggingMixin): + """ + To be used in a ``with`` block and timeout its content. + """ + + def __init__(self, seconds=1, error_message='Timeout'): + self.seconds = seconds + self.error_message = error_message + + def handle_timeout(self, signum, frame): + self.log.error("Process timed out") + raise AirflowTaskTimeout(self.error_message) + + def __enter__(self): + try: + signal.signal(signal.SIGALRM, self.handle_timeout) + signal.alarm(self.seconds) + except ValueError as e: + self.log.warning("timeout can't be used in the current context") + self.log.exception(e) + + def __exit__(self, type, value, traceback): + try: + signal.alarm(0) + except ValueError as e: + self.log.warning("timeout can't be used in the current context") + self.log.exception(e) diff --git a/airflow/utils/trigger_rule.py b/airflow/utils/trigger_rule.py new file mode 100644 index 0000000000000..f8309090f75ba --- /dev/null +++ b/airflow/utils/trigger_rule.py @@ -0,0 +1,36 @@ +# -*- coding: utf-8 -*- +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +from __future__ import unicode_literals + +from builtins import object + + +class TriggerRule(object): + ALL_SUCCESS = 'all_success' + ALL_FAILED = 'all_failed' + ALL_DONE = 'all_done' + ONE_SUCCESS = 'one_success' + ONE_FAILED = 'one_failed' + DUMMY = 'dummy' + + @classmethod + def is_valid(cls, trigger_rule): + return trigger_rule in cls.all_triggers() + + @classmethod + def all_triggers(cls): + return [getattr(cls, attr) + for attr in dir(cls) + if not attr.startswith("__") and not callable(getattr(cls, attr))] diff --git a/airflow/version.py b/airflow/version.py new file mode 100644 index 0000000000000..55e8b9f8ba000 --- /dev/null +++ b/airflow/version.py @@ -0,0 +1,16 @@ +# -*- coding: utf-8 -*- +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +version = '1.10.0dev0+incubating' diff --git a/airflow/www/__init__.py b/airflow/www/__init__.py new file mode 100644 index 0000000000000..c82f5790fe906 --- /dev/null +++ b/airflow/www/__init__.py @@ -0,0 +1,14 @@ +# -*- coding: utf-8 -*- +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + diff --git a/airflow/www/api/__init__.py b/airflow/www/api/__init__.py new file mode 100644 index 0000000000000..759b563511c1c --- /dev/null +++ b/airflow/www/api/__init__.py @@ -0,0 +1,14 @@ +# -*- coding: utf-8 -*- +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# diff --git a/airflow/www/api/experimental/__init__.py b/airflow/www/api/experimental/__init__.py new file mode 100644 index 0000000000000..759b563511c1c --- /dev/null +++ b/airflow/www/api/experimental/__init__.py @@ -0,0 +1,14 @@ +# -*- coding: utf-8 -*- +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# diff --git a/airflow/www/api/experimental/endpoints.py b/airflow/www/api/experimental/endpoints.py new file mode 100644 index 0000000000000..b5a30524ccd8a --- /dev/null +++ b/airflow/www/api/experimental/endpoints.py @@ -0,0 +1,234 @@ +# -*- coding: utf-8 -*- +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +import airflow.api + +from airflow.api.common.experimental import pool as pool_api +from airflow.api.common.experimental import trigger_dag as trigger +from airflow.api.common.experimental.get_task import get_task +from airflow.api.common.experimental.get_task_instance import get_task_instance +from airflow.exceptions import AirflowException +from airflow.utils.log.logging_mixin import LoggingMixin +from airflow.www.app import csrf + +from flask import ( + g, Markup, Blueprint, redirect, jsonify, abort, + request, current_app, send_file, url_for +) +from datetime import datetime + +_log = LoggingMixin().log + +requires_authentication = airflow.api.api_auth.requires_authentication + +api_experimental = Blueprint('api_experimental', __name__) + + +@csrf.exempt +@api_experimental.route('/dags//dag_runs', methods=['POST']) +@requires_authentication +def trigger_dag(dag_id): + """ + Trigger a new dag run for a Dag with an execution date of now unless + specified in the data. + """ + data = request.get_json(force=True) + + run_id = None + if 'run_id' in data: + run_id = data['run_id'] + + conf = None + if 'conf' in data: + conf = data['conf'] + + execution_date = None + if 'execution_date' in data and data['execution_date'] is not None: + execution_date = data['execution_date'] + + # Convert string datetime into actual datetime + try: + execution_date = datetime.strptime(execution_date, + '%Y-%m-%dT%H:%M:%S') + except ValueError: + error_message = ( + 'Given execution date, {}, could not be identified ' + 'as a date. Example date format: 2015-11-16T14:34:15' + .format(execution_date)) + _log.info(error_message) + response = jsonify({'error': error_message}) + response.status_code = 400 + + return response + + try: + dr = trigger.trigger_dag(dag_id, run_id, conf, execution_date) + except AirflowException as err: + _log.error(err) + response = jsonify(error="{}".format(err)) + response.status_code = 404 + return response + + if getattr(g, 'user', None): + _log.info("User {} created {}".format(g.user, dr)) + + response = jsonify(message="Created {}".format(dr)) + return response + + +@api_experimental.route('/test', methods=['GET']) +@requires_authentication +def test(): + return jsonify(status='OK') + + +@api_experimental.route('/dags//tasks/', methods=['GET']) +@requires_authentication +def task_info(dag_id, task_id): + """Returns a JSON with a task's public instance variables. """ + try: + info = get_task(dag_id, task_id) + except AirflowException as err: + _log.info(err) + response = jsonify(error="{}".format(err)) + response.status_code = 404 + return response + + # JSONify and return. + fields = {k: str(v) + for k, v in vars(info).items() + if not k.startswith('_')} + return jsonify(fields) + + +@api_experimental.route('/dags//dag_runs//tasks/', methods=['GET']) +@requires_authentication +def task_instance_info(dag_id, execution_date, task_id): + """ + Returns a JSON with a task instance's public instance variables. + The format for the exec_date is expected to be + "YYYY-mm-DDTHH:MM:SS", for example: "2016-11-16T11:34:15". This will + of course need to have been encoded for URL in the request. + """ + + # Convert string datetime into actual datetime + try: + execution_date = datetime.strptime(execution_date, + '%Y-%m-%dT%H:%M:%S') + except ValueError: + error_message = ( + 'Given execution date, {}, could not be identified ' + 'as a date. Example date format: 2015-11-16T14:34:15' + .format(execution_date)) + _log.info(error_message) + response = jsonify({'error': error_message}) + response.status_code = 400 + + return response + + try: + info = get_task_instance(dag_id, task_id, execution_date) + except AirflowException as err: + _log.info(err) + response = jsonify(error="{}".format(err)) + response.status_code = 404 + return response + + # JSONify and return. + fields = {k: str(v) + for k, v in vars(info).items() + if not k.startswith('_')} + return jsonify(fields) + + +@api_experimental.route('/latest_runs', methods=['GET']) +@requires_authentication +def latest_dag_runs(): + """Returns the latest DagRun for each DAG formatted for the UI. """ + from airflow.models import DagRun + dagruns = DagRun.get_latest_runs() + payload = [] + for dagrun in dagruns: + if dagrun.execution_date: + payload.append({ + 'dag_id': dagrun.dag_id, + 'execution_date': dagrun.execution_date.strftime("%Y-%m-%d %H:%M"), + 'start_date': ((dagrun.start_date or '') and + dagrun.start_date.strftime("%Y-%m-%d %H:%M")), + 'dag_run_url': url_for('airflow.graph', dag_id=dagrun.dag_id, + execution_date=dagrun.execution_date) + }) + return jsonify(items=payload) # old flask versions dont support jsonifying arrays + + +@api_experimental.route('/pools/', methods=['GET']) +@requires_authentication +def get_pool(name): + """Get pool by a given name.""" + try: + pool = pool_api.get_pool(name=name) + except AirflowException as e: + _log.error(e) + response = jsonify(error="{}".format(e)) + response.status_code = getattr(e, 'status', 500) + return response + else: + return jsonify(pool.to_json()) + + +@api_experimental.route('/pools', methods=['GET']) +@requires_authentication +def get_pools(): + """Get all pools.""" + try: + pools = pool_api.get_pools() + except AirflowException as e: + _log.error(e) + response = jsonify(error="{}".format(e)) + response.status_code = getattr(e, 'status', 500) + return response + else: + return jsonify([p.to_json() for p in pools]) + + +@csrf.exempt +@api_experimental.route('/pools', methods=['POST']) +@requires_authentication +def create_pool(): + """Create a pool.""" + params = request.get_json(force=True) + try: + pool = pool_api.create_pool(**params) + except AirflowException as e: + _log.error(e) + response = jsonify(error="{}".format(e)) + response.status_code = getattr(e, 'status', 500) + return response + else: + return jsonify(pool.to_json()) + + +@csrf.exempt +@api_experimental.route('/pools/', methods=['DELETE']) +@requires_authentication +def delete_pool(name): + """Delete pool.""" + try: + pool = pool_api.delete_pool(name=name) + except AirflowException as e: + _log.error(e) + response = jsonify(error="{}".format(e)) + response.status_code = getattr(e, 'status', 500) + return response + else: + return jsonify(pool.to_json()) diff --git a/airflow/www/app.py b/airflow/www/app.py new file mode 100644 index 0000000000000..dfdc04c0f7cad --- /dev/null +++ b/airflow/www/app.py @@ -0,0 +1,165 @@ +# -*- coding: utf-8 -*- +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +import socket +import six + +from flask import Flask +from flask_admin import Admin, base +from flask_cache import Cache +from flask_wtf.csrf import CSRFProtect +csrf = CSRFProtect() + +import airflow +from airflow import configuration as conf +from airflow import models, LoggingMixin +from airflow.settings import Session + +from airflow.www.blueprints import routes +from airflow.logging_config import configure_logging +from airflow import jobs +from airflow import settings +from airflow import configuration + + +def create_app(config=None, testing=False): + app = Flask(__name__) + app.secret_key = configuration.get('webserver', 'SECRET_KEY') + app.config['LOGIN_DISABLED'] = not configuration.getboolean('webserver', 'AUTHENTICATE') + + csrf.init_app(app) + + app.config['TESTING'] = testing + + airflow.load_login() + airflow.login.login_manager.init_app(app) + + from airflow import api + api.load_auth() + api.api_auth.init_app(app) + + cache = Cache( + app=app, config={'CACHE_TYPE': 'filesystem', 'CACHE_DIR': '/tmp'}) + + app.register_blueprint(routes) + + configure_logging() + + with app.app_context(): + from airflow.www import views + + admin = Admin( + app, name='Airflow', + static_url_path='/admin', + index_view=views.HomeView(endpoint='', url='/admin', name="DAGs"), + template_mode='bootstrap3', + ) + av = admin.add_view + vs = views + av(vs.Airflow(name='DAGs', category='DAGs')) + + av(vs.QueryView(name='Ad Hoc Query', category="Data Profiling")) + + if not conf.getboolean('core', 'secure_mode'): + av(vs.ChartModelView( + models.Chart, Session, name="Charts", category="Data Profiling")) + av(vs.KnownEventView( + models.KnownEvent, + Session, name="Known Events", category="Data Profiling")) + av(vs.SlaMissModelView( + models.SlaMiss, + Session, name="SLA Misses", category="Browse")) + av(vs.TaskInstanceModelView(models.TaskInstance, + Session, name="Task Instances", category="Browse")) + av(vs.LogModelView( + models.Log, Session, name="Logs", category="Browse")) + av(vs.JobModelView( + jobs.BaseJob, Session, name="Jobs", category="Browse")) + av(vs.PoolModelView( + models.Pool, Session, name="Pools", category="Admin")) + av(vs.ConfigurationView( + name='Configuration', category="Admin")) + av(vs.UserModelView( + models.User, Session, name="Users", category="Admin")) + av(vs.ConnectionModelView( + models.Connection, Session, name="Connections", category="Admin")) + av(vs.VariableView( + models.Variable, Session, name="Variables", category="Admin")) + av(vs.XComView( + models.XCom, Session, name="XComs", category="Admin")) + + admin.add_link(base.MenuLink( + category='Docs', name='Documentation', + url='http://pythonhosted.org/airflow/')) + admin.add_link( + base.MenuLink(category='Docs', + name='Github',url='https://github.com/apache/incubator-airflow')) + + av(vs.VersionView(name='Version', category="About")) + + av(vs.DagRunModelView( + models.DagRun, Session, name="DAG Runs", category="Browse")) + av(vs.DagModelView(models.DagModel, Session, name=None)) + # Hack to not add this view to the menu + admin._menu = admin._menu[:-1] + + def integrate_plugins(): + """Integrate plugins to the context""" + log = LoggingMixin().log + from airflow.plugins_manager import ( + admin_views, flask_blueprints, menu_links) + for v in admin_views: + log.debug('Adding view %s', v.name) + admin.add_view(v) + for bp in flask_blueprints: + log.debug('Adding blueprint %s', bp.name) + app.register_blueprint(bp) + for ml in sorted(menu_links, key=lambda x: x.name): + log.debug('Adding menu link %s', ml.name) + admin.add_link(ml) + + integrate_plugins() + + import airflow.www.api.experimental.endpoints as e + # required for testing purposes otherwise the module retains + # a link to the default_auth + if app.config['TESTING']: + if six.PY2: + reload(e) + else: + import importlib + importlib.reload(e) + + app.register_blueprint(e.api_experimental, url_prefix='/api/experimental') + + @app.context_processor + def jinja_globals(): + return { + 'hostname': socket.getfqdn(), + } + + @app.teardown_appcontext + def shutdown_session(exception=None): + settings.Session.remove() + + return app + +app = None + + +def cached_app(config=None): + global app + if not app: + app = create_app(config) + return app diff --git a/airflow/www/blueprints.py b/airflow/www/blueprints.py new file mode 100644 index 0000000000000..88f96288f93cd --- /dev/null +++ b/airflow/www/blueprints.py @@ -0,0 +1,32 @@ +# -*- coding: utf-8 -*- +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +from flask import ( + url_for, Markup, Blueprint, redirect, +) +import markdown + +routes = Blueprint('routes', __name__) + + +@routes.route('/') +def index(): + return redirect(url_for('admin.index')) + + +@routes.route('/health') +def health(): + """ We can add an array of tests here to check the server's health """ + content = Markup(markdown.markdown("The server is healthy!")) + return content diff --git a/airflow/www/forms.py b/airflow/www/forms.py new file mode 100644 index 0000000000000..2c6118c0da5ca --- /dev/null +++ b/airflow/www/forms.py @@ -0,0 +1,44 @@ +# -*- coding: utf-8 -*- +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +from __future__ import absolute_import +from __future__ import division +from __future__ import print_function +from __future__ import unicode_literals + +from datetime import datetime +from flask_admin.form import DateTimePickerWidget +from wtforms import DateTimeField, SelectField +from flask_wtf import Form + + +class DateTimeForm(Form): + # Date filter form needed for gantt and graph view + execution_date = DateTimeField( + "Execution date", widget=DateTimePickerWidget()) + + +class DateTimeWithNumRunsForm(Form): + # Date time and number of runs form for tree view, task duration + # and landing times + base_date = DateTimeField( + "Anchor date", widget=DateTimePickerWidget(), default=datetime.utcnow()) + num_runs = SelectField("Number of runs", default=25, choices=( + (5, "5"), + (25, "25"), + (50, "50"), + (100, "100"), + (365, "365"), + )) + diff --git a/airflow/www/gunicorn_config.py b/airflow/www/gunicorn_config.py new file mode 100644 index 0000000000000..e1fd9b648c1a9 --- /dev/null +++ b/airflow/www/gunicorn_config.py @@ -0,0 +1,23 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import setproctitle +from airflow import settings + + +def post_worker_init(dummy_worker): + setproctitle.setproctitle( + settings.GUNICORN_WORKER_READY_PREFIX + setproctitle.getproctitle() + ) diff --git a/airflow/www/static/ace.js b/airflow/www/static/ace.js new file mode 100644 index 0000000000000..b007a0946e989 --- /dev/null +++ b/airflow/www/static/ace.js @@ -0,0 +1,18297 @@ +/* ***** BEGIN LICENSE BLOCK ***** + * Distributed under the BSD license: + * + * Copyright (c) 2010, Ajax.org B.V. + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are met: + * * Redistributions of source code must retain the above copyright + * notice, this list of conditions and the following disclaimer. + * * Redistributions in binary form must reproduce the above copyright + * notice, this list of conditions and the following disclaimer in the + * documentation and/or other materials provided with the distribution. + * * Neither the name of Ajax.org B.V. nor the + * names of its contributors may be used to endorse or promote products + * derived from this software without specific prior written permission. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND + * ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED + * WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE + * DISCLAIMED. IN NO EVENT SHALL AJAX.ORG B.V. BE LIABLE FOR ANY + * DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES + * (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; + * LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND + * ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT + * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS + * SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + * + * ***** END LICENSE BLOCK ***** */ + +/** + * Define a module along with a payload + * @param module a name for the payload + * @param payload a function to call with (require, exports, module) params + */ + +(function() { + +var ACE_NAMESPACE = ""; + +var global = (function() { + return this; +})(); + + +if (!ACE_NAMESPACE && typeof requirejs !== "undefined") + return; + + +var _define = function(module, deps, payload) { + if (typeof module !== 'string') { + if (_define.original) + _define.original.apply(window, arguments); + else { + console.error('dropping module because define wasn\'t a string.'); + console.trace(); + } + return; + } + + if (arguments.length == 2) + payload = deps; + + if (!_define.modules) { + _define.modules = {}; + _define.payloads = {}; + } + + _define.payloads[module] = payload; + _define.modules[module] = null; +}; + +/** + * Get at functionality define()ed using the function above + */ +var _require = function(parentId, module, callback) { + if (Object.prototype.toString.call(module) === "[object Array]") { + var params = []; + for (var i = 0, l = module.length; i < l; ++i) { + var dep = lookup(parentId, module[i]); + if (!dep && _require.original) + return _require.original.apply(window, arguments); + params.push(dep); + } + if (callback) { + callback.apply(null, params); + } + } + else if (typeof module === 'string') { + var payload = lookup(parentId, module); + if (!payload && _require.original) + return _require.original.apply(window, arguments); + + if (callback) { + callback(); + } + + return payload; + } + else { + if (_require.original) + return _require.original.apply(window, arguments); + } +}; + +var normalizeModule = function(parentId, moduleName) { + // normalize plugin requires + if (moduleName.indexOf("!") !== -1) { + var chunks = moduleName.split("!"); + return normalizeModule(parentId, chunks[0]) + "!" + normalizeModule(parentId, chunks[1]); + } + // normalize relative requires + if (moduleName.charAt(0) == ".") { + var base = parentId.split("/").slice(0, -1).join("/"); + moduleName = base + "/" + moduleName; + + while(moduleName.indexOf(".") !== -1 && previous != moduleName) { + var previous = moduleName; + moduleName = moduleName.replace(/\/\.\//, "/").replace(/[^\/]+\/\.\.\//, ""); + } + } + + return moduleName; +}; + +/** + * Internal function to lookup moduleNames and resolve them by calling the + * definition function if needed. + */ +var lookup = function(parentId, moduleName) { + + moduleName = normalizeModule(parentId, moduleName); + + var module = _define.modules[moduleName]; + if (!module) { + module = _define.payloads[moduleName]; + if (typeof module === 'function') { + var exports = {}; + var mod = { + id: moduleName, + uri: '', + exports: exports, + packaged: true + }; + + var req = function(module, callback) { + return _require(moduleName, module, callback); + }; + + var returnValue = module(req, exports, mod); + exports = returnValue || mod.exports; + _define.modules[moduleName] = exports; + delete _define.payloads[moduleName]; + } + module = _define.modules[moduleName] = exports || module; + } + return module; +}; + +function exportAce(ns) { + var require = function(module, callback) { + return _require("", module, callback); + }; + + var root = global; + if (ns) { + if (!global[ns]) + global[ns] = {}; + root = global[ns]; + } + + if (!root.define || !root.define.packaged) { + _define.original = root.define; + root.define = _define; + root.define.packaged = true; + } + + if (!root.require || !root.require.packaged) { + _require.original = root.require; + root.require = require; + root.require.packaged = true; + } +} + +exportAce(ACE_NAMESPACE); + +})(); + +define("ace/lib/regexp",["require","exports","module"], function(require, exports, module) { +"use strict"; + + var real = { + exec: RegExp.prototype.exec, + test: RegExp.prototype.test, + match: String.prototype.match, + replace: String.prototype.replace, + split: String.prototype.split + }, + compliantExecNpcg = real.exec.call(/()??/, "")[1] === undefined, // check `exec` handling of nonparticipating capturing groups + compliantLastIndexIncrement = function () { + var x = /^/g; + real.test.call(x, ""); + return !x.lastIndex; + }(); + + if (compliantLastIndexIncrement && compliantExecNpcg) + return; + RegExp.prototype.exec = function (str) { + var match = real.exec.apply(this, arguments), + name, r2; + if ( typeof(str) == 'string' && match) { + if (!compliantExecNpcg && match.length > 1 && indexOf(match, "") > -1) { + r2 = RegExp(this.source, real.replace.call(getNativeFlags(this), "g", "")); + real.replace.call(str.slice(match.index), r2, function () { + for (var i = 1; i < arguments.length - 2; i++) { + if (arguments[i] === undefined) + match[i] = undefined; + } + }); + } + if (this._xregexp && this._xregexp.captureNames) { + for (var i = 1; i < match.length; i++) { + name = this._xregexp.captureNames[i - 1]; + if (name) + match[name] = match[i]; + } + } + if (!compliantLastIndexIncrement && this.global && !match[0].length && (this.lastIndex > match.index)) + this.lastIndex--; + } + return match; + }; + if (!compliantLastIndexIncrement) { + RegExp.prototype.test = function (str) { + var match = real.exec.call(this, str); + if (match && this.global && !match[0].length && (this.lastIndex > match.index)) + this.lastIndex--; + return !!match; + }; + } + + function getNativeFlags (regex) { + return (regex.global ? "g" : "") + + (regex.ignoreCase ? "i" : "") + + (regex.multiline ? "m" : "") + + (regex.extended ? "x" : "") + // Proposed for ES4; included in AS3 + (regex.sticky ? "y" : ""); + } + + function indexOf (array, item, from) { + if (Array.prototype.indexOf) // Use the native array method if available + return array.indexOf(item, from); + for (var i = from || 0; i < array.length; i++) { + if (array[i] === item) + return i; + } + return -1; + } + +}); + +define("ace/lib/es5-shim",["require","exports","module"], function(require, exports, module) { + +function Empty() {} + +if (!Function.prototype.bind) { + Function.prototype.bind = function bind(that) { // .length is 1 + var target = this; + if (typeof target != "function") { + throw new TypeError("Function.prototype.bind called on incompatible " + target); + } + var args = slice.call(arguments, 1); // for normal call + var bound = function () { + + if (this instanceof bound) { + + var result = target.apply( + this, + args.concat(slice.call(arguments)) + ); + if (Object(result) === result) { + return result; + } + return this; + + } else { + return target.apply( + that, + args.concat(slice.call(arguments)) + ); + + } + + }; + if(target.prototype) { + Empty.prototype = target.prototype; + bound.prototype = new Empty(); + Empty.prototype = null; + } + return bound; + }; +} +var call = Function.prototype.call; +var prototypeOfArray = Array.prototype; +var prototypeOfObject = Object.prototype; +var slice = prototypeOfArray.slice; +var _toString = call.bind(prototypeOfObject.toString); +var owns = call.bind(prototypeOfObject.hasOwnProperty); +var defineGetter; +var defineSetter; +var lookupGetter; +var lookupSetter; +var supportsAccessors; +if ((supportsAccessors = owns(prototypeOfObject, "__defineGetter__"))) { + defineGetter = call.bind(prototypeOfObject.__defineGetter__); + defineSetter = call.bind(prototypeOfObject.__defineSetter__); + lookupGetter = call.bind(prototypeOfObject.__lookupGetter__); + lookupSetter = call.bind(prototypeOfObject.__lookupSetter__); +} +if ([1,2].splice(0).length != 2) { + if(function() { // test IE < 9 to splice bug - see issue #138 + function makeArray(l) { + var a = new Array(l+2); + a[0] = a[1] = 0; + return a; + } + var array = [], lengthBefore; + + array.splice.apply(array, makeArray(20)); + array.splice.apply(array, makeArray(26)); + + lengthBefore = array.length; //46 + array.splice(5, 0, "XXX"); // add one element + + lengthBefore + 1 == array.length + + if (lengthBefore + 1 == array.length) { + return true;// has right splice implementation without bugs + } + }()) {//IE 6/7 + var array_splice = Array.prototype.splice; + Array.prototype.splice = function(start, deleteCount) { + if (!arguments.length) { + return []; + } else { + return array_splice.apply(this, [ + start === void 0 ? 0 : start, + deleteCount === void 0 ? (this.length - start) : deleteCount + ].concat(slice.call(arguments, 2))) + } + }; + } else {//IE8 + Array.prototype.splice = function(pos, removeCount){ + var length = this.length; + if (pos > 0) { + if (pos > length) + pos = length; + } else if (pos == void 0) { + pos = 0; + } else if (pos < 0) { + pos = Math.max(length + pos, 0); + } + + if (!(pos+removeCount < length)) + removeCount = length - pos; + + var removed = this.slice(pos, pos+removeCount); + var insert = slice.call(arguments, 2); + var add = insert.length; + if (pos === length) { + if (add) { + this.push.apply(this, insert); + } + } else { + var remove = Math.min(removeCount, length - pos); + var tailOldPos = pos + remove; + var tailNewPos = tailOldPos + add - remove; + var tailCount = length - tailOldPos; + var lengthAfterRemove = length - remove; + + if (tailNewPos < tailOldPos) { // case A + for (var i = 0; i < tailCount; ++i) { + this[tailNewPos+i] = this[tailOldPos+i]; + } + } else if (tailNewPos > tailOldPos) { // case B + for (i = tailCount; i--; ) { + this[tailNewPos+i] = this[tailOldPos+i]; + } + } // else, add == remove (nothing to do) + + if (add && pos === lengthAfterRemove) { + this.length = lengthAfterRemove; // truncate array + this.push.apply(this, insert); + } else { + this.length = lengthAfterRemove + add; // reserves space + for (i = 0; i < add; ++i) { + this[pos+i] = insert[i]; + } + } + } + return removed; + }; + } +} +if (!Array.isArray) { + Array.isArray = function isArray(obj) { + return _toString(obj) == "[object Array]"; + }; +} +var boxedString = Object("a"), + splitString = boxedString[0] != "a" || !(0 in boxedString); + +if (!Array.prototype.forEach) { + Array.prototype.forEach = function forEach(fun /*, thisp*/) { + var object = toObject(this), + self = splitString && _toString(this) == "[object String]" ? + this.split("") : + object, + thisp = arguments[1], + i = -1, + length = self.length >>> 0; + if (_toString(fun) != "[object Function]") { + throw new TypeError(); // TODO message + } + + while (++i < length) { + if (i in self) { + fun.call(thisp, self[i], i, object); + } + } + }; +} +if (!Array.prototype.map) { + Array.prototype.map = function map(fun /*, thisp*/) { + var object = toObject(this), + self = splitString && _toString(this) == "[object String]" ? + this.split("") : + object, + length = self.length >>> 0, + result = Array(length), + thisp = arguments[1]; + if (_toString(fun) != "[object Function]") { + throw new TypeError(fun + " is not a function"); + } + + for (var i = 0; i < length; i++) { + if (i in self) + result[i] = fun.call(thisp, self[i], i, object); + } + return result; + }; +} +if (!Array.prototype.filter) { + Array.prototype.filter = function filter(fun /*, thisp */) { + var object = toObject(this), + self = splitString && _toString(this) == "[object String]" ? + this.split("") : + object, + length = self.length >>> 0, + result = [], + value, + thisp = arguments[1]; + if (_toString(fun) != "[object Function]") { + throw new TypeError(fun + " is not a function"); + } + + for (var i = 0; i < length; i++) { + if (i in self) { + value = self[i]; + if (fun.call(thisp, value, i, object)) { + result.push(value); + } + } + } + return result; + }; +} +if (!Array.prototype.every) { + Array.prototype.every = function every(fun /*, thisp */) { + var object = toObject(this), + self = splitString && _toString(this) == "[object String]" ? + this.split("") : + object, + length = self.length >>> 0, + thisp = arguments[1]; + if (_toString(fun) != "[object Function]") { + throw new TypeError(fun + " is not a function"); + } + + for (var i = 0; i < length; i++) { + if (i in self && !fun.call(thisp, self[i], i, object)) { + return false; + } + } + return true; + }; +} +if (!Array.prototype.some) { + Array.prototype.some = function some(fun /*, thisp */) { + var object = toObject(this), + self = splitString && _toString(this) == "[object String]" ? + this.split("") : + object, + length = self.length >>> 0, + thisp = arguments[1]; + if (_toString(fun) != "[object Function]") { + throw new TypeError(fun + " is not a function"); + } + + for (var i = 0; i < length; i++) { + if (i in self && fun.call(thisp, self[i], i, object)) { + return true; + } + } + return false; + }; +} +if (!Array.prototype.reduce) { + Array.prototype.reduce = function reduce(fun /*, initial*/) { + var object = toObject(this), + self = splitString && _toString(this) == "[object String]" ? + this.split("") : + object, + length = self.length >>> 0; + if (_toString(fun) != "[object Function]") { + throw new TypeError(fun + " is not a function"); + } + if (!length && arguments.length == 1) { + throw new TypeError("reduce of empty array with no initial value"); + } + + var i = 0; + var result; + if (arguments.length >= 2) { + result = arguments[1]; + } else { + do { + if (i in self) { + result = self[i++]; + break; + } + if (++i >= length) { + throw new TypeError("reduce of empty array with no initial value"); + } + } while (true); + } + + for (; i < length; i++) { + if (i in self) { + result = fun.call(void 0, result, self[i], i, object); + } + } + + return result; + }; +} +if (!Array.prototype.reduceRight) { + Array.prototype.reduceRight = function reduceRight(fun /*, initial*/) { + var object = toObject(this), + self = splitString && _toString(this) == "[object String]" ? + this.split("") : + object, + length = self.length >>> 0; + if (_toString(fun) != "[object Function]") { + throw new TypeError(fun + " is not a function"); + } + if (!length && arguments.length == 1) { + throw new TypeError("reduceRight of empty array with no initial value"); + } + + var result, i = length - 1; + if (arguments.length >= 2) { + result = arguments[1]; + } else { + do { + if (i in self) { + result = self[i--]; + break; + } + if (--i < 0) { + throw new TypeError("reduceRight of empty array with no initial value"); + } + } while (true); + } + + do { + if (i in this) { + result = fun.call(void 0, result, self[i], i, object); + } + } while (i--); + + return result; + }; +} +if (!Array.prototype.indexOf || ([0, 1].indexOf(1, 2) != -1)) { + Array.prototype.indexOf = function indexOf(sought /*, fromIndex */ ) { + var self = splitString && _toString(this) == "[object String]" ? + this.split("") : + toObject(this), + length = self.length >>> 0; + + if (!length) { + return -1; + } + + var i = 0; + if (arguments.length > 1) { + i = toInteger(arguments[1]); + } + i = i >= 0 ? i : Math.max(0, length + i); + for (; i < length; i++) { + if (i in self && self[i] === sought) { + return i; + } + } + return -1; + }; +} +if (!Array.prototype.lastIndexOf || ([0, 1].lastIndexOf(0, -3) != -1)) { + Array.prototype.lastIndexOf = function lastIndexOf(sought /*, fromIndex */) { + var self = splitString && _toString(this) == "[object String]" ? + this.split("") : + toObject(this), + length = self.length >>> 0; + + if (!length) { + return -1; + } + var i = length - 1; + if (arguments.length > 1) { + i = Math.min(i, toInteger(arguments[1])); + } + i = i >= 0 ? i : length - Math.abs(i); + for (; i >= 0; i--) { + if (i in self && sought === self[i]) { + return i; + } + } + return -1; + }; +} +if (!Object.getPrototypeOf) { + Object.getPrototypeOf = function getPrototypeOf(object) { + return object.__proto__ || ( + object.constructor ? + object.constructor.prototype : + prototypeOfObject + ); + }; +} +if (!Object.getOwnPropertyDescriptor) { + var ERR_NON_OBJECT = "Object.getOwnPropertyDescriptor called on a " + + "non-object: "; + Object.getOwnPropertyDescriptor = function getOwnPropertyDescriptor(object, property) { + if ((typeof object != "object" && typeof object != "function") || object === null) + throw new TypeError(ERR_NON_OBJECT + object); + if (!owns(object, property)) + return; + + var descriptor, getter, setter; + descriptor = { enumerable: true, configurable: true }; + if (supportsAccessors) { + var prototype = object.__proto__; + object.__proto__ = prototypeOfObject; + + var getter = lookupGetter(object, property); + var setter = lookupSetter(object, property); + object.__proto__ = prototype; + + if (getter || setter) { + if (getter) descriptor.get = getter; + if (setter) descriptor.set = setter; + return descriptor; + } + } + descriptor.value = object[property]; + return descriptor; + }; +} +if (!Object.getOwnPropertyNames) { + Object.getOwnPropertyNames = function getOwnPropertyNames(object) { + return Object.keys(object); + }; +} +if (!Object.create) { + var createEmpty; + if (Object.prototype.__proto__ === null) { + createEmpty = function () { + return { "__proto__": null }; + }; + } else { + createEmpty = function () { + var empty = {}; + for (var i in empty) + empty[i] = null; + empty.constructor = + empty.hasOwnProperty = + empty.propertyIsEnumerable = + empty.isPrototypeOf = + empty.toLocaleString = + empty.toString = + empty.valueOf = + empty.__proto__ = null; + return empty; + } + } + + Object.create = function create(prototype, properties) { + var object; + if (prototype === null) { + object = createEmpty(); + } else { + if (typeof prototype != "object") + throw new TypeError("typeof prototype["+(typeof prototype)+"] != 'object'"); + var Type = function () {}; + Type.prototype = prototype; + object = new Type(); + object.__proto__ = prototype; + } + if (properties !== void 0) + Object.defineProperties(object, properties); + return object; + }; +} + +function doesDefinePropertyWork(object) { + try { + Object.defineProperty(object, "sentinel", {}); + return "sentinel" in object; + } catch (exception) { + } +} +if (Object.defineProperty) { + var definePropertyWorksOnObject = doesDefinePropertyWork({}); + var definePropertyWorksOnDom = typeof document == "undefined" || + doesDefinePropertyWork(document.createElement("div")); + if (!definePropertyWorksOnObject || !definePropertyWorksOnDom) { + var definePropertyFallback = Object.defineProperty; + } +} + +if (!Object.defineProperty || definePropertyFallback) { + var ERR_NON_OBJECT_DESCRIPTOR = "Property description must be an object: "; + var ERR_NON_OBJECT_TARGET = "Object.defineProperty called on non-object: " + var ERR_ACCESSORS_NOT_SUPPORTED = "getters & setters can not be defined " + + "on this javascript engine"; + + Object.defineProperty = function defineProperty(object, property, descriptor) { + if ((typeof object != "object" && typeof object != "function") || object === null) + throw new TypeError(ERR_NON_OBJECT_TARGET + object); + if ((typeof descriptor != "object" && typeof descriptor != "function") || descriptor === null) + throw new TypeError(ERR_NON_OBJECT_DESCRIPTOR + descriptor); + if (definePropertyFallback) { + try { + return definePropertyFallback.call(Object, object, property, descriptor); + } catch (exception) { + } + } + if (owns(descriptor, "value")) { + + if (supportsAccessors && (lookupGetter(object, property) || + lookupSetter(object, property))) + { + var prototype = object.__proto__; + object.__proto__ = prototypeOfObject; + delete object[property]; + object[property] = descriptor.value; + object.__proto__ = prototype; + } else { + object[property] = descriptor.value; + } + } else { + if (!supportsAccessors) + throw new TypeError(ERR_ACCESSORS_NOT_SUPPORTED); + if (owns(descriptor, "get")) + defineGetter(object, property, descriptor.get); + if (owns(descriptor, "set")) + defineSetter(object, property, descriptor.set); + } + + return object; + }; +} +if (!Object.defineProperties) { + Object.defineProperties = function defineProperties(object, properties) { + for (var property in properties) { + if (owns(properties, property)) + Object.defineProperty(object, property, properties[property]); + } + return object; + }; +} +if (!Object.seal) { + Object.seal = function seal(object) { + return object; + }; +} +if (!Object.freeze) { + Object.freeze = function freeze(object) { + return object; + }; +} +try { + Object.freeze(function () {}); +} catch (exception) { + Object.freeze = (function freeze(freezeObject) { + return function freeze(object) { + if (typeof object == "function") { + return object; + } else { + return freezeObject(object); + } + }; + })(Object.freeze); +} +if (!Object.preventExtensions) { + Object.preventExtensions = function preventExtensions(object) { + return object; + }; +} +if (!Object.isSealed) { + Object.isSealed = function isSealed(object) { + return false; + }; +} +if (!Object.isFrozen) { + Object.isFrozen = function isFrozen(object) { + return false; + }; +} +if (!Object.isExtensible) { + Object.isExtensible = function isExtensible(object) { + if (Object(object) === object) { + throw new TypeError(); // TODO message + } + var name = ''; + while (owns(object, name)) { + name += '?'; + } + object[name] = true; + var returnValue = owns(object, name); + delete object[name]; + return returnValue; + }; +} +if (!Object.keys) { + var hasDontEnumBug = true, + dontEnums = [ + "toString", + "toLocaleString", + "valueOf", + "hasOwnProperty", + "isPrototypeOf", + "propertyIsEnumerable", + "constructor" + ], + dontEnumsLength = dontEnums.length; + + for (var key in {"toString": null}) { + hasDontEnumBug = false; + } + + Object.keys = function keys(object) { + + if ( + (typeof object != "object" && typeof object != "function") || + object === null + ) { + throw new TypeError("Object.keys called on a non-object"); + } + + var keys = []; + for (var name in object) { + if (owns(object, name)) { + keys.push(name); + } + } + + if (hasDontEnumBug) { + for (var i = 0, ii = dontEnumsLength; i < ii; i++) { + var dontEnum = dontEnums[i]; + if (owns(object, dontEnum)) { + keys.push(dontEnum); + } + } + } + return keys; + }; + +} +if (!Date.now) { + Date.now = function now() { + return new Date().getTime(); + }; +} +var ws = "\x09\x0A\x0B\x0C\x0D\x20\xA0\u1680\u180E\u2000\u2001\u2002\u2003" + + "\u2004\u2005\u2006\u2007\u2008\u2009\u200A\u202F\u205F\u3000\u2028" + + "\u2029\uFEFF"; +if (!String.prototype.trim || ws.trim()) { + ws = "[" + ws + "]"; + var trimBeginRegexp = new RegExp("^" + ws + ws + "*"), + trimEndRegexp = new RegExp(ws + ws + "*$"); + String.prototype.trim = function trim() { + return String(this).replace(trimBeginRegexp, "").replace(trimEndRegexp, ""); + }; +} + +function toInteger(n) { + n = +n; + if (n !== n) { // isNaN + n = 0; + } else if (n !== 0 && n !== (1/0) && n !== -(1/0)) { + n = (n > 0 || -1) * Math.floor(Math.abs(n)); + } + return n; +} + +function isPrimitive(input) { + var type = typeof input; + return ( + input === null || + type === "undefined" || + type === "boolean" || + type === "number" || + type === "string" + ); +} + +function toPrimitive(input) { + var val, valueOf, toString; + if (isPrimitive(input)) { + return input; + } + valueOf = input.valueOf; + if (typeof valueOf === "function") { + val = valueOf.call(input); + if (isPrimitive(val)) { + return val; + } + } + toString = input.toString; + if (typeof toString === "function") { + val = toString.call(input); + if (isPrimitive(val)) { + return val; + } + } + throw new TypeError(); +} +var toObject = function (o) { + if (o == null) { // this matches both null and undefined + throw new TypeError("can't convert "+o+" to object"); + } + return Object(o); +}; + +}); + +define("ace/lib/fixoldbrowsers",["require","exports","module","ace/lib/regexp","ace/lib/es5-shim"], function(require, exports, module) { +"use strict"; + +require("./regexp"); +require("./es5-shim"); + +}); + +define("ace/lib/dom",["require","exports","module"], function(require, exports, module) { +"use strict"; + +var XHTML_NS = "http://www.w3.org/1999/xhtml"; + +exports.getDocumentHead = function(doc) { + if (!doc) + doc = document; + return doc.head || doc.getElementsByTagName("head")[0] || doc.documentElement; +} + +exports.createElement = function(tag, ns) { + return document.createElementNS ? + document.createElementNS(ns || XHTML_NS, tag) : + document.createElement(tag); +}; + +exports.hasCssClass = function(el, name) { + var classes = (el.className || "").split(/\s+/g); + return classes.indexOf(name) !== -1; +}; +exports.addCssClass = function(el, name) { + if (!exports.hasCssClass(el, name)) { + el.className += " " + name; + } +}; +exports.removeCssClass = function(el, name) { + var classes = el.className.split(/\s+/g); + while (true) { + var index = classes.indexOf(name); + if (index == -1) { + break; + } + classes.splice(index, 1); + } + el.className = classes.join(" "); +}; + +exports.toggleCssClass = function(el, name) { + var classes = el.className.split(/\s+/g), add = true; + while (true) { + var index = classes.indexOf(name); + if (index == -1) { + break; + } + add = false; + classes.splice(index, 1); + } + if(add) + classes.push(name); + + el.className = classes.join(" "); + return add; +}; +exports.setCssClass = function(node, className, include) { + if (include) { + exports.addCssClass(node, className); + } else { + exports.removeCssClass(node, className); + } +}; + +exports.hasCssString = function(id, doc) { + var index = 0, sheets; + doc = doc || document; + + if (doc.createStyleSheet && (sheets = doc.styleSheets)) { + while (index < sheets.length) + if (sheets[index++].owningElement.id === id) return true; + } else if ((sheets = doc.getElementsByTagName("style"))) { + while (index < sheets.length) + if (sheets[index++].id === id) return true; + } + + return false; +}; + +exports.importCssString = function importCssString(cssText, id, doc) { + doc = doc || document; + if (id && exports.hasCssString(id, doc)) + return null; + + var style; + + if (doc.createStyleSheet) { + style = doc.createStyleSheet(); + style.cssText = cssText; + if (id) + style.owningElement.id = id; + } else { + style = doc.createElementNS + ? doc.createElementNS(XHTML_NS, "style") + : doc.createElement("style"); + + style.appendChild(doc.createTextNode(cssText)); + if (id) + style.id = id; + + exports.getDocumentHead(doc).appendChild(style); + } +}; + +exports.importCssStylsheet = function(uri, doc) { + if (doc.createStyleSheet) { + doc.createStyleSheet(uri); + } else { + var link = exports.createElement('link'); + link.rel = 'stylesheet'; + link.href = uri; + + exports.getDocumentHead(doc).appendChild(link); + } +}; + +exports.getInnerWidth = function(element) { + return ( + parseInt(exports.computedStyle(element, "paddingLeft"), 10) + + parseInt(exports.computedStyle(element, "paddingRight"), 10) + + element.clientWidth + ); +}; + +exports.getInnerHeight = function(element) { + return ( + parseInt(exports.computedStyle(element, "paddingTop"), 10) + + parseInt(exports.computedStyle(element, "paddingBottom"), 10) + + element.clientHeight + ); +}; + + +if (typeof document == "undefined") + return; + +if (window.pageYOffset !== undefined) { + exports.getPageScrollTop = function() { + return window.pageYOffset; + }; + + exports.getPageScrollLeft = function() { + return window.pageXOffset; + }; +} +else { + exports.getPageScrollTop = function() { + return document.body.scrollTop; + }; + + exports.getPageScrollLeft = function() { + return document.body.scrollLeft; + }; +} + +if (window.getComputedStyle) + exports.computedStyle = function(element, style) { + if (style) + return (window.getComputedStyle(element, "") || {})[style] || ""; + return window.getComputedStyle(element, "") || {}; + }; +else + exports.computedStyle = function(element, style) { + if (style) + return element.currentStyle[style]; + return element.currentStyle; + }; + +exports.scrollbarWidth = function(document) { + var inner = exports.createElement("ace_inner"); + inner.style.width = "100%"; + inner.style.minWidth = "0px"; + inner.style.height = "200px"; + inner.style.display = "block"; + + var outer = exports.createElement("ace_outer"); + var style = outer.style; + + style.position = "absolute"; + style.left = "-10000px"; + style.overflow = "hidden"; + style.width = "200px"; + style.minWidth = "0px"; + style.height = "150px"; + style.display = "block"; + + outer.appendChild(inner); + + var body = document.documentElement; + body.appendChild(outer); + + var noScrollbar = inner.offsetWidth; + + style.overflow = "scroll"; + var withScrollbar = inner.offsetWidth; + + if (noScrollbar == withScrollbar) { + withScrollbar = outer.clientWidth; + } + + body.removeChild(outer); + + return noScrollbar-withScrollbar; +}; +exports.setInnerHtml = function(el, innerHtml) { + var element = el.cloneNode(false);//document.createElement("div"); + element.innerHTML = innerHtml; + el.parentNode.replaceChild(element, el); + return element; +}; + +if ("textContent" in document.documentElement) { + exports.setInnerText = function(el, innerText) { + el.textContent = innerText; + }; + + exports.getInnerText = function(el) { + return el.textContent; + }; +} +else { + exports.setInnerText = function(el, innerText) { + el.innerText = innerText; + }; + + exports.getInnerText = function(el) { + return el.innerText; + }; +} + +exports.getParentWindow = function(document) { + return document.defaultView || document.parentWindow; +}; + +}); + +define("ace/lib/oop",["require","exports","module"], function(require, exports, module) { +"use strict"; + +exports.inherits = function(ctor, superCtor) { + ctor.super_ = superCtor; + ctor.prototype = Object.create(superCtor.prototype, { + constructor: { + value: ctor, + enumerable: false, + writable: true, + configurable: true + } + }); +}; + +exports.mixin = function(obj, mixin) { + for (var key in mixin) { + obj[key] = mixin[key]; + } + return obj; +}; + +exports.implement = function(proto, mixin) { + exports.mixin(proto, mixin); +}; + +}); + +define("ace/lib/keys",["require","exports","module","ace/lib/fixoldbrowsers","ace/lib/oop"], function(require, exports, module) { +"use strict"; + +require("./fixoldbrowsers"); + +var oop = require("./oop"); +var Keys = (function() { + var ret = { + MODIFIER_KEYS: { + 16: 'Shift', 17: 'Ctrl', 18: 'Alt', 224: 'Meta' + }, + + KEY_MODS: { + "ctrl": 1, "alt": 2, "option" : 2, "shift": 4, + "super": 8, "meta": 8, "command": 8, "cmd": 8 + }, + + FUNCTION_KEYS : { + 8 : "Backspace", + 9 : "Tab", + 13 : "Return", + 19 : "Pause", + 27 : "Esc", + 32 : "Space", + 33 : "PageUp", + 34 : "PageDown", + 35 : "End", + 36 : "Home", + 37 : "Left", + 38 : "Up", + 39 : "Right", + 40 : "Down", + 44 : "Print", + 45 : "Insert", + 46 : "Delete", + 96 : "Numpad0", + 97 : "Numpad1", + 98 : "Numpad2", + 99 : "Numpad3", + 100: "Numpad4", + 101: "Numpad5", + 102: "Numpad6", + 103: "Numpad7", + 104: "Numpad8", + 105: "Numpad9", + '-13': "NumpadEnter", + 112: "F1", + 113: "F2", + 114: "F3", + 115: "F4", + 116: "F5", + 117: "F6", + 118: "F7", + 119: "F8", + 120: "F9", + 121: "F10", + 122: "F11", + 123: "F12", + 144: "Numlock", + 145: "Scrolllock" + }, + + PRINTABLE_KEYS: { + 32: ' ', 48: '0', 49: '1', 50: '2', 51: '3', 52: '4', 53: '5', + 54: '6', 55: '7', 56: '8', 57: '9', 59: ';', 61: '=', 65: 'a', + 66: 'b', 67: 'c', 68: 'd', 69: 'e', 70: 'f', 71: 'g', 72: 'h', + 73: 'i', 74: 'j', 75: 'k', 76: 'l', 77: 'm', 78: 'n', 79: 'o', + 80: 'p', 81: 'q', 82: 'r', 83: 's', 84: 't', 85: 'u', 86: 'v', + 87: 'w', 88: 'x', 89: 'y', 90: 'z', 107: '+', 109: '-', 110: '.', + 187: '=', 188: ',', 189: '-', 190: '.', 191: '/', 192: '`', 219: '[', + 220: '\\',221: ']', 222: '\'' + } + }; + var name, i; + for (i in ret.FUNCTION_KEYS) { + name = ret.FUNCTION_KEYS[i].toLowerCase(); + ret[name] = parseInt(i, 10); + } + for (i in ret.PRINTABLE_KEYS) { + name = ret.PRINTABLE_KEYS[i].toLowerCase(); + ret[name] = parseInt(i, 10); + } + oop.mixin(ret, ret.MODIFIER_KEYS); + oop.mixin(ret, ret.PRINTABLE_KEYS); + oop.mixin(ret, ret.FUNCTION_KEYS); + ret.enter = ret["return"]; + ret.escape = ret.esc; + ret.del = ret["delete"]; + ret[173] = '-'; + + (function() { + var mods = ["cmd", "ctrl", "alt", "shift"]; + for (var i = Math.pow(2, mods.length); i--;) { + ret.KEY_MODS[i] = mods.filter(function(x) { + return i & ret.KEY_MODS[x]; + }).join("-") + "-"; + } + })(); + + ret.KEY_MODS[0] = ""; + ret.KEY_MODS[-1] = "input"; + + return ret; +})(); +oop.mixin(exports, Keys); + +exports.keyCodeToString = function(keyCode) { + var keyString = Keys[keyCode]; + if (typeof keyString != "string") + keyString = String.fromCharCode(keyCode); + return keyString.toLowerCase(); +}; + +}); + +define("ace/lib/useragent",["require","exports","module"], function(require, exports, module) { +"use strict"; +exports.OS = { + LINUX: "LINUX", + MAC: "MAC", + WINDOWS: "WINDOWS" +}; +exports.getOS = function() { + if (exports.isMac) { + return exports.OS.MAC; + } else if (exports.isLinux) { + return exports.OS.LINUX; + } else { + return exports.OS.WINDOWS; + } +}; +if (typeof navigator != "object") + return; + +var os = (navigator.platform.match(/mac|win|linux/i) || ["other"])[0].toLowerCase(); +var ua = navigator.userAgent; +exports.isWin = (os == "win"); +exports.isMac = (os == "mac"); +exports.isLinux = (os == "linux"); +exports.isIE = + (navigator.appName == "Microsoft Internet Explorer" || navigator.appName.indexOf("MSAppHost") >= 0) + ? parseFloat((ua.match(/(?:MSIE |Trident\/[0-9]+[\.0-9]+;.*rv:)([0-9]+[\.0-9]+)/)||[])[1]) + : parseFloat((ua.match(/(?:Trident\/[0-9]+[\.0-9]+;.*rv:)([0-9]+[\.0-9]+)/)||[])[1]); // for ie + +exports.isOldIE = exports.isIE && exports.isIE < 9; +exports.isGecko = exports.isMozilla = (window.Controllers || window.controllers) && window.navigator.product === "Gecko"; +exports.isOldGecko = exports.isGecko && parseInt((ua.match(/rv\:(\d+)/)||[])[1], 10) < 4; +exports.isOpera = window.opera && Object.prototype.toString.call(window.opera) == "[object Opera]"; +exports.isWebKit = parseFloat(ua.split("WebKit/")[1]) || undefined; + +exports.isChrome = parseFloat(ua.split(" Chrome/")[1]) || undefined; + +exports.isAIR = ua.indexOf("AdobeAIR") >= 0; + +exports.isIPad = ua.indexOf("iPad") >= 0; + +exports.isTouchPad = ua.indexOf("TouchPad") >= 0; + +exports.isChromeOS = ua.indexOf(" CrOS ") >= 0; + +}); + +define("ace/lib/event",["require","exports","module","ace/lib/keys","ace/lib/useragent"], function(require, exports, module) { +"use strict"; + +var keys = require("./keys"); +var useragent = require("./useragent"); + +exports.addListener = function(elem, type, callback) { + if (elem.addEventListener) { + return elem.addEventListener(type, callback, false); + } + if (elem.attachEvent) { + var wrapper = function() { + callback.call(elem, window.event); + }; + callback._wrapper = wrapper; + elem.attachEvent("on" + type, wrapper); + } +}; + +exports.removeListener = function(elem, type, callback) { + if (elem.removeEventListener) { + return elem.removeEventListener(type, callback, false); + } + if (elem.detachEvent) { + elem.detachEvent("on" + type, callback._wrapper || callback); + } +}; +exports.stopEvent = function(e) { + exports.stopPropagation(e); + exports.preventDefault(e); + return false; +}; + +exports.stopPropagation = function(e) { + if (e.stopPropagation) + e.stopPropagation(); + else + e.cancelBubble = true; +}; + +exports.preventDefault = function(e) { + if (e.preventDefault) + e.preventDefault(); + else + e.returnValue = false; +}; +exports.getButton = function(e) { + if (e.type == "dblclick") + return 0; + if (e.type == "contextmenu" || (useragent.isMac && (e.ctrlKey && !e.altKey && !e.shiftKey))) + return 2; + if (e.preventDefault) { + return e.button; + } + else { + return {1:0, 2:2, 4:1}[e.button]; + } +}; + +exports.capture = function(el, eventHandler, releaseCaptureHandler) { + function onMouseUp(e) { + eventHandler && eventHandler(e); + releaseCaptureHandler && releaseCaptureHandler(e); + + exports.removeListener(document, "mousemove", eventHandler, true); + exports.removeListener(document, "mouseup", onMouseUp, true); + exports.removeListener(document, "dragstart", onMouseUp, true); + } + + exports.addListener(document, "mousemove", eventHandler, true); + exports.addListener(document, "mouseup", onMouseUp, true); + exports.addListener(document, "dragstart", onMouseUp, true); + + return onMouseUp; +}; + +exports.addMouseWheelListener = function(el, callback) { + if ("onmousewheel" in el) { + exports.addListener(el, "mousewheel", function(e) { + var factor = 8; + if (e.wheelDeltaX !== undefined) { + e.wheelX = -e.wheelDeltaX / factor; + e.wheelY = -e.wheelDeltaY / factor; + } else { + e.wheelX = 0; + e.wheelY = -e.wheelDelta / factor; + } + callback(e); + }); + } else if ("onwheel" in el) { + exports.addListener(el, "wheel", function(e) { + var factor = 0.35; + switch (e.deltaMode) { + case e.DOM_DELTA_PIXEL: + e.wheelX = e.deltaX * factor || 0; + e.wheelY = e.deltaY * factor || 0; + break; + case e.DOM_DELTA_LINE: + case e.DOM_DELTA_PAGE: + e.wheelX = (e.deltaX || 0) * 5; + e.wheelY = (e.deltaY || 0) * 5; + break; + } + + callback(e); + }); + } else { + exports.addListener(el, "DOMMouseScroll", function(e) { + if (e.axis && e.axis == e.HORIZONTAL_AXIS) { + e.wheelX = (e.detail || 0) * 5; + e.wheelY = 0; + } else { + e.wheelX = 0; + e.wheelY = (e.detail || 0) * 5; + } + callback(e); + }); + } +}; + +exports.addMultiMouseDownListener = function(el, timeouts, eventHandler, callbackName) { + var clicks = 0; + var startX, startY, timer; + var eventNames = { + 2: "dblclick", + 3: "tripleclick", + 4: "quadclick" + }; + + exports.addListener(el, "mousedown", function(e) { + if (exports.getButton(e) !== 0) { + clicks = 0; + } else if (e.detail > 1) { + clicks++; + if (clicks > 4) + clicks = 1; + } else { + clicks = 1; + } + if (useragent.isIE) { + var isNewClick = Math.abs(e.clientX - startX) > 5 || Math.abs(e.clientY - startY) > 5; + if (!timer || isNewClick) + clicks = 1; + if (timer) + clearTimeout(timer); + timer = setTimeout(function() {timer = null}, timeouts[clicks - 1] || 600); + + if (clicks == 1) { + startX = e.clientX; + startY = e.clientY; + } + } + + e._clicks = clicks; + + eventHandler[callbackName]("mousedown", e); + + if (clicks > 4) + clicks = 0; + else if (clicks > 1) + return eventHandler[callbackName](eventNames[clicks], e); + }); + + if (useragent.isOldIE) { + exports.addListener(el, "dblclick", function(e) { + clicks = 2; + if (timer) + clearTimeout(timer); + timer = setTimeout(function() {timer = null}, timeouts[clicks - 1] || 600); + eventHandler[callbackName]("mousedown", e); + eventHandler[callbackName](eventNames[clicks], e); + }); + } +}; + +var getModifierHash = useragent.isMac && useragent.isOpera && !("KeyboardEvent" in window) + ? function(e) { + return 0 | (e.metaKey ? 1 : 0) | (e.altKey ? 2 : 0) | (e.shiftKey ? 4 : 0) | (e.ctrlKey ? 8 : 0); + } + : function(e) { + return 0 | (e.ctrlKey ? 1 : 0) | (e.altKey ? 2 : 0) | (e.shiftKey ? 4 : 0) | (e.metaKey ? 8 : 0); + }; + +exports.getModifierString = function(e) { + return keys.KEY_MODS[getModifierHash(e)]; +}; + +function normalizeCommandKeys(callback, e, keyCode) { + var hashId = getModifierHash(e); + + if (!useragent.isMac && pressedKeys) { + if (pressedKeys[91] || pressedKeys[92]) + hashId |= 8; + if (pressedKeys.altGr) { + if ((3 & hashId) != 3) + pressedKeys.altGr = 0; + else + return; + } + if (keyCode === 18 || keyCode === 17) { + var location = "location" in e ? e.location : e.keyLocation; + if (keyCode === 17 && location === 1) { + ts = e.timeStamp; + } else if (keyCode === 18 && hashId === 3 && location === 2) { + var dt = -ts; + ts = e.timeStamp; + dt += ts; + if (dt < 3) + pressedKeys.altGr = true; + } + } + } + + if (keyCode in keys.MODIFIER_KEYS) { + switch (keys.MODIFIER_KEYS[keyCode]) { + case "Alt": + hashId = 2; + break; + case "Shift": + hashId = 4; + break; + case "Ctrl": + hashId = 1; + break; + default: + hashId = 8; + break; + } + keyCode = -1; + } + + if (hashId & 8 && (keyCode === 91 || keyCode === 93)) { + keyCode = -1; + } + + if (!hashId && keyCode === 13) { + var location = "location" in e ? e.location : e.keyLocation; + if (location === 3) { + callback(e, hashId, -keyCode); + if (e.defaultPrevented) + return; + } + } + + if (useragent.isChromeOS && hashId & 8) { + callback(e, hashId, keyCode); + if (e.defaultPrevented) + return; + else + hashId &= ~8; + } + if (!hashId && !(keyCode in keys.FUNCTION_KEYS) && !(keyCode in keys.PRINTABLE_KEYS)) { + return false; + } + + return callback(e, hashId, keyCode); +} + +var pressedKeys = null; +var ts = 0; +exports.addCommandKeyListener = function(el, callback) { + var addListener = exports.addListener; + if (useragent.isOldGecko || (useragent.isOpera && !("KeyboardEvent" in window))) { + var lastKeyDownKeyCode = null; + addListener(el, "keydown", function(e) { + lastKeyDownKeyCode = e.keyCode; + }); + addListener(el, "keypress", function(e) { + return normalizeCommandKeys(callback, e, lastKeyDownKeyCode); + }); + } else { + var lastDefaultPrevented = null; + + addListener(el, "keydown", function(e) { + pressedKeys[e.keyCode] = true; + var result = normalizeCommandKeys(callback, e, e.keyCode); + lastDefaultPrevented = e.defaultPrevented; + return result; + }); + + addListener(el, "keypress", function(e) { + if (lastDefaultPrevented && (e.ctrlKey || e.altKey || e.shiftKey || e.metaKey)) { + exports.stopEvent(e); + lastDefaultPrevented = null; + } + }); + + addListener(el, "keyup", function(e) { + pressedKeys[e.keyCode] = null; + }); + + if (!pressedKeys) { + pressedKeys = Object.create(null); + addListener(window, "focus", function(e) { + pressedKeys = Object.create(null); + }); + } + } +}; + +if (window.postMessage && !useragent.isOldIE) { + var postMessageId = 1; + exports.nextTick = function(callback, win) { + win = win || window; + var messageName = "zero-timeout-message-" + postMessageId; + exports.addListener(win, "message", function listener(e) { + if (e.data == messageName) { + exports.stopPropagation(e); + exports.removeListener(win, "message", listener); + callback(); + } + }); + win.postMessage(messageName, "*"); + }; +} + + +exports.nextFrame = window.requestAnimationFrame || + window.mozRequestAnimationFrame || + window.webkitRequestAnimationFrame || + window.msRequestAnimationFrame || + window.oRequestAnimationFrame; + +if (exports.nextFrame) + exports.nextFrame = exports.nextFrame.bind(window); +else + exports.nextFrame = function(callback) { + setTimeout(callback, 17); + }; +}); + +define("ace/lib/lang",["require","exports","module"], function(require, exports, module) { +"use strict"; + +exports.last = function(a) { + return a[a.length - 1]; +}; + +exports.stringReverse = function(string) { + return string.split("").reverse().join(""); +}; + +exports.stringRepeat = function (string, count) { + var result = ''; + while (count > 0) { + if (count & 1) + result += string; + + if (count >>= 1) + string += string; + } + return result; +}; + +var trimBeginRegexp = /^\s\s*/; +var trimEndRegexp = /\s\s*$/; + +exports.stringTrimLeft = function (string) { + return string.replace(trimBeginRegexp, ''); +}; + +exports.stringTrimRight = function (string) { + return string.replace(trimEndRegexp, ''); +}; + +exports.copyObject = function(obj) { + var copy = {}; + for (var key in obj) { + copy[key] = obj[key]; + } + return copy; +}; + +exports.copyArray = function(array){ + var copy = []; + for (var i=0, l=array.length; i 1); + return ev.preventDefault(); + }; + + this.startSelect = function(pos, waitForClickSelection) { + pos = pos || this.editor.renderer.screenToTextCoordinates(this.x, this.y); + var editor = this.editor; + editor.$blockScrolling++; + if (this.mousedownEvent.getShiftKey()) + editor.selection.selectToPosition(pos); + else if (!waitForClickSelection) + editor.selection.moveToPosition(pos); + if (!waitForClickSelection) + this.select(); + if (editor.renderer.scroller.setCapture) { + editor.renderer.scroller.setCapture(); + } + editor.setStyle("ace_selecting"); + this.setState("select"); + editor.$blockScrolling--; + }; + + this.select = function() { + var anchor, editor = this.editor; + var cursor = editor.renderer.screenToTextCoordinates(this.x, this.y); + editor.$blockScrolling++; + if (this.$clickSelection) { + var cmp = this.$clickSelection.comparePoint(cursor); + + if (cmp == -1) { + anchor = this.$clickSelection.end; + } else if (cmp == 1) { + anchor = this.$clickSelection.start; + } else { + var orientedRange = calcRangeOrientation(this.$clickSelection, cursor); + cursor = orientedRange.cursor; + anchor = orientedRange.anchor; + } + editor.selection.setSelectionAnchor(anchor.row, anchor.column); + } + editor.selection.selectToPosition(cursor); + editor.$blockScrolling--; + editor.renderer.scrollCursorIntoView(); + }; + + this.extendSelectionBy = function(unitName) { + var anchor, editor = this.editor; + var cursor = editor.renderer.screenToTextCoordinates(this.x, this.y); + var range = editor.selection[unitName](cursor.row, cursor.column); + editor.$blockScrolling++; + if (this.$clickSelection) { + var cmpStart = this.$clickSelection.comparePoint(range.start); + var cmpEnd = this.$clickSelection.comparePoint(range.end); + + if (cmpStart == -1 && cmpEnd <= 0) { + anchor = this.$clickSelection.end; + if (range.end.row != cursor.row || range.end.column != cursor.column) + cursor = range.start; + } else if (cmpEnd == 1 && cmpStart >= 0) { + anchor = this.$clickSelection.start; + if (range.start.row != cursor.row || range.start.column != cursor.column) + cursor = range.end; + } else if (cmpStart == -1 && cmpEnd == 1) { + cursor = range.end; + anchor = range.start; + } else { + var orientedRange = calcRangeOrientation(this.$clickSelection, cursor); + cursor = orientedRange.cursor; + anchor = orientedRange.anchor; + } + editor.selection.setSelectionAnchor(anchor.row, anchor.column); + } + editor.selection.selectToPosition(cursor); + editor.$blockScrolling--; + editor.renderer.scrollCursorIntoView(); + }; + + this.selectEnd = + this.selectAllEnd = + this.selectByWordsEnd = + this.selectByLinesEnd = function() { + this.$clickSelection = null; + this.editor.unsetStyle("ace_selecting"); + if (this.editor.renderer.scroller.releaseCapture) { + this.editor.renderer.scroller.releaseCapture(); + } + }; + + this.focusWait = function() { + var distance = calcDistance(this.mousedownEvent.x, this.mousedownEvent.y, this.x, this.y); + var time = Date.now(); + + if (distance > DRAG_OFFSET || time - this.mousedownEvent.time > this.$focusTimout) + this.startSelect(this.mousedownEvent.getDocumentPosition()); + }; + + this.onDoubleClick = function(ev) { + var pos = ev.getDocumentPosition(); + var editor = this.editor; + var session = editor.session; + + var range = session.getBracketRange(pos); + if (range) { + if (range.isEmpty()) { + range.start.column--; + range.end.column++; + } + this.setState("select"); + } else { + range = editor.selection.getWordRange(pos.row, pos.column); + this.setState("selectByWords"); + } + this.$clickSelection = range; + this.select(); + }; + + this.onTripleClick = function(ev) { + var pos = ev.getDocumentPosition(); + var editor = this.editor; + + this.setState("selectByLines"); + var range = editor.getSelectionRange(); + if (range.isMultiLine() && range.contains(pos.row, pos.column)) { + this.$clickSelection = editor.selection.getLineRange(range.start.row); + this.$clickSelection.end = editor.selection.getLineRange(range.end.row).end; + } else { + this.$clickSelection = editor.selection.getLineRange(pos.row); + } + this.select(); + }; + + this.onQuadClick = function(ev) { + var editor = this.editor; + + editor.selectAll(); + this.$clickSelection = editor.getSelectionRange(); + this.setState("selectAll"); + }; + + this.onMouseWheel = function(ev) { + if (ev.getAccelKey()) + return; + if (ev.getShiftKey() && ev.wheelY && !ev.wheelX) { + ev.wheelX = ev.wheelY; + ev.wheelY = 0; + } + + var t = ev.domEvent.timeStamp; + var dt = t - (this.$lastScrollTime||0); + + var editor = this.editor; + var isScrolable = editor.renderer.isScrollableBy(ev.wheelX * ev.speed, ev.wheelY * ev.speed); + if (isScrolable || dt < 200) { + this.$lastScrollTime = t; + editor.renderer.scrollBy(ev.wheelX * ev.speed, ev.wheelY * ev.speed); + return ev.stop(); + } + }; + +}).call(DefaultHandlers.prototype); + +exports.DefaultHandlers = DefaultHandlers; + +function calcDistance(ax, ay, bx, by) { + return Math.sqrt(Math.pow(bx - ax, 2) + Math.pow(by - ay, 2)); +} + +function calcRangeOrientation(range, cursor) { + if (range.start.row == range.end.row) + var cmp = 2 * cursor.column - range.start.column - range.end.column; + else if (range.start.row == range.end.row - 1 && !range.start.column && !range.end.column) + var cmp = cursor.column - 4; + else + var cmp = 2 * cursor.row - range.start.row - range.end.row; + + if (cmp < 0) + return {cursor: range.start, anchor: range.end}; + else + return {cursor: range.end, anchor: range.start}; +} + +}); + +define("ace/tooltip",["require","exports","module","ace/lib/oop","ace/lib/dom"], function(require, exports, module) { +"use strict"; + +var oop = require("./lib/oop"); +var dom = require("./lib/dom"); +function Tooltip (parentNode) { + this.isOpen = false; + this.$element = null; + this.$parentNode = parentNode; +} + +(function() { + this.$init = function() { + this.$element = dom.createElement("div"); + this.$element.className = "ace_tooltip"; + this.$element.style.display = "none"; + this.$parentNode.appendChild(this.$element); + return this.$element; + }; + this.getElement = function() { + return this.$element || this.$init(); + }; + this.setText = function(text) { + dom.setInnerText(this.getElement(), text); + }; + this.setHtml = function(html) { + this.getElement().innerHTML = html; + }; + this.setPosition = function(x, y) { + this.getElement().style.left = x + "px"; + this.getElement().style.top = y + "px"; + }; + this.setClassName = function(className) { + dom.addCssClass(this.getElement(), className); + }; + this.show = function(text, x, y) { + if (text != null) + this.setText(text); + if (x != null && y != null) + this.setPosition(x, y); + if (!this.isOpen) { + this.getElement().style.display = "block"; + this.isOpen = true; + } + }; + + this.hide = function() { + if (this.isOpen) { + this.getElement().style.display = "none"; + this.isOpen = false; + } + }; + this.getHeight = function() { + return this.getElement().offsetHeight; + }; + this.getWidth = function() { + return this.getElement().offsetWidth; + }; + +}).call(Tooltip.prototype); + +exports.Tooltip = Tooltip; +}); + +define("ace/mouse/default_gutter_handler",["require","exports","module","ace/lib/dom","ace/lib/oop","ace/lib/event","ace/tooltip"], function(require, exports, module) { +"use strict"; +var dom = require("../lib/dom"); +var oop = require("../lib/oop"); +var event = require("../lib/event"); +var Tooltip = require("../tooltip").Tooltip; + +function GutterHandler(mouseHandler) { + var editor = mouseHandler.editor; + var gutter = editor.renderer.$gutterLayer; + var tooltip = new GutterTooltip(editor.container); + + mouseHandler.editor.setDefaultHandler("guttermousedown", function(e) { + if (!editor.isFocused() || e.getButton() != 0) + return; + var gutterRegion = gutter.getRegion(e); + + if (gutterRegion == "foldWidgets") + return; + + var row = e.getDocumentPosition().row; + var selection = editor.session.selection; + + if (e.getShiftKey()) + selection.selectTo(row, 0); + else { + if (e.domEvent.detail == 2) { + editor.selectAll(); + return e.preventDefault(); + } + mouseHandler.$clickSelection = editor.selection.getLineRange(row); + } + mouseHandler.setState("selectByLines"); + mouseHandler.captureMouse(e); + return e.preventDefault(); + }); + + + var tooltipTimeout, mouseEvent, tooltipAnnotation; + + function showTooltip() { + var row = mouseEvent.getDocumentPosition().row; + var annotation = gutter.$annotations[row]; + if (!annotation) + return hideTooltip(); + + var maxRow = editor.session.getLength(); + if (row == maxRow) { + var screenRow = editor.renderer.pixelToScreenCoordinates(0, mouseEvent.y).row; + var pos = mouseEvent.$pos; + if (screenRow > editor.session.documentToScreenRow(pos.row, pos.column)) + return hideTooltip(); + } + + if (tooltipAnnotation == annotation) + return; + tooltipAnnotation = annotation.text.join("
"); + + tooltip.setHtml(tooltipAnnotation); + tooltip.show(); + editor.on("mousewheel", hideTooltip); + + if (mouseHandler.$tooltipFollowsMouse) { + moveTooltip(mouseEvent); + } else { + var gutterElement = gutter.$cells[editor.session.documentToScreenRow(row, 0)].element; + var rect = gutterElement.getBoundingClientRect(); + var style = tooltip.getElement().style; + style.left = rect.right + "px"; + style.top = rect.bottom + "px"; + } + } + + function hideTooltip() { + if (tooltipTimeout) + tooltipTimeout = clearTimeout(tooltipTimeout); + if (tooltipAnnotation) { + tooltip.hide(); + tooltipAnnotation = null; + editor.removeEventListener("mousewheel", hideTooltip); + } + } + + function moveTooltip(e) { + tooltip.setPosition(e.x, e.y); + } + + mouseHandler.editor.setDefaultHandler("guttermousemove", function(e) { + var target = e.domEvent.target || e.domEvent.srcElement; + if (dom.hasCssClass(target, "ace_fold-widget")) + return hideTooltip(); + + if (tooltipAnnotation && mouseHandler.$tooltipFollowsMouse) + moveTooltip(e); + + mouseEvent = e; + if (tooltipTimeout) + return; + tooltipTimeout = setTimeout(function() { + tooltipTimeout = null; + if (mouseEvent && !mouseHandler.isMousePressed) + showTooltip(); + else + hideTooltip(); + }, 50); + }); + + event.addListener(editor.renderer.$gutter, "mouseout", function(e) { + mouseEvent = null; + if (!tooltipAnnotation || tooltipTimeout) + return; + + tooltipTimeout = setTimeout(function() { + tooltipTimeout = null; + hideTooltip(); + }, 50); + }); + + editor.on("changeSession", hideTooltip); +} + +function GutterTooltip(parentNode) { + Tooltip.call(this, parentNode); +} + +oop.inherits(GutterTooltip, Tooltip); + +(function(){ + this.setPosition = function(x, y) { + var windowWidth = window.innerWidth || document.documentElement.clientWidth; + var windowHeight = window.innerHeight || document.documentElement.clientHeight; + var width = this.getWidth(); + var height = this.getHeight(); + x += 15; + y += 15; + if (x + width > windowWidth) { + x -= (x + width) - windowWidth; + } + if (y + height > windowHeight) { + y -= 20 + height; + } + Tooltip.prototype.setPosition.call(this, x, y); + }; + +}).call(GutterTooltip.prototype); + + + +exports.GutterHandler = GutterHandler; + +}); + +define("ace/mouse/mouse_event",["require","exports","module","ace/lib/event","ace/lib/useragent"], function(require, exports, module) { +"use strict"; + +var event = require("../lib/event"); +var useragent = require("../lib/useragent"); +var MouseEvent = exports.MouseEvent = function(domEvent, editor) { + this.domEvent = domEvent; + this.editor = editor; + + this.x = this.clientX = domEvent.clientX; + this.y = this.clientY = domEvent.clientY; + + this.$pos = null; + this.$inSelection = null; + + this.propagationStopped = false; + this.defaultPrevented = false; +}; + +(function() { + + this.stopPropagation = function() { + event.stopPropagation(this.domEvent); + this.propagationStopped = true; + }; + + this.preventDefault = function() { + event.preventDefault(this.domEvent); + this.defaultPrevented = true; + }; + + this.stop = function() { + this.stopPropagation(); + this.preventDefault(); + }; + this.getDocumentPosition = function() { + if (this.$pos) + return this.$pos; + + this.$pos = this.editor.renderer.screenToTextCoordinates(this.clientX, this.clientY); + return this.$pos; + }; + this.inSelection = function() { + if (this.$inSelection !== null) + return this.$inSelection; + + var editor = this.editor; + + + var selectionRange = editor.getSelectionRange(); + if (selectionRange.isEmpty()) + this.$inSelection = false; + else { + var pos = this.getDocumentPosition(); + this.$inSelection = selectionRange.contains(pos.row, pos.column); + } + + return this.$inSelection; + }; + this.getButton = function() { + return event.getButton(this.domEvent); + }; + this.getShiftKey = function() { + return this.domEvent.shiftKey; + }; + + this.getAccelKey = useragent.isMac + ? function() { return this.domEvent.metaKey; } + : function() { return this.domEvent.ctrlKey; }; + +}).call(MouseEvent.prototype); + +}); + +define("ace/mouse/dragdrop_handler",["require","exports","module","ace/lib/dom","ace/lib/event","ace/lib/useragent"], function(require, exports, module) { +"use strict"; + +var dom = require("../lib/dom"); +var event = require("../lib/event"); +var useragent = require("../lib/useragent"); + +var AUTOSCROLL_DELAY = 200; +var SCROLL_CURSOR_DELAY = 200; +var SCROLL_CURSOR_HYSTERESIS = 5; + +function DragdropHandler(mouseHandler) { + + var editor = mouseHandler.editor; + + var blankImage = dom.createElement("img"); + blankImage.src = "data:image/gif;base64,R0lGODlhAQABAAAAACH5BAEKAAEALAAAAAABAAEAAAICTAEAOw=="; + if (useragent.isOpera) + blankImage.style.cssText = "width:1px;height:1px;position:fixed;top:0;left:0;z-index:2147483647;opacity:0;"; + + var exports = ["dragWait", "dragWaitEnd", "startDrag", "dragReadyEnd", "onMouseDrag"]; + + exports.forEach(function(x) { + mouseHandler[x] = this[x]; + }, this); + editor.addEventListener("mousedown", this.onMouseDown.bind(mouseHandler)); + + + var mouseTarget = editor.container; + var dragSelectionMarker, x, y; + var timerId, range; + var dragCursor, counter = 0; + var dragOperation; + var isInternal; + var autoScrollStartTime; + var cursorMovedTime; + var cursorPointOnCaretMoved; + + this.onDragStart = function(e) { + if (this.cancelDrag || !mouseTarget.draggable) { + var self = this; + setTimeout(function(){ + self.startSelect(); + self.captureMouse(e); + }, 0); + return e.preventDefault(); + } + range = editor.getSelectionRange(); + + var dataTransfer = e.dataTransfer; + dataTransfer.effectAllowed = editor.getReadOnly() ? "copy" : "copyMove"; + if (useragent.isOpera) { + editor.container.appendChild(blankImage); + blankImage.scrollTop = 0; + } + dataTransfer.setDragImage && dataTransfer.setDragImage(blankImage, 0, 0); + if (useragent.isOpera) { + editor.container.removeChild(blankImage); + } + dataTransfer.clearData(); + dataTransfer.setData("Text", editor.session.getTextRange()); + + isInternal = true; + this.setState("drag"); + }; + + this.onDragEnd = function(e) { + mouseTarget.draggable = false; + isInternal = false; + this.setState(null); + if (!editor.getReadOnly()) { + var dropEffect = e.dataTransfer.dropEffect; + if (!dragOperation && dropEffect == "move") + editor.session.remove(editor.getSelectionRange()); + editor.renderer.$cursorLayer.setBlinking(true); + } + this.editor.unsetStyle("ace_dragging"); + this.editor.renderer.setCursorStyle(""); + }; + + this.onDragEnter = function(e) { + if (editor.getReadOnly() || !canAccept(e.dataTransfer)) + return; + x = e.clientX; + y = e.clientY; + if (!dragSelectionMarker) + addDragMarker(); + counter++; + e.dataTransfer.dropEffect = dragOperation = getDropEffect(e); + return event.preventDefault(e); + }; + + this.onDragOver = function(e) { + if (editor.getReadOnly() || !canAccept(e.dataTransfer)) + return; + x = e.clientX; + y = e.clientY; + if (!dragSelectionMarker) { + addDragMarker(); + counter++; + } + if (onMouseMoveTimer !== null) + onMouseMoveTimer = null; + + e.dataTransfer.dropEffect = dragOperation = getDropEffect(e); + return event.preventDefault(e); + }; + + this.onDragLeave = function(e) { + counter--; + if (counter <= 0 && dragSelectionMarker) { + clearDragMarker(); + dragOperation = null; + return event.preventDefault(e); + } + }; + + this.onDrop = function(e) { + if (!dragCursor) + return; + var dataTransfer = e.dataTransfer; + if (isInternal) { + switch (dragOperation) { + case "move": + if (range.contains(dragCursor.row, dragCursor.column)) { + range = { + start: dragCursor, + end: dragCursor + }; + } else { + range = editor.moveText(range, dragCursor); + } + break; + case "copy": + range = editor.moveText(range, dragCursor, true); + break; + } + } else { + var dropData = dataTransfer.getData('Text'); + range = { + start: dragCursor, + end: editor.session.insert(dragCursor, dropData) + }; + editor.focus(); + dragOperation = null; + } + clearDragMarker(); + return event.preventDefault(e); + }; + + event.addListener(mouseTarget, "dragstart", this.onDragStart.bind(mouseHandler)); + event.addListener(mouseTarget, "dragend", this.onDragEnd.bind(mouseHandler)); + event.addListener(mouseTarget, "dragenter", this.onDragEnter.bind(mouseHandler)); + event.addListener(mouseTarget, "dragover", this.onDragOver.bind(mouseHandler)); + event.addListener(mouseTarget, "dragleave", this.onDragLeave.bind(mouseHandler)); + event.addListener(mouseTarget, "drop", this.onDrop.bind(mouseHandler)); + + function scrollCursorIntoView(cursor, prevCursor) { + var now = Date.now(); + var vMovement = !prevCursor || cursor.row != prevCursor.row; + var hMovement = !prevCursor || cursor.column != prevCursor.column; + if (!cursorMovedTime || vMovement || hMovement) { + editor.$blockScrolling += 1; + editor.moveCursorToPosition(cursor); + editor.$blockScrolling -= 1; + cursorMovedTime = now; + cursorPointOnCaretMoved = {x: x, y: y}; + } else { + var distance = calcDistance(cursorPointOnCaretMoved.x, cursorPointOnCaretMoved.y, x, y); + if (distance > SCROLL_CURSOR_HYSTERESIS) { + cursorMovedTime = null; + } else if (now - cursorMovedTime >= SCROLL_CURSOR_DELAY) { + editor.renderer.scrollCursorIntoView(); + cursorMovedTime = null; + } + } + } + + function autoScroll(cursor, prevCursor) { + var now = Date.now(); + var lineHeight = editor.renderer.layerConfig.lineHeight; + var characterWidth = editor.renderer.layerConfig.characterWidth; + var editorRect = editor.renderer.scroller.getBoundingClientRect(); + var offsets = { + x: { + left: x - editorRect.left, + right: editorRect.right - x + }, + y: { + top: y - editorRect.top, + bottom: editorRect.bottom - y + } + }; + var nearestXOffset = Math.min(offsets.x.left, offsets.x.right); + var nearestYOffset = Math.min(offsets.y.top, offsets.y.bottom); + var scrollCursor = {row: cursor.row, column: cursor.column}; + if (nearestXOffset / characterWidth <= 2) { + scrollCursor.column += (offsets.x.left < offsets.x.right ? -3 : +2); + } + if (nearestYOffset / lineHeight <= 1) { + scrollCursor.row += (offsets.y.top < offsets.y.bottom ? -1 : +1); + } + var vScroll = cursor.row != scrollCursor.row; + var hScroll = cursor.column != scrollCursor.column; + var vMovement = !prevCursor || cursor.row != prevCursor.row; + if (vScroll || (hScroll && !vMovement)) { + if (!autoScrollStartTime) + autoScrollStartTime = now; + else if (now - autoScrollStartTime >= AUTOSCROLL_DELAY) + editor.renderer.scrollCursorIntoView(scrollCursor); + } else { + autoScrollStartTime = null; + } + } + + function onDragInterval() { + var prevCursor = dragCursor; + dragCursor = editor.renderer.screenToTextCoordinates(x, y); + scrollCursorIntoView(dragCursor, prevCursor); + autoScroll(dragCursor, prevCursor); + } + + function addDragMarker() { + range = editor.selection.toOrientedRange(); + dragSelectionMarker = editor.session.addMarker(range, "ace_selection", editor.getSelectionStyle()); + editor.clearSelection(); + if (editor.isFocused()) + editor.renderer.$cursorLayer.setBlinking(false); + clearInterval(timerId); + onDragInterval(); + timerId = setInterval(onDragInterval, 20); + counter = 0; + event.addListener(document, "mousemove", onMouseMove); + } + + function clearDragMarker() { + clearInterval(timerId); + editor.session.removeMarker(dragSelectionMarker); + dragSelectionMarker = null; + editor.$blockScrolling += 1; + editor.selection.fromOrientedRange(range); + editor.$blockScrolling -= 1; + if (editor.isFocused() && !isInternal) + editor.renderer.$cursorLayer.setBlinking(!editor.getReadOnly()); + range = null; + dragCursor = null; + counter = 0; + autoScrollStartTime = null; + cursorMovedTime = null; + event.removeListener(document, "mousemove", onMouseMove); + } + var onMouseMoveTimer = null; + function onMouseMove() { + if (onMouseMoveTimer == null) { + onMouseMoveTimer = setTimeout(function() { + if (onMouseMoveTimer != null && dragSelectionMarker) + clearDragMarker(); + }, 20); + } + } + + function canAccept(dataTransfer) { + var types = dataTransfer.types; + return !types || Array.prototype.some.call(types, function(type) { + return type == 'text/plain' || type == 'Text'; + }); + } + + function getDropEffect(e) { + var copyAllowed = ['copy', 'copymove', 'all', 'uninitialized']; + var moveAllowed = ['move', 'copymove', 'linkmove', 'all', 'uninitialized']; + + var copyModifierState = useragent.isMac ? e.altKey : e.ctrlKey; + var effectAllowed = "uninitialized"; + try { + effectAllowed = e.dataTransfer.effectAllowed.toLowerCase(); + } catch (e) {} + var dropEffect = "none"; + + if (copyModifierState && copyAllowed.indexOf(effectAllowed) >= 0) + dropEffect = "copy"; + else if (moveAllowed.indexOf(effectAllowed) >= 0) + dropEffect = "move"; + else if (copyAllowed.indexOf(effectAllowed) >= 0) + dropEffect = "copy"; + + return dropEffect; + } +} + +(function() { + + this.dragWait = function() { + var interval = Date.now() - this.mousedownEvent.time; + if (interval > this.editor.getDragDelay()) + this.startDrag(); + }; + + this.dragWaitEnd = function() { + var target = this.editor.container; + target.draggable = false; + this.startSelect(this.mousedownEvent.getDocumentPosition()); + this.selectEnd(); + }; + + this.dragReadyEnd = function(e) { + this.editor.renderer.$cursorLayer.setBlinking(!this.editor.getReadOnly()); + this.editor.unsetStyle("ace_dragging"); + this.editor.renderer.setCursorStyle(""); + this.dragWaitEnd(); + }; + + this.startDrag = function(){ + this.cancelDrag = false; + var editor = this.editor; + var target = editor.container; + target.draggable = true; + editor.renderer.$cursorLayer.setBlinking(false); + editor.setStyle("ace_dragging"); + var cursorStyle = useragent.isWin ? "default" : "move"; + editor.renderer.setCursorStyle(cursorStyle); + this.setState("dragReady"); + }; + + this.onMouseDrag = function(e) { + var target = this.editor.container; + if (useragent.isIE && this.state == "dragReady") { + var distance = calcDistance(this.mousedownEvent.x, this.mousedownEvent.y, this.x, this.y); + if (distance > 3) + target.dragDrop(); + } + if (this.state === "dragWait") { + var distance = calcDistance(this.mousedownEvent.x, this.mousedownEvent.y, this.x, this.y); + if (distance > 0) { + target.draggable = false; + this.startSelect(this.mousedownEvent.getDocumentPosition()); + } + } + }; + + this.onMouseDown = function(e) { + if (!this.$dragEnabled) + return; + this.mousedownEvent = e; + var editor = this.editor; + + var inSelection = e.inSelection(); + var button = e.getButton(); + var clickCount = e.domEvent.detail || 1; + if (clickCount === 1 && button === 0 && inSelection) { + if (e.editor.inMultiSelectMode && (e.getAccelKey() || e.getShiftKey())) + return; + this.mousedownEvent.time = Date.now(); + var eventTarget = e.domEvent.target || e.domEvent.srcElement; + if ("unselectable" in eventTarget) + eventTarget.unselectable = "on"; + if (editor.getDragDelay()) { + if (useragent.isWebKit) { + this.cancelDrag = true; + var mouseTarget = editor.container; + mouseTarget.draggable = true; + } + this.setState("dragWait"); + } else { + this.startDrag(); + } + this.captureMouse(e, this.onMouseDrag.bind(this)); + e.defaultPrevented = true; + } + }; + +}).call(DragdropHandler.prototype); + + +function calcDistance(ax, ay, bx, by) { + return Math.sqrt(Math.pow(bx - ax, 2) + Math.pow(by - ay, 2)); +} + +exports.DragdropHandler = DragdropHandler; + +}); + +define("ace/lib/net",["require","exports","module","ace/lib/dom"], function(require, exports, module) { +"use strict"; +var dom = require("./dom"); + +exports.get = function (url, callback) { + var xhr = new XMLHttpRequest(); + xhr.open('GET', url, true); + xhr.onreadystatechange = function () { + if (xhr.readyState === 4) { + callback(xhr.responseText); + } + }; + xhr.send(null); +}; + +exports.loadScript = function(path, callback) { + var head = dom.getDocumentHead(); + var s = document.createElement('script'); + + s.src = path; + head.appendChild(s); + + s.onload = s.onreadystatechange = function(_, isAbort) { + if (isAbort || !s.readyState || s.readyState == "loaded" || s.readyState == "complete") { + s = s.onload = s.onreadystatechange = null; + if (!isAbort) + callback(); + } + }; +}; +exports.qualifyURL = function(url) { + var a = document.createElement('a'); + a.href = url; + return a.href; +} + +}); + +define("ace/lib/event_emitter",["require","exports","module"], function(require, exports, module) { +"use strict"; + +var EventEmitter = {}; +var stopPropagation = function() { this.propagationStopped = true; }; +var preventDefault = function() { this.defaultPrevented = true; }; + +EventEmitter._emit = +EventEmitter._dispatchEvent = function(eventName, e) { + this._eventRegistry || (this._eventRegistry = {}); + this._defaultHandlers || (this._defaultHandlers = {}); + + var listeners = this._eventRegistry[eventName] || []; + var defaultHandler = this._defaultHandlers[eventName]; + if (!listeners.length && !defaultHandler) + return; + + if (typeof e != "object" || !e) + e = {}; + + if (!e.type) + e.type = eventName; + if (!e.stopPropagation) + e.stopPropagation = stopPropagation; + if (!e.preventDefault) + e.preventDefault = preventDefault; + + listeners = listeners.slice(); + for (var i=0; i 1) + base = parts[parts.length - 2]; + var path = options[component + "Path"]; + if (path == null) { + path = options.basePath; + } else if (sep == "/") { + component = sep = ""; + } + if (path && path.slice(-1) != "/") + path += "/"; + return path + component + sep + base + this.get("suffix"); +}; + +exports.setModuleUrl = function(name, subst) { + return options.$moduleUrls[name] = subst; +}; + +exports.$loading = {}; +exports.loadModule = function(moduleName, onLoad) { + var module, moduleType; + if (Array.isArray(moduleName)) { + moduleType = moduleName[0]; + moduleName = moduleName[1]; + } + + try { + module = require(moduleName); + } catch (e) {} + if (module && !exports.$loading[moduleName]) + return onLoad && onLoad(module); + + if (!exports.$loading[moduleName]) + exports.$loading[moduleName] = []; + + exports.$loading[moduleName].push(onLoad); + + if (exports.$loading[moduleName].length > 1) + return; + + var afterLoad = function() { + require([moduleName], function(module) { + exports._emit("load.module", {name: moduleName, module: module}); + var listeners = exports.$loading[moduleName]; + exports.$loading[moduleName] = null; + listeners.forEach(function(onLoad) { + onLoad && onLoad(module); + }); + }); + }; + + if (!exports.get("packaged")) + return afterLoad(); + net.loadScript(exports.moduleUrl(moduleName, moduleType), afterLoad); +}; +init(true);function init(packaged) { + + options.packaged = packaged || require.packaged || module.packaged || (global.define && define.packaged); + + if (!global.document) + return ""; + + var scriptOptions = {}; + var scriptUrl = ""; + var currentScript = (document.currentScript || document._currentScript ); // native or polyfill + var currentDocument = currentScript && currentScript.ownerDocument || document; + + var scripts = currentDocument.getElementsByTagName("script"); + for (var i=0; i [" + this.end.row + "/" + this.end.column + "]"); + }; + + this.contains = function(row, column) { + return this.compare(row, column) == 0; + }; + this.compareRange = function(range) { + var cmp, + end = range.end, + start = range.start; + + cmp = this.compare(end.row, end.column); + if (cmp == 1) { + cmp = this.compare(start.row, start.column); + if (cmp == 1) { + return 2; + } else if (cmp == 0) { + return 1; + } else { + return 0; + } + } else if (cmp == -1) { + return -2; + } else { + cmp = this.compare(start.row, start.column); + if (cmp == -1) { + return -1; + } else if (cmp == 1) { + return 42; + } else { + return 0; + } + } + }; + this.comparePoint = function(p) { + return this.compare(p.row, p.column); + }; + this.containsRange = function(range) { + return this.comparePoint(range.start) == 0 && this.comparePoint(range.end) == 0; + }; + this.intersects = function(range) { + var cmp = this.compareRange(range); + return (cmp == -1 || cmp == 0 || cmp == 1); + }; + this.isEnd = function(row, column) { + return this.end.row == row && this.end.column == column; + }; + this.isStart = function(row, column) { + return this.start.row == row && this.start.column == column; + }; + this.setStart = function(row, column) { + if (typeof row == "object") { + this.start.column = row.column; + this.start.row = row.row; + } else { + this.start.row = row; + this.start.column = column; + } + }; + this.setEnd = function(row, column) { + if (typeof row == "object") { + this.end.column = row.column; + this.end.row = row.row; + } else { + this.end.row = row; + this.end.column = column; + } + }; + this.inside = function(row, column) { + if (this.compare(row, column) == 0) { + if (this.isEnd(row, column) || this.isStart(row, column)) { + return false; + } else { + return true; + } + } + return false; + }; + this.insideStart = function(row, column) { + if (this.compare(row, column) == 0) { + if (this.isEnd(row, column)) { + return false; + } else { + return true; + } + } + return false; + }; + this.insideEnd = function(row, column) { + if (this.compare(row, column) == 0) { + if (this.isStart(row, column)) { + return false; + } else { + return true; + } + } + return false; + }; + this.compare = function(row, column) { + if (!this.isMultiLine()) { + if (row === this.start.row) { + return column < this.start.column ? -1 : (column > this.end.column ? 1 : 0); + }; + } + + if (row < this.start.row) + return -1; + + if (row > this.end.row) + return 1; + + if (this.start.row === row) + return column >= this.start.column ? 0 : -1; + + if (this.end.row === row) + return column <= this.end.column ? 0 : 1; + + return 0; + }; + this.compareStart = function(row, column) { + if (this.start.row == row && this.start.column == column) { + return -1; + } else { + return this.compare(row, column); + } + }; + this.compareEnd = function(row, column) { + if (this.end.row == row && this.end.column == column) { + return 1; + } else { + return this.compare(row, column); + } + }; + this.compareInside = function(row, column) { + if (this.end.row == row && this.end.column == column) { + return 1; + } else if (this.start.row == row && this.start.column == column) { + return -1; + } else { + return this.compare(row, column); + } + }; + this.clipRows = function(firstRow, lastRow) { + if (this.end.row > lastRow) + var end = {row: lastRow + 1, column: 0}; + else if (this.end.row < firstRow) + var end = {row: firstRow, column: 0}; + + if (this.start.row > lastRow) + var start = {row: lastRow + 1, column: 0}; + else if (this.start.row < firstRow) + var start = {row: firstRow, column: 0}; + + return Range.fromPoints(start || this.start, end || this.end); + }; + this.extend = function(row, column) { + var cmp = this.compare(row, column); + + if (cmp == 0) + return this; + else if (cmp == -1) + var start = {row: row, column: column}; + else + var end = {row: row, column: column}; + + return Range.fromPoints(start || this.start, end || this.end); + }; + + this.isEmpty = function() { + return (this.start.row === this.end.row && this.start.column === this.end.column); + }; + this.isMultiLine = function() { + return (this.start.row !== this.end.row); + }; + this.clone = function() { + return Range.fromPoints(this.start, this.end); + }; + this.collapseRows = function() { + if (this.end.column == 0) + return new Range(this.start.row, 0, Math.max(this.start.row, this.end.row-1), 0) + else + return new Range(this.start.row, 0, this.end.row, 0) + }; + this.toScreenRange = function(session) { + var screenPosStart = session.documentToScreenPosition(this.start); + var screenPosEnd = session.documentToScreenPosition(this.end); + + return new Range( + screenPosStart.row, screenPosStart.column, + screenPosEnd.row, screenPosEnd.column + ); + }; + this.moveBy = function(row, column) { + this.start.row += row; + this.start.column += column; + this.end.row += row; + this.end.column += column; + }; + +}).call(Range.prototype); +Range.fromPoints = function(start, end) { + return new Range(start.row, start.column, end.row, end.column); +}; +Range.comparePoints = comparePoints; + +Range.comparePoints = function(p1, p2) { + return p1.row - p2.row || p1.column - p2.column; +}; + + +exports.Range = Range; +}); + +define("ace/selection",["require","exports","module","ace/lib/oop","ace/lib/lang","ace/lib/event_emitter","ace/range"], function(require, exports, module) { +"use strict"; + +var oop = require("./lib/oop"); +var lang = require("./lib/lang"); +var EventEmitter = require("./lib/event_emitter").EventEmitter; +var Range = require("./range").Range; +var Selection = function(session) { + this.session = session; + this.doc = session.getDocument(); + + this.clearSelection(); + this.lead = this.selectionLead = this.doc.createAnchor(0, 0); + this.anchor = this.selectionAnchor = this.doc.createAnchor(0, 0); + + var self = this; + this.lead.on("change", function(e) { + self._emit("changeCursor"); + if (!self.$isEmpty) + self._emit("changeSelection"); + if (!self.$keepDesiredColumnOnChange && e.old.column != e.value.column) + self.$desiredColumn = null; + }); + + this.selectionAnchor.on("change", function() { + if (!self.$isEmpty) + self._emit("changeSelection"); + }); +}; + +(function() { + + oop.implement(this, EventEmitter); + this.isEmpty = function() { + return (this.$isEmpty || ( + this.anchor.row == this.lead.row && + this.anchor.column == this.lead.column + )); + }; + this.isMultiLine = function() { + if (this.isEmpty()) { + return false; + } + + return this.getRange().isMultiLine(); + }; + this.getCursor = function() { + return this.lead.getPosition(); + }; + this.setSelectionAnchor = function(row, column) { + this.anchor.setPosition(row, column); + + if (this.$isEmpty) { + this.$isEmpty = false; + this._emit("changeSelection"); + } + }; + this.getSelectionAnchor = function() { + if (this.$isEmpty) + return this.getSelectionLead(); + else + return this.anchor.getPosition(); + }; + this.getSelectionLead = function() { + return this.lead.getPosition(); + }; + this.shiftSelection = function(columns) { + if (this.$isEmpty) { + this.moveCursorTo(this.lead.row, this.lead.column + columns); + return; + } + + var anchor = this.getSelectionAnchor(); + var lead = this.getSelectionLead(); + + var isBackwards = this.isBackwards(); + + if (!isBackwards || anchor.column !== 0) + this.setSelectionAnchor(anchor.row, anchor.column + columns); + + if (isBackwards || lead.column !== 0) { + this.$moveSelection(function() { + this.moveCursorTo(lead.row, lead.column + columns); + }); + } + }; + this.isBackwards = function() { + var anchor = this.anchor; + var lead = this.lead; + return (anchor.row > lead.row || (anchor.row == lead.row && anchor.column > lead.column)); + }; + this.getRange = function() { + var anchor = this.anchor; + var lead = this.lead; + + if (this.isEmpty()) + return Range.fromPoints(lead, lead); + + if (this.isBackwards()) { + return Range.fromPoints(lead, anchor); + } + else { + return Range.fromPoints(anchor, lead); + } + }; + this.clearSelection = function() { + if (!this.$isEmpty) { + this.$isEmpty = true; + this._emit("changeSelection"); + } + }; + this.selectAll = function() { + var lastRow = this.doc.getLength() - 1; + this.setSelectionAnchor(0, 0); + this.moveCursorTo(lastRow, this.doc.getLine(lastRow).length); + }; + this.setRange = + this.setSelectionRange = function(range, reverse) { + if (reverse) { + this.setSelectionAnchor(range.end.row, range.end.column); + this.selectTo(range.start.row, range.start.column); + } else { + this.setSelectionAnchor(range.start.row, range.start.column); + this.selectTo(range.end.row, range.end.column); + } + if (this.getRange().isEmpty()) + this.$isEmpty = true; + this.$desiredColumn = null; + }; + + this.$moveSelection = function(mover) { + var lead = this.lead; + if (this.$isEmpty) + this.setSelectionAnchor(lead.row, lead.column); + + mover.call(this); + }; + this.selectTo = function(row, column) { + this.$moveSelection(function() { + this.moveCursorTo(row, column); + }); + }; + this.selectToPosition = function(pos) { + this.$moveSelection(function() { + this.moveCursorToPosition(pos); + }); + }; + this.moveTo = function(row, column) { + this.clearSelection(); + this.moveCursorTo(row, column); + }; + this.moveToPosition = function(pos) { + this.clearSelection(); + this.moveCursorToPosition(pos); + }; + this.selectUp = function() { + this.$moveSelection(this.moveCursorUp); + }; + this.selectDown = function() { + this.$moveSelection(this.moveCursorDown); + }; + this.selectRight = function() { + this.$moveSelection(this.moveCursorRight); + }; + this.selectLeft = function() { + this.$moveSelection(this.moveCursorLeft); + }; + this.selectLineStart = function() { + this.$moveSelection(this.moveCursorLineStart); + }; + this.selectLineEnd = function() { + this.$moveSelection(this.moveCursorLineEnd); + }; + this.selectFileEnd = function() { + this.$moveSelection(this.moveCursorFileEnd); + }; + this.selectFileStart = function() { + this.$moveSelection(this.moveCursorFileStart); + }; + this.selectWordRight = function() { + this.$moveSelection(this.moveCursorWordRight); + }; + this.selectWordLeft = function() { + this.$moveSelection(this.moveCursorWordLeft); + }; + this.getWordRange = function(row, column) { + if (typeof column == "undefined") { + var cursor = row || this.lead; + row = cursor.row; + column = cursor.column; + } + return this.session.getWordRange(row, column); + }; + this.selectWord = function() { + this.setSelectionRange(this.getWordRange()); + }; + this.selectAWord = function() { + var cursor = this.getCursor(); + var range = this.session.getAWordRange(cursor.row, cursor.column); + this.setSelectionRange(range); + }; + + this.getLineRange = function(row, excludeLastChar) { + var rowStart = typeof row == "number" ? row : this.lead.row; + var rowEnd; + + var foldLine = this.session.getFoldLine(rowStart); + if (foldLine) { + rowStart = foldLine.start.row; + rowEnd = foldLine.end.row; + } else { + rowEnd = rowStart; + } + if (excludeLastChar === true) + return new Range(rowStart, 0, rowEnd, this.session.getLine(rowEnd).length); + else + return new Range(rowStart, 0, rowEnd + 1, 0); + }; + this.selectLine = function() { + this.setSelectionRange(this.getLineRange()); + }; + this.moveCursorUp = function() { + this.moveCursorBy(-1, 0); + }; + this.moveCursorDown = function() { + this.moveCursorBy(1, 0); + }; + this.moveCursorLeft = function() { + var cursor = this.lead.getPosition(), + fold; + + if (fold = this.session.getFoldAt(cursor.row, cursor.column, -1)) { + this.moveCursorTo(fold.start.row, fold.start.column); + } else if (cursor.column === 0) { + if (cursor.row > 0) { + this.moveCursorTo(cursor.row - 1, this.doc.getLine(cursor.row - 1).length); + } + } + else { + var tabSize = this.session.getTabSize(); + if (this.session.isTabStop(cursor) && this.doc.getLine(cursor.row).slice(cursor.column-tabSize, cursor.column).split(" ").length-1 == tabSize) + this.moveCursorBy(0, -tabSize); + else + this.moveCursorBy(0, -1); + } + }; + this.moveCursorRight = function() { + var cursor = this.lead.getPosition(), + fold; + if (fold = this.session.getFoldAt(cursor.row, cursor.column, 1)) { + this.moveCursorTo(fold.end.row, fold.end.column); + } + else if (this.lead.column == this.doc.getLine(this.lead.row).length) { + if (this.lead.row < this.doc.getLength() - 1) { + this.moveCursorTo(this.lead.row + 1, 0); + } + } + else { + var tabSize = this.session.getTabSize(); + var cursor = this.lead; + if (this.session.isTabStop(cursor) && this.doc.getLine(cursor.row).slice(cursor.column, cursor.column+tabSize).split(" ").length-1 == tabSize) + this.moveCursorBy(0, tabSize); + else + this.moveCursorBy(0, 1); + } + }; + this.moveCursorLineStart = function() { + var row = this.lead.row; + var column = this.lead.column; + var screenRow = this.session.documentToScreenRow(row, column); + var firstColumnPosition = this.session.screenToDocumentPosition(screenRow, 0); + var beforeCursor = this.session.getDisplayLine( + row, null, firstColumnPosition.row, + firstColumnPosition.column + ); + + var leadingSpace = beforeCursor.match(/^\s*/); + if (leadingSpace[0].length != column && !this.session.$useEmacsStyleLineStart) + firstColumnPosition.column += leadingSpace[0].length; + this.moveCursorToPosition(firstColumnPosition); + }; + this.moveCursorLineEnd = function() { + var lead = this.lead; + var lineEnd = this.session.getDocumentLastRowColumnPosition(lead.row, lead.column); + if (this.lead.column == lineEnd.column) { + var line = this.session.getLine(lineEnd.row); + if (lineEnd.column == line.length) { + var textEnd = line.search(/\s+$/); + if (textEnd > 0) + lineEnd.column = textEnd; + } + } + + this.moveCursorTo(lineEnd.row, lineEnd.column); + }; + this.moveCursorFileEnd = function() { + var row = this.doc.getLength() - 1; + var column = this.doc.getLine(row).length; + this.moveCursorTo(row, column); + }; + this.moveCursorFileStart = function() { + this.moveCursorTo(0, 0); + }; + this.moveCursorLongWordRight = function() { + var row = this.lead.row; + var column = this.lead.column; + var line = this.doc.getLine(row); + var rightOfCursor = line.substring(column); + + var match; + this.session.nonTokenRe.lastIndex = 0; + this.session.tokenRe.lastIndex = 0; + var fold = this.session.getFoldAt(row, column, 1); + if (fold) { + this.moveCursorTo(fold.end.row, fold.end.column); + return; + } + if (match = this.session.nonTokenRe.exec(rightOfCursor)) { + column += this.session.nonTokenRe.lastIndex; + this.session.nonTokenRe.lastIndex = 0; + rightOfCursor = line.substring(column); + } + if (column >= line.length) { + this.moveCursorTo(row, line.length); + this.moveCursorRight(); + if (row < this.doc.getLength() - 1) + this.moveCursorWordRight(); + return; + } + if (match = this.session.tokenRe.exec(rightOfCursor)) { + column += this.session.tokenRe.lastIndex; + this.session.tokenRe.lastIndex = 0; + } + + this.moveCursorTo(row, column); + }; + this.moveCursorLongWordLeft = function() { + var row = this.lead.row; + var column = this.lead.column; + var fold; + if (fold = this.session.getFoldAt(row, column, -1)) { + this.moveCursorTo(fold.start.row, fold.start.column); + return; + } + + var str = this.session.getFoldStringAt(row, column, -1); + if (str == null) { + str = this.doc.getLine(row).substring(0, column); + } + + var leftOfCursor = lang.stringReverse(str); + var match; + this.session.nonTokenRe.lastIndex = 0; + this.session.tokenRe.lastIndex = 0; + if (match = this.session.nonTokenRe.exec(leftOfCursor)) { + column -= this.session.nonTokenRe.lastIndex; + leftOfCursor = leftOfCursor.slice(this.session.nonTokenRe.lastIndex); + this.session.nonTokenRe.lastIndex = 0; + } + if (column <= 0) { + this.moveCursorTo(row, 0); + this.moveCursorLeft(); + if (row > 0) + this.moveCursorWordLeft(); + return; + } + if (match = this.session.tokenRe.exec(leftOfCursor)) { + column -= this.session.tokenRe.lastIndex; + this.session.tokenRe.lastIndex = 0; + } + + this.moveCursorTo(row, column); + }; + + this.$shortWordEndIndex = function(rightOfCursor) { + var match, index = 0, ch; + var whitespaceRe = /\s/; + var tokenRe = this.session.tokenRe; + + tokenRe.lastIndex = 0; + if (match = this.session.tokenRe.exec(rightOfCursor)) { + index = this.session.tokenRe.lastIndex; + } else { + while ((ch = rightOfCursor[index]) && whitespaceRe.test(ch)) + index ++; + + if (index < 1) { + tokenRe.lastIndex = 0; + while ((ch = rightOfCursor[index]) && !tokenRe.test(ch)) { + tokenRe.lastIndex = 0; + index ++; + if (whitespaceRe.test(ch)) { + if (index > 2) { + index--; + break; + } else { + while ((ch = rightOfCursor[index]) && whitespaceRe.test(ch)) + index ++; + if (index > 2) + break; + } + } + } + } + } + tokenRe.lastIndex = 0; + + return index; + }; + + this.moveCursorShortWordRight = function() { + var row = this.lead.row; + var column = this.lead.column; + var line = this.doc.getLine(row); + var rightOfCursor = line.substring(column); + + var fold = this.session.getFoldAt(row, column, 1); + if (fold) + return this.moveCursorTo(fold.end.row, fold.end.column); + + if (column == line.length) { + var l = this.doc.getLength(); + do { + row++; + rightOfCursor = this.doc.getLine(row); + } while (row < l && /^\s*$/.test(rightOfCursor)); + + if (!/^\s+/.test(rightOfCursor)) + rightOfCursor = ""; + column = 0; + } + + var index = this.$shortWordEndIndex(rightOfCursor); + + this.moveCursorTo(row, column + index); + }; + + this.moveCursorShortWordLeft = function() { + var row = this.lead.row; + var column = this.lead.column; + + var fold; + if (fold = this.session.getFoldAt(row, column, -1)) + return this.moveCursorTo(fold.start.row, fold.start.column); + + var line = this.session.getLine(row).substring(0, column); + if (column === 0) { + do { + row--; + line = this.doc.getLine(row); + } while (row > 0 && /^\s*$/.test(line)); + + column = line.length; + if (!/\s+$/.test(line)) + line = ""; + } + + var leftOfCursor = lang.stringReverse(line); + var index = this.$shortWordEndIndex(leftOfCursor); + + return this.moveCursorTo(row, column - index); + }; + + this.moveCursorWordRight = function() { + if (this.session.$selectLongWords) + this.moveCursorLongWordRight(); + else + this.moveCursorShortWordRight(); + }; + + this.moveCursorWordLeft = function() { + if (this.session.$selectLongWords) + this.moveCursorLongWordLeft(); + else + this.moveCursorShortWordLeft(); + }; + this.moveCursorBy = function(rows, chars) { + var screenPos = this.session.documentToScreenPosition( + this.lead.row, + this.lead.column + ); + + if (chars === 0) { + if (this.$desiredColumn) + screenPos.column = this.$desiredColumn; + else + this.$desiredColumn = screenPos.column; + } + + var docPos = this.session.screenToDocumentPosition(screenPos.row + rows, screenPos.column); + + if (rows !== 0 && chars === 0 && docPos.row === this.lead.row && docPos.column === this.lead.column) { + if (this.session.lineWidgets && this.session.lineWidgets[docPos.row]) + docPos.row++; + } + this.moveCursorTo(docPos.row, docPos.column + chars, chars === 0); + }; + this.moveCursorToPosition = function(position) { + this.moveCursorTo(position.row, position.column); + }; + this.moveCursorTo = function(row, column, keepDesiredColumn) { + var fold = this.session.getFoldAt(row, column, 1); + if (fold) { + row = fold.start.row; + column = fold.start.column; + } + + this.$keepDesiredColumnOnChange = true; + this.lead.setPosition(row, column); + this.$keepDesiredColumnOnChange = false; + + if (!keepDesiredColumn) + this.$desiredColumn = null; + }; + this.moveCursorToScreen = function(row, column, keepDesiredColumn) { + var pos = this.session.screenToDocumentPosition(row, column); + this.moveCursorTo(pos.row, pos.column, keepDesiredColumn); + }; + this.detach = function() { + this.lead.detach(); + this.anchor.detach(); + this.session = this.doc = null; + }; + + this.fromOrientedRange = function(range) { + this.setSelectionRange(range, range.cursor == range.start); + this.$desiredColumn = range.desiredColumn || this.$desiredColumn; + }; + + this.toOrientedRange = function(range) { + var r = this.getRange(); + if (range) { + range.start.column = r.start.column; + range.start.row = r.start.row; + range.end.column = r.end.column; + range.end.row = r.end.row; + } else { + range = r; + } + + range.cursor = this.isBackwards() ? range.start : range.end; + range.desiredColumn = this.$desiredColumn; + return range; + }; + this.getRangeOfMovements = function(func) { + var start = this.getCursor(); + try { + func.call(null, this); + var end = this.getCursor(); + return Range.fromPoints(start,end); + } catch(e) { + return Range.fromPoints(start,start); + } finally { + this.moveCursorToPosition(start); + } + }; + + this.toJSON = function() { + if (this.rangeCount) { + var data = this.ranges.map(function(r) { + var r1 = r.clone(); + r1.isBackwards = r.cursor == r.start; + return r1; + }); + } else { + var data = this.getRange(); + data.isBackwards = this.isBackwards(); + } + return data; + }; + + this.fromJSON = function(data) { + if (data.start == undefined) { + if (this.rangeList) { + this.toSingleRange(data[0]); + for (var i = data.length; i--; ) { + var r = Range.fromPoints(data[i].start, data[i].end); + if (data.isBackwards) + r.cursor = r.start; + this.addRange(r, true); + } + return; + } else + data = data[0]; + } + if (this.rangeList) + this.toSingleRange(data); + this.setSelectionRange(data, data.isBackwards); + }; + + this.isEqual = function(data) { + if ((data.length || this.rangeCount) && data.length != this.rangeCount) + return false; + if (!data.length || !this.ranges) + return this.getRange().isEqual(data); + + for (var i = this.ranges.length; i--; ) { + if (!this.ranges[i].isEqual(data[i])) + return false; + } + return true; + }; + +}).call(Selection.prototype); + +exports.Selection = Selection; +}); + +define("ace/tokenizer",["require","exports","module","ace/config"], function(require, exports, module) { +"use strict"; + +var config = require("./config"); +var MAX_TOKEN_COUNT = 2000; +var Tokenizer = function(rules) { + this.states = rules; + + this.regExps = {}; + this.matchMappings = {}; + for (var key in this.states) { + var state = this.states[key]; + var ruleRegExps = []; + var matchTotal = 0; + var mapping = this.matchMappings[key] = {defaultToken: "text"}; + var flag = "g"; + + var splitterRurles = []; + for (var i = 0; i < state.length; i++) { + var rule = state[i]; + if (rule.defaultToken) + mapping.defaultToken = rule.defaultToken; + if (rule.caseInsensitive) + flag = "gi"; + if (rule.regex == null) + continue; + + if (rule.regex instanceof RegExp) + rule.regex = rule.regex.toString().slice(1, -1); + var adjustedregex = rule.regex; + var matchcount = new RegExp("(?:(" + adjustedregex + ")|(.))").exec("a").length - 2; + if (Array.isArray(rule.token)) { + if (rule.token.length == 1 || matchcount == 1) { + rule.token = rule.token[0]; + } else if (matchcount - 1 != rule.token.length) { + this.reportError("number of classes and regexp groups doesn't match", { + rule: rule, + groupCount: matchcount - 1 + }); + rule.token = rule.token[0]; + } else { + rule.tokenArray = rule.token; + rule.token = null; + rule.onMatch = this.$arrayTokens; + } + } else if (typeof rule.token == "function" && !rule.onMatch) { + if (matchcount > 1) + rule.onMatch = this.$applyToken; + else + rule.onMatch = rule.token; + } + + if (matchcount > 1) { + if (/\\\d/.test(rule.regex)) { + adjustedregex = rule.regex.replace(/\\([0-9]+)/g, function(match, digit) { + return "\\" + (parseInt(digit, 10) + matchTotal + 1); + }); + } else { + matchcount = 1; + adjustedregex = this.removeCapturingGroups(rule.regex); + } + if (!rule.splitRegex && typeof rule.token != "string") + splitterRurles.push(rule); // flag will be known only at the very end + } + + mapping[matchTotal] = i; + matchTotal += matchcount; + + ruleRegExps.push(adjustedregex); + if (!rule.onMatch) + rule.onMatch = null; + } + + if (!ruleRegExps.length) { + mapping[0] = 0; + ruleRegExps.push("$"); + } + + splitterRurles.forEach(function(rule) { + rule.splitRegex = this.createSplitterRegexp(rule.regex, flag); + }, this); + + this.regExps[key] = new RegExp("(" + ruleRegExps.join(")|(") + ")|($)", flag); + } +}; + +(function() { + this.$setMaxTokenCount = function(m) { + MAX_TOKEN_COUNT = m | 0; + }; + + this.$applyToken = function(str) { + var values = this.splitRegex.exec(str).slice(1); + var types = this.token.apply(this, values); + if (typeof types === "string") + return [{type: types, value: str}]; + + var tokens = []; + for (var i = 0, l = types.length; i < l; i++) { + if (values[i]) + tokens[tokens.length] = { + type: types[i], + value: values[i] + }; + } + return tokens; + }, + + this.$arrayTokens = function(str) { + if (!str) + return []; + var values = this.splitRegex.exec(str); + if (!values) + return "text"; + var tokens = []; + var types = this.tokenArray; + for (var i = 0, l = types.length; i < l; i++) { + if (values[i + 1]) + tokens[tokens.length] = { + type: types[i], + value: values[i + 1] + }; + } + return tokens; + }; + + this.removeCapturingGroups = function(src) { + var r = src.replace( + /\[(?:\\.|[^\]])*?\]|\\.|\(\?[:=!]|(\()/g, + function(x, y) {return y ? "(?:" : x;} + ); + return r; + }; + + this.createSplitterRegexp = function(src, flag) { + if (src.indexOf("(?=") != -1) { + var stack = 0; + var inChClass = false; + var lastCapture = {}; + src.replace(/(\\.)|(\((?:\?[=!])?)|(\))|([\[\]])/g, function( + m, esc, parenOpen, parenClose, square, index + ) { + if (inChClass) { + inChClass = square != "]"; + } else if (square) { + inChClass = true; + } else if (parenClose) { + if (stack == lastCapture.stack) { + lastCapture.end = index+1; + lastCapture.stack = -1; + } + stack--; + } else if (parenOpen) { + stack++; + if (parenOpen.length != 1) { + lastCapture.stack = stack + lastCapture.start = index; + } + } + return m; + }); + + if (lastCapture.end != null && /^\)*$/.test(src.substr(lastCapture.end))) + src = src.substring(0, lastCapture.start) + src.substr(lastCapture.end); + } + return new RegExp(src, (flag||"").replace("g", "")); + }; + this.getLineTokens = function(line, startState) { + if (startState && typeof startState != "string") { + var stack = startState.slice(0); + startState = stack[0]; + if (startState === "#tmp") { + stack.shift() + startState = stack.shift() + } + } else + var stack = []; + + var currentState = startState || "start"; + var state = this.states[currentState]; + if (!state) { + currentState = "start"; + state = this.states[currentState]; + } + var mapping = this.matchMappings[currentState]; + var re = this.regExps[currentState]; + re.lastIndex = 0; + + var match, tokens = []; + var lastIndex = 0; + var matchAttempts = 0; + + var token = {type: null, value: ""}; + + while (match = re.exec(line)) { + var type = mapping.defaultToken; + var rule = null; + var value = match[0]; + var index = re.lastIndex; + + if (index - value.length > lastIndex) { + var skipped = line.substring(lastIndex, index - value.length); + if (token.type == type) { + token.value += skipped; + } else { + if (token.type) + tokens.push(token); + token = {type: type, value: skipped}; + } + } + + for (var i = 0; i < match.length-2; i++) { + if (match[i + 1] === undefined) + continue; + + rule = state[mapping[i]]; + + if (rule.onMatch) + type = rule.onMatch(value, currentState, stack); + else + type = rule.token; + + if (rule.next) { + if (typeof rule.next == "string") { + currentState = rule.next; + } else { + currentState = rule.next(currentState, stack); + } + + state = this.states[currentState]; + if (!state) { + this.reportError("state doesn't exist", currentState); + currentState = "start"; + state = this.states[currentState]; + } + mapping = this.matchMappings[currentState]; + lastIndex = index; + re = this.regExps[currentState]; + re.lastIndex = index; + } + break; + } + + if (value) { + if (typeof type === "string") { + if ((!rule || rule.merge !== false) && token.type === type) { + token.value += value; + } else { + if (token.type) + tokens.push(token); + token = {type: type, value: value}; + } + } else if (type) { + if (token.type) + tokens.push(token); + token = {type: null, value: ""}; + for (var i = 0; i < type.length; i++) + tokens.push(type[i]); + } + } + + if (lastIndex == line.length) + break; + + lastIndex = index; + + if (matchAttempts++ > MAX_TOKEN_COUNT) { + if (matchAttempts > 2 * line.length) { + this.reportError("infinite loop with in ace tokenizer", { + startState: startState, + line: line + }); + } + while (lastIndex < line.length) { + if (token.type) + tokens.push(token); + token = { + value: line.substring(lastIndex, lastIndex += 2000), + type: "overflow" + }; + } + currentState = "start"; + stack = []; + break; + } + } + + if (token.type) + tokens.push(token); + + if (stack.length > 1) { + if (stack[0] !== currentState) + stack.unshift("#tmp", currentState); + } + return { + tokens : tokens, + state : stack.length ? stack : currentState + }; + }; + + this.reportError = config.reportError; + +}).call(Tokenizer.prototype); + +exports.Tokenizer = Tokenizer; +}); + +define("ace/mode/text_highlight_rules",["require","exports","module","ace/lib/lang"], function(require, exports, module) { +"use strict"; + +var lang = require("../lib/lang"); + +var TextHighlightRules = function() { + + this.$rules = { + "start" : [{ + token : "empty_line", + regex : '^$' + }, { + defaultToken : "text" + }] + }; +}; + +(function() { + + this.addRules = function(rules, prefix) { + if (!prefix) { + for (var key in rules) + this.$rules[key] = rules[key]; + return; + } + for (var key in rules) { + var state = rules[key]; + for (var i = 0; i < state.length; i++) { + var rule = state[i]; + if (rule.next || rule.onMatch) { + if (typeof rule.next != "string") { + if (rule.nextState && rule.nextState.indexOf(prefix) !== 0) + rule.nextState = prefix + rule.nextState; + } else { + if (rule.next.indexOf(prefix) !== 0) + rule.next = prefix + rule.next; + } + } + } + this.$rules[prefix + key] = state; + } + }; + + this.getRules = function() { + return this.$rules; + }; + + this.embedRules = function (HighlightRules, prefix, escapeRules, states, append) { + var embedRules = typeof HighlightRules == "function" + ? new HighlightRules().getRules() + : HighlightRules; + if (states) { + for (var i = 0; i < states.length; i++) + states[i] = prefix + states[i]; + } else { + states = []; + for (var key in embedRules) + states.push(prefix + key); + } + + this.addRules(embedRules, prefix); + + if (escapeRules) { + var addRules = Array.prototype[append ? "push" : "unshift"]; + for (var i = 0; i < states.length; i++) + addRules.apply(this.$rules[states[i]], lang.deepCopy(escapeRules)); + } + + if (!this.$embeds) + this.$embeds = []; + this.$embeds.push(prefix); + }; + + this.getEmbeds = function() { + return this.$embeds; + }; + + var pushState = function(currentState, stack) { + if (currentState != "start" || stack.length) + stack.unshift(this.nextState, currentState); + return this.nextState; + }; + var popState = function(currentState, stack) { + stack.shift(); + return stack.shift() || "start"; + }; + + this.normalizeRules = function() { + var id = 0; + var rules = this.$rules; + function processState(key) { + var state = rules[key]; + state.processed = true; + for (var i = 0; i < state.length; i++) { + var rule = state[i]; + if (!rule.regex && rule.start) { + rule.regex = rule.start; + if (!rule.next) + rule.next = []; + rule.next.push({ + defaultToken: rule.token + }, { + token: rule.token + ".end", + regex: rule.end || rule.start, + next: "pop" + }); + rule.token = rule.token + ".start"; + rule.push = true; + } + var next = rule.next || rule.push; + if (next && Array.isArray(next)) { + var stateName = rule.stateName; + if (!stateName) { + stateName = rule.token; + if (typeof stateName != "string") + stateName = stateName[0] || ""; + if (rules[stateName]) + stateName += id++; + } + rules[stateName] = next; + rule.next = stateName; + processState(stateName); + } else if (next == "pop") { + rule.next = popState; + } + + if (rule.push) { + rule.nextState = rule.next || rule.push; + rule.next = pushState; + delete rule.push; + } + + if (rule.rules) { + for (var r in rule.rules) { + if (rules[r]) { + if (rules[r].push) + rules[r].push.apply(rules[r], rule.rules[r]); + } else { + rules[r] = rule.rules[r]; + } + } + } + if (rule.include || typeof rule == "string") { + var includeName = rule.include || rule; + var toInsert = rules[includeName]; + } else if (Array.isArray(rule)) + toInsert = rule; + + if (toInsert) { + var args = [i, 1].concat(toInsert); + if (rule.noEscape) + args = args.filter(function(x) {return !x.next;}); + state.splice.apply(state, args); + i--; + toInsert = null; + } + + if (rule.keywordMap) { + rule.token = this.createKeywordMapper( + rule.keywordMap, rule.defaultToken || "text", rule.caseInsensitive + ); + delete rule.defaultToken; + } + } + } + Object.keys(rules).forEach(processState, this); + }; + + this.createKeywordMapper = function(map, defaultToken, ignoreCase, splitChar) { + var keywords = Object.create(null); + Object.keys(map).forEach(function(className) { + var a = map[className]; + if (ignoreCase) + a = a.toLowerCase(); + var list = a.split(splitChar || "|"); + for (var i = list.length; i--; ) + keywords[list[i]] = className; + }); + if (Object.getPrototypeOf(keywords)) { + keywords.__proto__ = null; + } + this.$keywordList = Object.keys(keywords); + map = null; + return ignoreCase + ? function(value) {return keywords[value.toLowerCase()] || defaultToken } + : function(value) {return keywords[value] || defaultToken }; + }; + + this.getKeywords = function() { + return this.$keywords; + }; + +}).call(TextHighlightRules.prototype); + +exports.TextHighlightRules = TextHighlightRules; +}); + +define("ace/mode/behaviour",["require","exports","module"], function(require, exports, module) { +"use strict"; + +var Behaviour = function() { + this.$behaviours = {}; +}; + +(function () { + + this.add = function (name, action, callback) { + switch (undefined) { + case this.$behaviours: + this.$behaviours = {}; + case this.$behaviours[name]: + this.$behaviours[name] = {}; + } + this.$behaviours[name][action] = callback; + } + + this.addBehaviours = function (behaviours) { + for (var key in behaviours) { + for (var action in behaviours[key]) { + this.add(key, action, behaviours[key][action]); + } + } + } + + this.remove = function (name) { + if (this.$behaviours && this.$behaviours[name]) { + delete this.$behaviours[name]; + } + } + + this.inherit = function (mode, filter) { + if (typeof mode === "function") { + var behaviours = new mode().getBehaviours(filter); + } else { + var behaviours = mode.getBehaviours(filter); + } + this.addBehaviours(behaviours); + } + + this.getBehaviours = function (filter) { + if (!filter) { + return this.$behaviours; + } else { + var ret = {} + for (var i = 0; i < filter.length; i++) { + if (this.$behaviours[filter[i]]) { + ret[filter[i]] = this.$behaviours[filter[i]]; + } + } + return ret; + } + } + +}).call(Behaviour.prototype); + +exports.Behaviour = Behaviour; +}); + +define("ace/unicode",["require","exports","module"], function(require, exports, module) { +"use strict"; +exports.packages = {}; + +addUnicodePackage({ + L: "0041-005A0061-007A00AA00B500BA00C0-00D600D8-00F600F8-02C102C6-02D102E0-02E402EC02EE0370-037403760377037A-037D03860388-038A038C038E-03A103A3-03F503F7-0481048A-05250531-055605590561-058705D0-05EA05F0-05F20621-064A066E066F0671-06D306D506E506E606EE06EF06FA-06FC06FF07100712-072F074D-07A507B107CA-07EA07F407F507FA0800-0815081A082408280904-0939093D09500958-0961097109720979-097F0985-098C098F09900993-09A809AA-09B009B209B6-09B909BD09CE09DC09DD09DF-09E109F009F10A05-0A0A0A0F0A100A13-0A280A2A-0A300A320A330A350A360A380A390A59-0A5C0A5E0A72-0A740A85-0A8D0A8F-0A910A93-0AA80AAA-0AB00AB20AB30AB5-0AB90ABD0AD00AE00AE10B05-0B0C0B0F0B100B13-0B280B2A-0B300B320B330B35-0B390B3D0B5C0B5D0B5F-0B610B710B830B85-0B8A0B8E-0B900B92-0B950B990B9A0B9C0B9E0B9F0BA30BA40BA8-0BAA0BAE-0BB90BD00C05-0C0C0C0E-0C100C12-0C280C2A-0C330C35-0C390C3D0C580C590C600C610C85-0C8C0C8E-0C900C92-0CA80CAA-0CB30CB5-0CB90CBD0CDE0CE00CE10D05-0D0C0D0E-0D100D12-0D280D2A-0D390D3D0D600D610D7A-0D7F0D85-0D960D9A-0DB10DB3-0DBB0DBD0DC0-0DC60E01-0E300E320E330E40-0E460E810E820E840E870E880E8A0E8D0E94-0E970E99-0E9F0EA1-0EA30EA50EA70EAA0EAB0EAD-0EB00EB20EB30EBD0EC0-0EC40EC60EDC0EDD0F000F40-0F470F49-0F6C0F88-0F8B1000-102A103F1050-1055105A-105D106110651066106E-10701075-1081108E10A0-10C510D0-10FA10FC1100-1248124A-124D1250-12561258125A-125D1260-1288128A-128D1290-12B012B2-12B512B8-12BE12C012C2-12C512C8-12D612D8-13101312-13151318-135A1380-138F13A0-13F41401-166C166F-167F1681-169A16A0-16EA1700-170C170E-17111720-17311740-17511760-176C176E-17701780-17B317D717DC1820-18771880-18A818AA18B0-18F51900-191C1950-196D1970-19741980-19AB19C1-19C71A00-1A161A20-1A541AA71B05-1B331B45-1B4B1B83-1BA01BAE1BAF1C00-1C231C4D-1C4F1C5A-1C7D1CE9-1CEC1CEE-1CF11D00-1DBF1E00-1F151F18-1F1D1F20-1F451F48-1F4D1F50-1F571F591F5B1F5D1F5F-1F7D1F80-1FB41FB6-1FBC1FBE1FC2-1FC41FC6-1FCC1FD0-1FD31FD6-1FDB1FE0-1FEC1FF2-1FF41FF6-1FFC2071207F2090-209421022107210A-211321152119-211D212421262128212A-212D212F-2139213C-213F2145-2149214E218321842C00-2C2E2C30-2C5E2C60-2CE42CEB-2CEE2D00-2D252D30-2D652D6F2D80-2D962DA0-2DA62DA8-2DAE2DB0-2DB62DB8-2DBE2DC0-2DC62DC8-2DCE2DD0-2DD62DD8-2DDE2E2F300530063031-3035303B303C3041-3096309D-309F30A1-30FA30FC-30FF3105-312D3131-318E31A0-31B731F0-31FF3400-4DB54E00-9FCBA000-A48CA4D0-A4FDA500-A60CA610-A61FA62AA62BA640-A65FA662-A66EA67F-A697A6A0-A6E5A717-A71FA722-A788A78BA78CA7FB-A801A803-A805A807-A80AA80C-A822A840-A873A882-A8B3A8F2-A8F7A8FBA90A-A925A930-A946A960-A97CA984-A9B2A9CFAA00-AA28AA40-AA42AA44-AA4BAA60-AA76AA7AAA80-AAAFAAB1AAB5AAB6AAB9-AABDAAC0AAC2AADB-AADDABC0-ABE2AC00-D7A3D7B0-D7C6D7CB-D7FBF900-FA2DFA30-FA6DFA70-FAD9FB00-FB06FB13-FB17FB1DFB1F-FB28FB2A-FB36FB38-FB3CFB3EFB40FB41FB43FB44FB46-FBB1FBD3-FD3DFD50-FD8FFD92-FDC7FDF0-FDFBFE70-FE74FE76-FEFCFF21-FF3AFF41-FF5AFF66-FFBEFFC2-FFC7FFCA-FFCFFFD2-FFD7FFDA-FFDC", + Ll: "0061-007A00AA00B500BA00DF-00F600F8-00FF01010103010501070109010B010D010F01110113011501170119011B011D011F01210123012501270129012B012D012F01310133013501370138013A013C013E014001420144014601480149014B014D014F01510153015501570159015B015D015F01610163016501670169016B016D016F0171017301750177017A017C017E-0180018301850188018C018D019201950199-019B019E01A101A301A501A801AA01AB01AD01B001B401B601B901BA01BD-01BF01C601C901CC01CE01D001D201D401D601D801DA01DC01DD01DF01E101E301E501E701E901EB01ED01EF01F001F301F501F901FB01FD01FF02010203020502070209020B020D020F02110213021502170219021B021D021F02210223022502270229022B022D022F02310233-0239023C023F0240024202470249024B024D024F-02930295-02AF037103730377037B-037D039003AC-03CE03D003D103D5-03D703D903DB03DD03DF03E103E303E503E703E903EB03ED03EF-03F303F503F803FB03FC0430-045F04610463046504670469046B046D046F04710473047504770479047B047D047F0481048B048D048F04910493049504970499049B049D049F04A104A304A504A704A904AB04AD04AF04B104B304B504B704B904BB04BD04BF04C204C404C604C804CA04CC04CE04CF04D104D304D504D704D904DB04DD04DF04E104E304E504E704E904EB04ED04EF04F104F304F504F704F904FB04FD04FF05010503050505070509050B050D050F05110513051505170519051B051D051F0521052305250561-05871D00-1D2B1D62-1D771D79-1D9A1E011E031E051E071E091E0B1E0D1E0F1E111E131E151E171E191E1B1E1D1E1F1E211E231E251E271E291E2B1E2D1E2F1E311E331E351E371E391E3B1E3D1E3F1E411E431E451E471E491E4B1E4D1E4F1E511E531E551E571E591E5B1E5D1E5F1E611E631E651E671E691E6B1E6D1E6F1E711E731E751E771E791E7B1E7D1E7F1E811E831E851E871E891E8B1E8D1E8F1E911E931E95-1E9D1E9F1EA11EA31EA51EA71EA91EAB1EAD1EAF1EB11EB31EB51EB71EB91EBB1EBD1EBF1EC11EC31EC51EC71EC91ECB1ECD1ECF1ED11ED31ED51ED71ED91EDB1EDD1EDF1EE11EE31EE51EE71EE91EEB1EED1EEF1EF11EF31EF51EF71EF91EFB1EFD1EFF-1F071F10-1F151F20-1F271F30-1F371F40-1F451F50-1F571F60-1F671F70-1F7D1F80-1F871F90-1F971FA0-1FA71FB0-1FB41FB61FB71FBE1FC2-1FC41FC61FC71FD0-1FD31FD61FD71FE0-1FE71FF2-1FF41FF61FF7210A210E210F2113212F21342139213C213D2146-2149214E21842C30-2C5E2C612C652C662C682C6A2C6C2C712C732C742C76-2C7C2C812C832C852C872C892C8B2C8D2C8F2C912C932C952C972C992C9B2C9D2C9F2CA12CA32CA52CA72CA92CAB2CAD2CAF2CB12CB32CB52CB72CB92CBB2CBD2CBF2CC12CC32CC52CC72CC92CCB2CCD2CCF2CD12CD32CD52CD72CD92CDB2CDD2CDF2CE12CE32CE42CEC2CEE2D00-2D25A641A643A645A647A649A64BA64DA64FA651A653A655A657A659A65BA65DA65FA663A665A667A669A66BA66DA681A683A685A687A689A68BA68DA68FA691A693A695A697A723A725A727A729A72BA72DA72F-A731A733A735A737A739A73BA73DA73FA741A743A745A747A749A74BA74DA74FA751A753A755A757A759A75BA75DA75FA761A763A765A767A769A76BA76DA76FA771-A778A77AA77CA77FA781A783A785A787A78CFB00-FB06FB13-FB17FF41-FF5A", + Lu: "0041-005A00C0-00D600D8-00DE01000102010401060108010A010C010E01100112011401160118011A011C011E01200122012401260128012A012C012E01300132013401360139013B013D013F0141014301450147014A014C014E01500152015401560158015A015C015E01600162016401660168016A016C016E017001720174017601780179017B017D018101820184018601870189-018B018E-0191019301940196-0198019C019D019F01A001A201A401A601A701A901AC01AE01AF01B1-01B301B501B701B801BC01C401C701CA01CD01CF01D101D301D501D701D901DB01DE01E001E201E401E601E801EA01EC01EE01F101F401F6-01F801FA01FC01FE02000202020402060208020A020C020E02100212021402160218021A021C021E02200222022402260228022A022C022E02300232023A023B023D023E02410243-02460248024A024C024E03700372037603860388-038A038C038E038F0391-03A103A3-03AB03CF03D2-03D403D803DA03DC03DE03E003E203E403E603E803EA03EC03EE03F403F703F903FA03FD-042F04600462046404660468046A046C046E04700472047404760478047A047C047E0480048A048C048E04900492049404960498049A049C049E04A004A204A404A604A804AA04AC04AE04B004B204B404B604B804BA04BC04BE04C004C104C304C504C704C904CB04CD04D004D204D404D604D804DA04DC04DE04E004E204E404E604E804EA04EC04EE04F004F204F404F604F804FA04FC04FE05000502050405060508050A050C050E05100512051405160518051A051C051E0520052205240531-055610A0-10C51E001E021E041E061E081E0A1E0C1E0E1E101E121E141E161E181E1A1E1C1E1E1E201E221E241E261E281E2A1E2C1E2E1E301E321E341E361E381E3A1E3C1E3E1E401E421E441E461E481E4A1E4C1E4E1E501E521E541E561E581E5A1E5C1E5E1E601E621E641E661E681E6A1E6C1E6E1E701E721E741E761E781E7A1E7C1E7E1E801E821E841E861E881E8A1E8C1E8E1E901E921E941E9E1EA01EA21EA41EA61EA81EAA1EAC1EAE1EB01EB21EB41EB61EB81EBA1EBC1EBE1EC01EC21EC41EC61EC81ECA1ECC1ECE1ED01ED21ED41ED61ED81EDA1EDC1EDE1EE01EE21EE41EE61EE81EEA1EEC1EEE1EF01EF21EF41EF61EF81EFA1EFC1EFE1F08-1F0F1F18-1F1D1F28-1F2F1F38-1F3F1F48-1F4D1F591F5B1F5D1F5F1F68-1F6F1FB8-1FBB1FC8-1FCB1FD8-1FDB1FE8-1FEC1FF8-1FFB21022107210B-210D2110-211221152119-211D212421262128212A-212D2130-2133213E213F214521832C00-2C2E2C602C62-2C642C672C692C6B2C6D-2C702C722C752C7E-2C802C822C842C862C882C8A2C8C2C8E2C902C922C942C962C982C9A2C9C2C9E2CA02CA22CA42CA62CA82CAA2CAC2CAE2CB02CB22CB42CB62CB82CBA2CBC2CBE2CC02CC22CC42CC62CC82CCA2CCC2CCE2CD02CD22CD42CD62CD82CDA2CDC2CDE2CE02CE22CEB2CEDA640A642A644A646A648A64AA64CA64EA650A652A654A656A658A65AA65CA65EA662A664A666A668A66AA66CA680A682A684A686A688A68AA68CA68EA690A692A694A696A722A724A726A728A72AA72CA72EA732A734A736A738A73AA73CA73EA740A742A744A746A748A74AA74CA74EA750A752A754A756A758A75AA75CA75EA760A762A764A766A768A76AA76CA76EA779A77BA77DA77EA780A782A784A786A78BFF21-FF3A", + Lt: "01C501C801CB01F21F88-1F8F1F98-1F9F1FA8-1FAF1FBC1FCC1FFC", + Lm: "02B0-02C102C6-02D102E0-02E402EC02EE0374037A0559064006E506E607F407F507FA081A0824082809710E460EC610FC17D718431AA71C78-1C7D1D2C-1D611D781D9B-1DBF2071207F2090-20942C7D2D6F2E2F30053031-3035303B309D309E30FC-30FEA015A4F8-A4FDA60CA67FA717-A71FA770A788A9CFAA70AADDFF70FF9EFF9F", + Lo: "01BB01C0-01C3029405D0-05EA05F0-05F20621-063F0641-064A066E066F0671-06D306D506EE06EF06FA-06FC06FF07100712-072F074D-07A507B107CA-07EA0800-08150904-0939093D09500958-096109720979-097F0985-098C098F09900993-09A809AA-09B009B209B6-09B909BD09CE09DC09DD09DF-09E109F009F10A05-0A0A0A0F0A100A13-0A280A2A-0A300A320A330A350A360A380A390A59-0A5C0A5E0A72-0A740A85-0A8D0A8F-0A910A93-0AA80AAA-0AB00AB20AB30AB5-0AB90ABD0AD00AE00AE10B05-0B0C0B0F0B100B13-0B280B2A-0B300B320B330B35-0B390B3D0B5C0B5D0B5F-0B610B710B830B85-0B8A0B8E-0B900B92-0B950B990B9A0B9C0B9E0B9F0BA30BA40BA8-0BAA0BAE-0BB90BD00C05-0C0C0C0E-0C100C12-0C280C2A-0C330C35-0C390C3D0C580C590C600C610C85-0C8C0C8E-0C900C92-0CA80CAA-0CB30CB5-0CB90CBD0CDE0CE00CE10D05-0D0C0D0E-0D100D12-0D280D2A-0D390D3D0D600D610D7A-0D7F0D85-0D960D9A-0DB10DB3-0DBB0DBD0DC0-0DC60E01-0E300E320E330E40-0E450E810E820E840E870E880E8A0E8D0E94-0E970E99-0E9F0EA1-0EA30EA50EA70EAA0EAB0EAD-0EB00EB20EB30EBD0EC0-0EC40EDC0EDD0F000F40-0F470F49-0F6C0F88-0F8B1000-102A103F1050-1055105A-105D106110651066106E-10701075-1081108E10D0-10FA1100-1248124A-124D1250-12561258125A-125D1260-1288128A-128D1290-12B012B2-12B512B8-12BE12C012C2-12C512C8-12D612D8-13101312-13151318-135A1380-138F13A0-13F41401-166C166F-167F1681-169A16A0-16EA1700-170C170E-17111720-17311740-17511760-176C176E-17701780-17B317DC1820-18421844-18771880-18A818AA18B0-18F51900-191C1950-196D1970-19741980-19AB19C1-19C71A00-1A161A20-1A541B05-1B331B45-1B4B1B83-1BA01BAE1BAF1C00-1C231C4D-1C4F1C5A-1C771CE9-1CEC1CEE-1CF12135-21382D30-2D652D80-2D962DA0-2DA62DA8-2DAE2DB0-2DB62DB8-2DBE2DC0-2DC62DC8-2DCE2DD0-2DD62DD8-2DDE3006303C3041-3096309F30A1-30FA30FF3105-312D3131-318E31A0-31B731F0-31FF3400-4DB54E00-9FCBA000-A014A016-A48CA4D0-A4F7A500-A60BA610-A61FA62AA62BA66EA6A0-A6E5A7FB-A801A803-A805A807-A80AA80C-A822A840-A873A882-A8B3A8F2-A8F7A8FBA90A-A925A930-A946A960-A97CA984-A9B2AA00-AA28AA40-AA42AA44-AA4BAA60-AA6FAA71-AA76AA7AAA80-AAAFAAB1AAB5AAB6AAB9-AABDAAC0AAC2AADBAADCABC0-ABE2AC00-D7A3D7B0-D7C6D7CB-D7FBF900-FA2DFA30-FA6DFA70-FAD9FB1DFB1F-FB28FB2A-FB36FB38-FB3CFB3EFB40FB41FB43FB44FB46-FBB1FBD3-FD3DFD50-FD8FFD92-FDC7FDF0-FDFBFE70-FE74FE76-FEFCFF66-FF6FFF71-FF9DFFA0-FFBEFFC2-FFC7FFCA-FFCFFFD2-FFD7FFDA-FFDC", + M: "0300-036F0483-04890591-05BD05BF05C105C205C405C505C70610-061A064B-065E067006D6-06DC06DE-06E406E706E806EA-06ED07110730-074A07A6-07B007EB-07F30816-0819081B-08230825-08270829-082D0900-0903093C093E-094E0951-0955096209630981-098309BC09BE-09C409C709C809CB-09CD09D709E209E30A01-0A030A3C0A3E-0A420A470A480A4B-0A4D0A510A700A710A750A81-0A830ABC0ABE-0AC50AC7-0AC90ACB-0ACD0AE20AE30B01-0B030B3C0B3E-0B440B470B480B4B-0B4D0B560B570B620B630B820BBE-0BC20BC6-0BC80BCA-0BCD0BD70C01-0C030C3E-0C440C46-0C480C4A-0C4D0C550C560C620C630C820C830CBC0CBE-0CC40CC6-0CC80CCA-0CCD0CD50CD60CE20CE30D020D030D3E-0D440D46-0D480D4A-0D4D0D570D620D630D820D830DCA0DCF-0DD40DD60DD8-0DDF0DF20DF30E310E34-0E3A0E47-0E4E0EB10EB4-0EB90EBB0EBC0EC8-0ECD0F180F190F350F370F390F3E0F3F0F71-0F840F860F870F90-0F970F99-0FBC0FC6102B-103E1056-1059105E-10601062-10641067-106D1071-10741082-108D108F109A-109D135F1712-17141732-1734175217531772177317B6-17D317DD180B-180D18A91920-192B1930-193B19B0-19C019C819C91A17-1A1B1A55-1A5E1A60-1A7C1A7F1B00-1B041B34-1B441B6B-1B731B80-1B821BA1-1BAA1C24-1C371CD0-1CD21CD4-1CE81CED1CF21DC0-1DE61DFD-1DFF20D0-20F02CEF-2CF12DE0-2DFF302A-302F3099309AA66F-A672A67CA67DA6F0A6F1A802A806A80BA823-A827A880A881A8B4-A8C4A8E0-A8F1A926-A92DA947-A953A980-A983A9B3-A9C0AA29-AA36AA43AA4CAA4DAA7BAAB0AAB2-AAB4AAB7AAB8AABEAABFAAC1ABE3-ABEAABECABEDFB1EFE00-FE0FFE20-FE26", + Mn: "0300-036F0483-04870591-05BD05BF05C105C205C405C505C70610-061A064B-065E067006D6-06DC06DF-06E406E706E806EA-06ED07110730-074A07A6-07B007EB-07F30816-0819081B-08230825-08270829-082D0900-0902093C0941-0948094D0951-095509620963098109BC09C1-09C409CD09E209E30A010A020A3C0A410A420A470A480A4B-0A4D0A510A700A710A750A810A820ABC0AC1-0AC50AC70AC80ACD0AE20AE30B010B3C0B3F0B41-0B440B4D0B560B620B630B820BC00BCD0C3E-0C400C46-0C480C4A-0C4D0C550C560C620C630CBC0CBF0CC60CCC0CCD0CE20CE30D41-0D440D4D0D620D630DCA0DD2-0DD40DD60E310E34-0E3A0E47-0E4E0EB10EB4-0EB90EBB0EBC0EC8-0ECD0F180F190F350F370F390F71-0F7E0F80-0F840F860F870F90-0F970F99-0FBC0FC6102D-10301032-10371039103A103D103E10581059105E-10601071-1074108210851086108D109D135F1712-17141732-1734175217531772177317B7-17BD17C617C9-17D317DD180B-180D18A91920-19221927192819321939-193B1A171A181A561A58-1A5E1A601A621A65-1A6C1A73-1A7C1A7F1B00-1B031B341B36-1B3A1B3C1B421B6B-1B731B801B811BA2-1BA51BA81BA91C2C-1C331C361C371CD0-1CD21CD4-1CE01CE2-1CE81CED1DC0-1DE61DFD-1DFF20D0-20DC20E120E5-20F02CEF-2CF12DE0-2DFF302A-302F3099309AA66FA67CA67DA6F0A6F1A802A806A80BA825A826A8C4A8E0-A8F1A926-A92DA947-A951A980-A982A9B3A9B6-A9B9A9BCAA29-AA2EAA31AA32AA35AA36AA43AA4CAAB0AAB2-AAB4AAB7AAB8AABEAABFAAC1ABE5ABE8ABEDFB1EFE00-FE0FFE20-FE26", + Mc: "0903093E-09400949-094C094E0982098309BE-09C009C709C809CB09CC09D70A030A3E-0A400A830ABE-0AC00AC90ACB0ACC0B020B030B3E0B400B470B480B4B0B4C0B570BBE0BBF0BC10BC20BC6-0BC80BCA-0BCC0BD70C01-0C030C41-0C440C820C830CBE0CC0-0CC40CC70CC80CCA0CCB0CD50CD60D020D030D3E-0D400D46-0D480D4A-0D4C0D570D820D830DCF-0DD10DD8-0DDF0DF20DF30F3E0F3F0F7F102B102C10311038103B103C105610571062-10641067-106D108310841087-108C108F109A-109C17B617BE-17C517C717C81923-19261929-192B193019311933-193819B0-19C019C819C91A19-1A1B1A551A571A611A631A641A6D-1A721B041B351B3B1B3D-1B411B431B441B821BA11BA61BA71BAA1C24-1C2B1C341C351CE11CF2A823A824A827A880A881A8B4-A8C3A952A953A983A9B4A9B5A9BAA9BBA9BD-A9C0AA2FAA30AA33AA34AA4DAA7BABE3ABE4ABE6ABE7ABE9ABEAABEC", + Me: "0488048906DE20DD-20E020E2-20E4A670-A672", + N: "0030-003900B200B300B900BC-00BE0660-066906F0-06F907C0-07C90966-096F09E6-09EF09F4-09F90A66-0A6F0AE6-0AEF0B66-0B6F0BE6-0BF20C66-0C6F0C78-0C7E0CE6-0CEF0D66-0D750E50-0E590ED0-0ED90F20-0F331040-10491090-10991369-137C16EE-16F017E0-17E917F0-17F91810-18191946-194F19D0-19DA1A80-1A891A90-1A991B50-1B591BB0-1BB91C40-1C491C50-1C5920702074-20792080-20892150-21822185-21892460-249B24EA-24FF2776-27932CFD30073021-30293038-303A3192-31953220-32293251-325F3280-328932B1-32BFA620-A629A6E6-A6EFA830-A835A8D0-A8D9A900-A909A9D0-A9D9AA50-AA59ABF0-ABF9FF10-FF19", + Nd: "0030-00390660-066906F0-06F907C0-07C90966-096F09E6-09EF0A66-0A6F0AE6-0AEF0B66-0B6F0BE6-0BEF0C66-0C6F0CE6-0CEF0D66-0D6F0E50-0E590ED0-0ED90F20-0F291040-10491090-109917E0-17E91810-18191946-194F19D0-19DA1A80-1A891A90-1A991B50-1B591BB0-1BB91C40-1C491C50-1C59A620-A629A8D0-A8D9A900-A909A9D0-A9D9AA50-AA59ABF0-ABF9FF10-FF19", + Nl: "16EE-16F02160-21822185-218830073021-30293038-303AA6E6-A6EF", + No: "00B200B300B900BC-00BE09F4-09F90BF0-0BF20C78-0C7E0D70-0D750F2A-0F331369-137C17F0-17F920702074-20792080-20892150-215F21892460-249B24EA-24FF2776-27932CFD3192-31953220-32293251-325F3280-328932B1-32BFA830-A835", + P: "0021-00230025-002A002C-002F003A003B003F0040005B-005D005F007B007D00A100AB00B700BB00BF037E0387055A-055F0589058A05BE05C005C305C605F305F40609060A060C060D061B061E061F066A-066D06D40700-070D07F7-07F90830-083E0964096509700DF40E4F0E5A0E5B0F04-0F120F3A-0F3D0F850FD0-0FD4104A-104F10FB1361-13681400166D166E169B169C16EB-16ED1735173617D4-17D617D8-17DA1800-180A1944194519DE19DF1A1E1A1F1AA0-1AA61AA8-1AAD1B5A-1B601C3B-1C3F1C7E1C7F1CD32010-20272030-20432045-20512053-205E207D207E208D208E2329232A2768-277527C527C627E6-27EF2983-299829D8-29DB29FC29FD2CF9-2CFC2CFE2CFF2E00-2E2E2E302E313001-30033008-30113014-301F3030303D30A030FBA4FEA4FFA60D-A60FA673A67EA6F2-A6F7A874-A877A8CEA8CFA8F8-A8FAA92EA92FA95FA9C1-A9CDA9DEA9DFAA5C-AA5FAADEAADFABEBFD3EFD3FFE10-FE19FE30-FE52FE54-FE61FE63FE68FE6AFE6BFF01-FF03FF05-FF0AFF0C-FF0FFF1AFF1BFF1FFF20FF3B-FF3DFF3FFF5BFF5DFF5F-FF65", + Pd: "002D058A05BE140018062010-20152E172E1A301C303030A0FE31FE32FE58FE63FF0D", + Ps: "0028005B007B0F3A0F3C169B201A201E2045207D208D23292768276A276C276E27702772277427C527E627E827EA27EC27EE2983298529872989298B298D298F299129932995299729D829DA29FC2E222E242E262E283008300A300C300E3010301430163018301A301DFD3EFE17FE35FE37FE39FE3BFE3DFE3FFE41FE43FE47FE59FE5BFE5DFF08FF3BFF5BFF5FFF62", + Pe: "0029005D007D0F3B0F3D169C2046207E208E232A2769276B276D276F27712773277527C627E727E927EB27ED27EF298429862988298A298C298E2990299229942996299829D929DB29FD2E232E252E272E293009300B300D300F3011301530173019301B301E301FFD3FFE18FE36FE38FE3AFE3CFE3EFE40FE42FE44FE48FE5AFE5CFE5EFF09FF3DFF5DFF60FF63", + Pi: "00AB2018201B201C201F20392E022E042E092E0C2E1C2E20", + Pf: "00BB2019201D203A2E032E052E0A2E0D2E1D2E21", + Pc: "005F203F20402054FE33FE34FE4D-FE4FFF3F", + Po: "0021-00230025-0027002A002C002E002F003A003B003F0040005C00A100B700BF037E0387055A-055F058905C005C305C605F305F40609060A060C060D061B061E061F066A-066D06D40700-070D07F7-07F90830-083E0964096509700DF40E4F0E5A0E5B0F04-0F120F850FD0-0FD4104A-104F10FB1361-1368166D166E16EB-16ED1735173617D4-17D617D8-17DA1800-18051807-180A1944194519DE19DF1A1E1A1F1AA0-1AA61AA8-1AAD1B5A-1B601C3B-1C3F1C7E1C7F1CD3201620172020-20272030-2038203B-203E2041-20432047-205120532055-205E2CF9-2CFC2CFE2CFF2E002E012E06-2E082E0B2E0E-2E162E182E192E1B2E1E2E1F2E2A-2E2E2E302E313001-3003303D30FBA4FEA4FFA60D-A60FA673A67EA6F2-A6F7A874-A877A8CEA8CFA8F8-A8FAA92EA92FA95FA9C1-A9CDA9DEA9DFAA5C-AA5FAADEAADFABEBFE10-FE16FE19FE30FE45FE46FE49-FE4CFE50-FE52FE54-FE57FE5F-FE61FE68FE6AFE6BFF01-FF03FF05-FF07FF0AFF0CFF0EFF0FFF1AFF1BFF1FFF20FF3CFF61FF64FF65", + S: "0024002B003C-003E005E0060007C007E00A2-00A900AC00AE-00B100B400B600B800D700F702C2-02C502D2-02DF02E5-02EB02ED02EF-02FF03750384038503F604820606-0608060B060E060F06E906FD06FE07F609F209F309FA09FB0AF10B700BF3-0BFA0C7F0CF10CF20D790E3F0F01-0F030F13-0F170F1A-0F1F0F340F360F380FBE-0FC50FC7-0FCC0FCE0FCF0FD5-0FD8109E109F13601390-139917DB194019E0-19FF1B61-1B6A1B74-1B7C1FBD1FBF-1FC11FCD-1FCF1FDD-1FDF1FED-1FEF1FFD1FFE20442052207A-207C208A-208C20A0-20B8210021012103-21062108210921142116-2118211E-2123212521272129212E213A213B2140-2144214A-214D214F2190-2328232B-23E82400-24262440-244A249C-24E92500-26CD26CF-26E126E326E8-26FF2701-27042706-2709270C-27272729-274B274D274F-27522756-275E2761-276727942798-27AF27B1-27BE27C0-27C427C7-27CA27CC27D0-27E527F0-29822999-29D729DC-29FB29FE-2B4C2B50-2B592CE5-2CEA2E80-2E992E9B-2EF32F00-2FD52FF0-2FFB300430123013302030363037303E303F309B309C319031913196-319F31C0-31E33200-321E322A-32503260-327F328A-32B032C0-32FE3300-33FF4DC0-4DFFA490-A4C6A700-A716A720A721A789A78AA828-A82BA836-A839AA77-AA79FB29FDFCFDFDFE62FE64-FE66FE69FF04FF0BFF1C-FF1EFF3EFF40FF5CFF5EFFE0-FFE6FFE8-FFEEFFFCFFFD", + Sm: "002B003C-003E007C007E00AC00B100D700F703F60606-060820442052207A-207C208A-208C2140-2144214B2190-2194219A219B21A021A321A621AE21CE21CF21D221D421F4-22FF2308-230B23202321237C239B-23B323DC-23E125B725C125F8-25FF266F27C0-27C427C7-27CA27CC27D0-27E527F0-27FF2900-29822999-29D729DC-29FB29FE-2AFF2B30-2B442B47-2B4CFB29FE62FE64-FE66FF0BFF1C-FF1EFF5CFF5EFFE2FFE9-FFEC", + Sc: "002400A2-00A5060B09F209F309FB0AF10BF90E3F17DB20A0-20B8A838FDFCFE69FF04FFE0FFE1FFE5FFE6", + Sk: "005E006000A800AF00B400B802C2-02C502D2-02DF02E5-02EB02ED02EF-02FF0375038403851FBD1FBF-1FC11FCD-1FCF1FDD-1FDF1FED-1FEF1FFD1FFE309B309CA700-A716A720A721A789A78AFF3EFF40FFE3", + So: "00A600A700A900AE00B000B60482060E060F06E906FD06FE07F609FA0B700BF3-0BF80BFA0C7F0CF10CF20D790F01-0F030F13-0F170F1A-0F1F0F340F360F380FBE-0FC50FC7-0FCC0FCE0FCF0FD5-0FD8109E109F13601390-1399194019E0-19FF1B61-1B6A1B74-1B7C210021012103-21062108210921142116-2118211E-2123212521272129212E213A213B214A214C214D214F2195-2199219C-219F21A121A221A421A521A7-21AD21AF-21CD21D021D121D321D5-21F32300-2307230C-231F2322-2328232B-237B237D-239A23B4-23DB23E2-23E82400-24262440-244A249C-24E92500-25B625B8-25C025C2-25F72600-266E2670-26CD26CF-26E126E326E8-26FF2701-27042706-2709270C-27272729-274B274D274F-27522756-275E2761-276727942798-27AF27B1-27BE2800-28FF2B00-2B2F2B452B462B50-2B592CE5-2CEA2E80-2E992E9B-2EF32F00-2FD52FF0-2FFB300430123013302030363037303E303F319031913196-319F31C0-31E33200-321E322A-32503260-327F328A-32B032C0-32FE3300-33FF4DC0-4DFFA490-A4C6A828-A82BA836A837A839AA77-AA79FDFDFFE4FFE8FFEDFFEEFFFCFFFD", + Z: "002000A01680180E2000-200A20282029202F205F3000", + Zs: "002000A01680180E2000-200A202F205F3000", + Zl: "2028", + Zp: "2029", + C: "0000-001F007F-009F00AD03780379037F-0383038B038D03A20526-05300557055805600588058B-059005C8-05CF05EB-05EF05F5-0605061C061D0620065F06DD070E070F074B074C07B2-07BF07FB-07FF082E082F083F-08FF093A093B094F095609570973-097809800984098D098E0991099209A909B109B3-09B509BA09BB09C509C609C909CA09CF-09D609D8-09DB09DE09E409E509FC-0A000A040A0B-0A0E0A110A120A290A310A340A370A3A0A3B0A3D0A43-0A460A490A4A0A4E-0A500A52-0A580A5D0A5F-0A650A76-0A800A840A8E0A920AA90AB10AB40ABA0ABB0AC60ACA0ACE0ACF0AD1-0ADF0AE40AE50AF00AF2-0B000B040B0D0B0E0B110B120B290B310B340B3A0B3B0B450B460B490B4A0B4E-0B550B58-0B5B0B5E0B640B650B72-0B810B840B8B-0B8D0B910B96-0B980B9B0B9D0BA0-0BA20BA5-0BA70BAB-0BAD0BBA-0BBD0BC3-0BC50BC90BCE0BCF0BD1-0BD60BD8-0BE50BFB-0C000C040C0D0C110C290C340C3A-0C3C0C450C490C4E-0C540C570C5A-0C5F0C640C650C70-0C770C800C810C840C8D0C910CA90CB40CBA0CBB0CC50CC90CCE-0CD40CD7-0CDD0CDF0CE40CE50CF00CF3-0D010D040D0D0D110D290D3A-0D3C0D450D490D4E-0D560D58-0D5F0D640D650D76-0D780D800D810D840D97-0D990DB20DBC0DBE0DBF0DC7-0DC90DCB-0DCE0DD50DD70DE0-0DF10DF5-0E000E3B-0E3E0E5C-0E800E830E850E860E890E8B0E8C0E8E-0E930E980EA00EA40EA60EA80EA90EAC0EBA0EBE0EBF0EC50EC70ECE0ECF0EDA0EDB0EDE-0EFF0F480F6D-0F700F8C-0F8F0F980FBD0FCD0FD9-0FFF10C6-10CF10FD-10FF1249124E124F12571259125E125F1289128E128F12B112B612B712BF12C112C612C712D7131113161317135B-135E137D-137F139A-139F13F5-13FF169D-169F16F1-16FF170D1715-171F1737-173F1754-175F176D17711774-177F17B417B517DE17DF17EA-17EF17FA-17FF180F181A-181F1878-187F18AB-18AF18F6-18FF191D-191F192C-192F193C-193F1941-1943196E196F1975-197F19AC-19AF19CA-19CF19DB-19DD1A1C1A1D1A5F1A7D1A7E1A8A-1A8F1A9A-1A9F1AAE-1AFF1B4C-1B4F1B7D-1B7F1BAB-1BAD1BBA-1BFF1C38-1C3A1C4A-1C4C1C80-1CCF1CF3-1CFF1DE7-1DFC1F161F171F1E1F1F1F461F471F4E1F4F1F581F5A1F5C1F5E1F7E1F7F1FB51FC51FD41FD51FDC1FF01FF11FF51FFF200B-200F202A-202E2060-206F20722073208F2095-209F20B9-20CF20F1-20FF218A-218F23E9-23FF2427-243F244B-245F26CE26E226E4-26E727002705270A270B2728274C274E2753-2755275F27602795-279727B027BF27CB27CD-27CF2B4D-2B4F2B5A-2BFF2C2F2C5F2CF2-2CF82D26-2D2F2D66-2D6E2D70-2D7F2D97-2D9F2DA72DAF2DB72DBF2DC72DCF2DD72DDF2E32-2E7F2E9A2EF4-2EFF2FD6-2FEF2FFC-2FFF3040309730983100-3104312E-3130318F31B8-31BF31E4-31EF321F32FF4DB6-4DBF9FCC-9FFFA48D-A48FA4C7-A4CFA62C-A63FA660A661A674-A67BA698-A69FA6F8-A6FFA78D-A7FAA82C-A82FA83A-A83FA878-A87FA8C5-A8CDA8DA-A8DFA8FC-A8FFA954-A95EA97D-A97FA9CEA9DA-A9DDA9E0-A9FFAA37-AA3FAA4EAA4FAA5AAA5BAA7C-AA7FAAC3-AADAAAE0-ABBFABEEABEFABFA-ABFFD7A4-D7AFD7C7-D7CAD7FC-F8FFFA2EFA2FFA6EFA6FFADA-FAFFFB07-FB12FB18-FB1CFB37FB3DFB3FFB42FB45FBB2-FBD2FD40-FD4FFD90FD91FDC8-FDEFFDFEFDFFFE1A-FE1FFE27-FE2FFE53FE67FE6C-FE6FFE75FEFD-FF00FFBF-FFC1FFC8FFC9FFD0FFD1FFD8FFD9FFDD-FFDFFFE7FFEF-FFFBFFFEFFFF", + Cc: "0000-001F007F-009F", + Cf: "00AD0600-060306DD070F17B417B5200B-200F202A-202E2060-2064206A-206FFEFFFFF9-FFFB", + Co: "E000-F8FF", + Cs: "D800-DFFF", + Cn: "03780379037F-0383038B038D03A20526-05300557055805600588058B-059005C8-05CF05EB-05EF05F5-05FF06040605061C061D0620065F070E074B074C07B2-07BF07FB-07FF082E082F083F-08FF093A093B094F095609570973-097809800984098D098E0991099209A909B109B3-09B509BA09BB09C509C609C909CA09CF-09D609D8-09DB09DE09E409E509FC-0A000A040A0B-0A0E0A110A120A290A310A340A370A3A0A3B0A3D0A43-0A460A490A4A0A4E-0A500A52-0A580A5D0A5F-0A650A76-0A800A840A8E0A920AA90AB10AB40ABA0ABB0AC60ACA0ACE0ACF0AD1-0ADF0AE40AE50AF00AF2-0B000B040B0D0B0E0B110B120B290B310B340B3A0B3B0B450B460B490B4A0B4E-0B550B58-0B5B0B5E0B640B650B72-0B810B840B8B-0B8D0B910B96-0B980B9B0B9D0BA0-0BA20BA5-0BA70BAB-0BAD0BBA-0BBD0BC3-0BC50BC90BCE0BCF0BD1-0BD60BD8-0BE50BFB-0C000C040C0D0C110C290C340C3A-0C3C0C450C490C4E-0C540C570C5A-0C5F0C640C650C70-0C770C800C810C840C8D0C910CA90CB40CBA0CBB0CC50CC90CCE-0CD40CD7-0CDD0CDF0CE40CE50CF00CF3-0D010D040D0D0D110D290D3A-0D3C0D450D490D4E-0D560D58-0D5F0D640D650D76-0D780D800D810D840D97-0D990DB20DBC0DBE0DBF0DC7-0DC90DCB-0DCE0DD50DD70DE0-0DF10DF5-0E000E3B-0E3E0E5C-0E800E830E850E860E890E8B0E8C0E8E-0E930E980EA00EA40EA60EA80EA90EAC0EBA0EBE0EBF0EC50EC70ECE0ECF0EDA0EDB0EDE-0EFF0F480F6D-0F700F8C-0F8F0F980FBD0FCD0FD9-0FFF10C6-10CF10FD-10FF1249124E124F12571259125E125F1289128E128F12B112B612B712BF12C112C612C712D7131113161317135B-135E137D-137F139A-139F13F5-13FF169D-169F16F1-16FF170D1715-171F1737-173F1754-175F176D17711774-177F17DE17DF17EA-17EF17FA-17FF180F181A-181F1878-187F18AB-18AF18F6-18FF191D-191F192C-192F193C-193F1941-1943196E196F1975-197F19AC-19AF19CA-19CF19DB-19DD1A1C1A1D1A5F1A7D1A7E1A8A-1A8F1A9A-1A9F1AAE-1AFF1B4C-1B4F1B7D-1B7F1BAB-1BAD1BBA-1BFF1C38-1C3A1C4A-1C4C1C80-1CCF1CF3-1CFF1DE7-1DFC1F161F171F1E1F1F1F461F471F4E1F4F1F581F5A1F5C1F5E1F7E1F7F1FB51FC51FD41FD51FDC1FF01FF11FF51FFF2065-206920722073208F2095-209F20B9-20CF20F1-20FF218A-218F23E9-23FF2427-243F244B-245F26CE26E226E4-26E727002705270A270B2728274C274E2753-2755275F27602795-279727B027BF27CB27CD-27CF2B4D-2B4F2B5A-2BFF2C2F2C5F2CF2-2CF82D26-2D2F2D66-2D6E2D70-2D7F2D97-2D9F2DA72DAF2DB72DBF2DC72DCF2DD72DDF2E32-2E7F2E9A2EF4-2EFF2FD6-2FEF2FFC-2FFF3040309730983100-3104312E-3130318F31B8-31BF31E4-31EF321F32FF4DB6-4DBF9FCC-9FFFA48D-A48FA4C7-A4CFA62C-A63FA660A661A674-A67BA698-A69FA6F8-A6FFA78D-A7FAA82C-A82FA83A-A83FA878-A87FA8C5-A8CDA8DA-A8DFA8FC-A8FFA954-A95EA97D-A97FA9CEA9DA-A9DDA9E0-A9FFAA37-AA3FAA4EAA4FAA5AAA5BAA7C-AA7FAAC3-AADAAAE0-ABBFABEEABEFABFA-ABFFD7A4-D7AFD7C7-D7CAD7FC-D7FFFA2EFA2FFA6EFA6FFADA-FAFFFB07-FB12FB18-FB1CFB37FB3DFB3FFB42FB45FBB2-FBD2FD40-FD4FFD90FD91FDC8-FDEFFDFEFDFFFE1A-FE1FFE27-FE2FFE53FE67FE6C-FE6FFE75FEFDFEFEFF00FFBF-FFC1FFC8FFC9FFD0FFD1FFD8FFD9FFDD-FFDFFFE7FFEF-FFF8FFFEFFFF" +}); + +function addUnicodePackage (pack) { + var codePoint = /\w{4}/g; + for (var name in pack) + exports.packages[name] = pack[name].replace(codePoint, "\\u$&"); +}; + +}); + +define("ace/token_iterator",["require","exports","module"], function(require, exports, module) { +"use strict"; +var TokenIterator = function(session, initialRow, initialColumn) { + this.$session = session; + this.$row = initialRow; + this.$rowTokens = session.getTokens(initialRow); + + var token = session.getTokenAt(initialRow, initialColumn); + this.$tokenIndex = token ? token.index : -1; +}; + +(function() { + this.stepBackward = function() { + this.$tokenIndex -= 1; + + while (this.$tokenIndex < 0) { + this.$row -= 1; + if (this.$row < 0) { + this.$row = 0; + return null; + } + + this.$rowTokens = this.$session.getTokens(this.$row); + this.$tokenIndex = this.$rowTokens.length - 1; + } + + return this.$rowTokens[this.$tokenIndex]; + }; + this.stepForward = function() { + this.$tokenIndex += 1; + var rowCount; + while (this.$tokenIndex >= this.$rowTokens.length) { + this.$row += 1; + if (!rowCount) + rowCount = this.$session.getLength(); + if (this.$row >= rowCount) { + this.$row = rowCount - 1; + return null; + } + + this.$rowTokens = this.$session.getTokens(this.$row); + this.$tokenIndex = 0; + } + + return this.$rowTokens[this.$tokenIndex]; + }; + this.getCurrentToken = function () { + return this.$rowTokens[this.$tokenIndex]; + }; + this.getCurrentTokenRow = function () { + return this.$row; + }; + this.getCurrentTokenColumn = function() { + var rowTokens = this.$rowTokens; + var tokenIndex = this.$tokenIndex; + var column = rowTokens[tokenIndex].start; + if (column !== undefined) + return column; + + column = 0; + while (tokenIndex > 0) { + tokenIndex -= 1; + column += rowTokens[tokenIndex].value.length; + } + + return column; + }; + +}).call(TokenIterator.prototype); + +exports.TokenIterator = TokenIterator; +}); + +define("ace/mode/text",["require","exports","module","ace/tokenizer","ace/mode/text_highlight_rules","ace/mode/behaviour","ace/unicode","ace/lib/lang","ace/token_iterator","ace/range"], function(require, exports, module) { +"use strict"; + +var Tokenizer = require("../tokenizer").Tokenizer; +var TextHighlightRules = require("./text_highlight_rules").TextHighlightRules; +var Behaviour = require("./behaviour").Behaviour; +var unicode = require("../unicode"); +var lang = require("../lib/lang"); +var TokenIterator = require("../token_iterator").TokenIterator; +var Range = require("../range").Range; + +var Mode = function() { + this.HighlightRules = TextHighlightRules; + this.$behaviour = new Behaviour(); +}; + +(function() { + + this.tokenRe = new RegExp("^[" + + unicode.packages.L + + unicode.packages.Mn + unicode.packages.Mc + + unicode.packages.Nd + + unicode.packages.Pc + "\\$_]+", "g" + ); + + this.nonTokenRe = new RegExp("^(?:[^" + + unicode.packages.L + + unicode.packages.Mn + unicode.packages.Mc + + unicode.packages.Nd + + unicode.packages.Pc + "\\$_]|\\s])+", "g" + ); + + this.getTokenizer = function() { + if (!this.$tokenizer) { + this.$highlightRules = this.$highlightRules || new this.HighlightRules(); + this.$tokenizer = new Tokenizer(this.$highlightRules.getRules()); + } + return this.$tokenizer; + }; + + this.lineCommentStart = ""; + this.blockComment = ""; + + this.toggleCommentLines = function(state, session, startRow, endRow) { + var doc = session.doc; + + var ignoreBlankLines = true; + var shouldRemove = true; + var minIndent = Infinity; + var tabSize = session.getTabSize(); + var insertAtTabStop = false; + + if (!this.lineCommentStart) { + if (!this.blockComment) + return false; + var lineCommentStart = this.blockComment.start; + var lineCommentEnd = this.blockComment.end; + var regexpStart = new RegExp("^(\\s*)(?:" + lang.escapeRegExp(lineCommentStart) + ")"); + var regexpEnd = new RegExp("(?:" + lang.escapeRegExp(lineCommentEnd) + ")\\s*$"); + + var comment = function(line, i) { + if (testRemove(line, i)) + return; + if (!ignoreBlankLines || /\S/.test(line)) { + doc.insertInLine({row: i, column: line.length}, lineCommentEnd); + doc.insertInLine({row: i, column: minIndent}, lineCommentStart); + } + }; + + var uncomment = function(line, i) { + var m; + if (m = line.match(regexpEnd)) + doc.removeInLine(i, line.length - m[0].length, line.length); + if (m = line.match(regexpStart)) + doc.removeInLine(i, m[1].length, m[0].length); + }; + + var testRemove = function(line, row) { + if (regexpStart.test(line)) + return true; + var tokens = session.getTokens(row); + for (var i = 0; i < tokens.length; i++) { + if (tokens[i].type === 'comment') + return true; + } + }; + } else { + if (Array.isArray(this.lineCommentStart)) { + var regexpStart = this.lineCommentStart.map(lang.escapeRegExp).join("|"); + var lineCommentStart = this.lineCommentStart[0]; + } else { + var regexpStart = lang.escapeRegExp(this.lineCommentStart); + var lineCommentStart = this.lineCommentStart; + } + regexpStart = new RegExp("^(\\s*)(?:" + regexpStart + ") ?"); + + insertAtTabStop = session.getUseSoftTabs(); + + var uncomment = function(line, i) { + var m = line.match(regexpStart); + if (!m) return; + var start = m[1].length, end = m[0].length; + if (!shouldInsertSpace(line, start, end) && m[0][end - 1] == " ") + end--; + doc.removeInLine(i, start, end); + }; + var commentWithSpace = lineCommentStart + " "; + var comment = function(line, i) { + if (!ignoreBlankLines || /\S/.test(line)) { + if (shouldInsertSpace(line, minIndent, minIndent)) + doc.insertInLine({row: i, column: minIndent}, commentWithSpace); + else + doc.insertInLine({row: i, column: minIndent}, lineCommentStart); + } + }; + var testRemove = function(line, i) { + return regexpStart.test(line); + }; + + var shouldInsertSpace = function(line, before, after) { + var spaces = 0; + while (before-- && line.charAt(before) == " ") + spaces++; + if (spaces % tabSize != 0) + return false; + var spaces = 0; + while (line.charAt(after++) == " ") + spaces++; + if (tabSize > 2) + return spaces % tabSize != tabSize - 1; + else + return spaces % tabSize == 0; + return true; + }; + } + + function iter(fun) { + for (var i = startRow; i <= endRow; i++) + fun(doc.getLine(i), i); + } + + + var minEmptyLength = Infinity; + iter(function(line, i) { + var indent = line.search(/\S/); + if (indent !== -1) { + if (indent < minIndent) + minIndent = indent; + if (shouldRemove && !testRemove(line, i)) + shouldRemove = false; + } else if (minEmptyLength > line.length) { + minEmptyLength = line.length; + } + }); + + if (minIndent == Infinity) { + minIndent = minEmptyLength; + ignoreBlankLines = false; + shouldRemove = false; + } + + if (insertAtTabStop && minIndent % tabSize != 0) + minIndent = Math.floor(minIndent / tabSize) * tabSize; + + iter(shouldRemove ? uncomment : comment); + }; + + this.toggleBlockComment = function(state, session, range, cursor) { + var comment = this.blockComment; + if (!comment) + return; + if (!comment.start && comment[0]) + comment = comment[0]; + + var iterator = new TokenIterator(session, cursor.row, cursor.column); + var token = iterator.getCurrentToken(); + + var sel = session.selection; + var initialRange = session.selection.toOrientedRange(); + var startRow, colDiff; + + if (token && /comment/.test(token.type)) { + var startRange, endRange; + while (token && /comment/.test(token.type)) { + var i = token.value.indexOf(comment.start); + if (i != -1) { + var row = iterator.getCurrentTokenRow(); + var column = iterator.getCurrentTokenColumn() + i; + startRange = new Range(row, column, row, column + comment.start.length); + break; + } + token = iterator.stepBackward(); + } + + var iterator = new TokenIterator(session, cursor.row, cursor.column); + var token = iterator.getCurrentToken(); + while (token && /comment/.test(token.type)) { + var i = token.value.indexOf(comment.end); + if (i != -1) { + var row = iterator.getCurrentTokenRow(); + var column = iterator.getCurrentTokenColumn() + i; + endRange = new Range(row, column, row, column + comment.end.length); + break; + } + token = iterator.stepForward(); + } + if (endRange) + session.remove(endRange); + if (startRange) { + session.remove(startRange); + startRow = startRange.start.row; + colDiff = -comment.start.length; + } + } else { + colDiff = comment.start.length; + startRow = range.start.row; + session.insert(range.end, comment.end); + session.insert(range.start, comment.start); + } + if (initialRange.start.row == startRow) + initialRange.start.column += colDiff; + if (initialRange.end.row == startRow) + initialRange.end.column += colDiff; + session.selection.fromOrientedRange(initialRange); + }; + + this.getNextLineIndent = function(state, line, tab) { + return this.$getIndent(line); + }; + + this.checkOutdent = function(state, line, input) { + return false; + }; + + this.autoOutdent = function(state, doc, row) { + }; + + this.$getIndent = function(line) { + return line.match(/^\s*/)[0]; + }; + + this.createWorker = function(session) { + return null; + }; + + this.createModeDelegates = function (mapping) { + this.$embeds = []; + this.$modes = {}; + for (var i in mapping) { + if (mapping[i]) { + this.$embeds.push(i); + this.$modes[i] = new mapping[i](); + } + } + + var delegations = ['toggleBlockComment', 'toggleCommentLines', 'getNextLineIndent', + 'checkOutdent', 'autoOutdent', 'transformAction', 'getCompletions']; + + for (var i = 0; i < delegations.length; i++) { + (function(scope) { + var functionName = delegations[i]; + var defaultHandler = scope[functionName]; + scope[delegations[i]] = function() { + return this.$delegator(functionName, arguments, defaultHandler); + }; + } (this)); + } + }; + + this.$delegator = function(method, args, defaultHandler) { + var state = args[0]; + if (typeof state != "string") + state = state[0]; + for (var i = 0; i < this.$embeds.length; i++) { + if (!this.$modes[this.$embeds[i]]) continue; + + var split = state.split(this.$embeds[i]); + if (!split[0] && split[1]) { + args[0] = split[1]; + var mode = this.$modes[this.$embeds[i]]; + return mode[method].apply(mode, args); + } + } + var ret = defaultHandler.apply(this, args); + return defaultHandler ? ret : undefined; + }; + + this.transformAction = function(state, action, editor, session, param) { + if (this.$behaviour) { + var behaviours = this.$behaviour.getBehaviours(); + for (var key in behaviours) { + if (behaviours[key][action]) { + var ret = behaviours[key][action].apply(this, arguments); + if (ret) { + return ret; + } + } + } + } + }; + + this.getKeywords = function(append) { + if (!this.completionKeywords) { + var rules = this.$tokenizer.rules; + var completionKeywords = []; + for (var rule in rules) { + var ruleItr = rules[rule]; + for (var r = 0, l = ruleItr.length; r < l; r++) { + if (typeof ruleItr[r].token === "string") { + if (/keyword|support|storage/.test(ruleItr[r].token)) + completionKeywords.push(ruleItr[r].regex); + } + else if (typeof ruleItr[r].token === "object") { + for (var a = 0, aLength = ruleItr[r].token.length; a < aLength; a++) { + if (/keyword|support|storage/.test(ruleItr[r].token[a])) { + var rule = ruleItr[r].regex.match(/\(.+?\)/g)[a]; + completionKeywords.push(rule.substr(1, rule.length - 2)); + } + } + } + } + } + this.completionKeywords = completionKeywords; + } + if (!append) + return this.$keywordList; + return completionKeywords.concat(this.$keywordList || []); + }; + + this.$createKeywordList = function() { + if (!this.$highlightRules) + this.getTokenizer(); + return this.$keywordList = this.$highlightRules.$keywordList || []; + }; + + this.getCompletions = function(state, session, pos, prefix) { + var keywords = this.$keywordList || this.$createKeywordList(); + return keywords.map(function(word) { + return { + name: word, + value: word, + score: 0, + meta: "keyword" + }; + }); + }; + + this.$id = "ace/mode/text"; +}).call(Mode.prototype); + +exports.Mode = Mode; +}); + +define("ace/anchor",["require","exports","module","ace/lib/oop","ace/lib/event_emitter"], function(require, exports, module) { +"use strict"; + +var oop = require("./lib/oop"); +var EventEmitter = require("./lib/event_emitter").EventEmitter; + +var Anchor = exports.Anchor = function(doc, row, column) { + this.$onChange = this.onChange.bind(this); + this.attach(doc); + + if (typeof column == "undefined") + this.setPosition(row.row, row.column); + else + this.setPosition(row, column); +}; + +(function() { + + oop.implement(this, EventEmitter); + this.getPosition = function() { + return this.$clipPositionToDocument(this.row, this.column); + }; + this.getDocument = function() { + return this.document; + }; + this.$insertRight = false; + this.onChange = function(e) { + var delta = e.data; + var range = delta.range; + + if (range.start.row == range.end.row && range.start.row != this.row) + return; + + if (range.start.row > this.row) + return; + + if (range.start.row == this.row && range.start.column > this.column) + return; + + var row = this.row; + var column = this.column; + var start = range.start; + var end = range.end; + + if (delta.action === "insertText") { + if (start.row === row && start.column <= column) { + if (start.column === column && this.$insertRight) { + } else if (start.row === end.row) { + column += end.column - start.column; + } else { + column -= start.column; + row += end.row - start.row; + } + } else if (start.row !== end.row && start.row < row) { + row += end.row - start.row; + } + } else if (delta.action === "insertLines") { + if (start.row === row && column === 0 && this.$insertRight) { + } + else if (start.row <= row) { + row += end.row - start.row; + } + } else if (delta.action === "removeText") { + if (start.row === row && start.column < column) { + if (end.column >= column) + column = start.column; + else + column = Math.max(0, column - (end.column - start.column)); + + } else if (start.row !== end.row && start.row < row) { + if (end.row === row) + column = Math.max(0, column - end.column) + start.column; + row -= (end.row - start.row); + } else if (end.row === row) { + row -= end.row - start.row; + column = Math.max(0, column - end.column) + start.column; + } + } else if (delta.action == "removeLines") { + if (start.row <= row) { + if (end.row <= row) + row -= end.row - start.row; + else { + row = start.row; + column = 0; + } + } + } + + this.setPosition(row, column, true); + }; + this.setPosition = function(row, column, noClip) { + var pos; + if (noClip) { + pos = { + row: row, + column: column + }; + } else { + pos = this.$clipPositionToDocument(row, column); + } + + if (this.row == pos.row && this.column == pos.column) + return; + + var old = { + row: this.row, + column: this.column + }; + + this.row = pos.row; + this.column = pos.column; + this._signal("change", { + old: old, + value: pos + }); + }; + this.detach = function() { + this.document.removeEventListener("change", this.$onChange); + }; + this.attach = function(doc) { + this.document = doc || this.document; + this.document.on("change", this.$onChange); + }; + this.$clipPositionToDocument = function(row, column) { + var pos = {}; + + if (row >= this.document.getLength()) { + pos.row = Math.max(0, this.document.getLength() - 1); + pos.column = this.document.getLine(pos.row).length; + } + else if (row < 0) { + pos.row = 0; + pos.column = 0; + } + else { + pos.row = row; + pos.column = Math.min(this.document.getLine(pos.row).length, Math.max(0, column)); + } + + if (column < 0) + pos.column = 0; + + return pos; + }; + +}).call(Anchor.prototype); + +}); + +define("ace/document",["require","exports","module","ace/lib/oop","ace/lib/event_emitter","ace/range","ace/anchor"], function(require, exports, module) { +"use strict"; + +var oop = require("./lib/oop"); +var EventEmitter = require("./lib/event_emitter").EventEmitter; +var Range = require("./range").Range; +var Anchor = require("./anchor").Anchor; + +var Document = function(text) { + this.$lines = []; + if (text.length === 0) { + this.$lines = [""]; + } else if (Array.isArray(text)) { + this._insertLines(0, text); + } else { + this.insert({row: 0, column:0}, text); + } +}; + +(function() { + + oop.implement(this, EventEmitter); + this.setValue = function(text) { + var len = this.getLength(); + this.remove(new Range(0, 0, len, this.getLine(len-1).length)); + this.insert({row: 0, column:0}, text); + }; + this.getValue = function() { + return this.getAllLines().join(this.getNewLineCharacter()); + }; + this.createAnchor = function(row, column) { + return new Anchor(this, row, column); + }; + if ("aaa".split(/a/).length === 0) + this.$split = function(text) { + return text.replace(/\r\n|\r/g, "\n").split("\n"); + }; + else + this.$split = function(text) { + return text.split(/\r\n|\r|\n/); + }; + + + this.$detectNewLine = function(text) { + var match = text.match(/^.*?(\r\n|\r|\n)/m); + this.$autoNewLine = match ? match[1] : "\n"; + this._signal("changeNewLineMode"); + }; + this.getNewLineCharacter = function() { + switch (this.$newLineMode) { + case "windows": + return "\r\n"; + case "unix": + return "\n"; + default: + return this.$autoNewLine || "\n"; + } + }; + + this.$autoNewLine = ""; + this.$newLineMode = "auto"; + this.setNewLineMode = function(newLineMode) { + if (this.$newLineMode === newLineMode) + return; + + this.$newLineMode = newLineMode; + this._signal("changeNewLineMode"); + }; + this.getNewLineMode = function() { + return this.$newLineMode; + }; + this.isNewLine = function(text) { + return (text == "\r\n" || text == "\r" || text == "\n"); + }; + this.getLine = function(row) { + return this.$lines[row] || ""; + }; + this.getLines = function(firstRow, lastRow) { + return this.$lines.slice(firstRow, lastRow + 1); + }; + this.getAllLines = function() { + return this.getLines(0, this.getLength()); + }; + this.getLength = function() { + return this.$lines.length; + }; + this.getTextRange = function(range) { + if (range.start.row == range.end.row) { + return this.getLine(range.start.row) + .substring(range.start.column, range.end.column); + } + var lines = this.getLines(range.start.row, range.end.row); + lines[0] = (lines[0] || "").substring(range.start.column); + var l = lines.length - 1; + if (range.end.row - range.start.row == l) + lines[l] = lines[l].substring(0, range.end.column); + return lines.join(this.getNewLineCharacter()); + }; + + this.$clipPosition = function(position) { + var length = this.getLength(); + if (position.row >= length) { + position.row = Math.max(0, length - 1); + position.column = this.getLine(length-1).length; + } else if (position.row < 0) + position.row = 0; + return position; + }; + this.insert = function(position, text) { + if (!text || text.length === 0) + return position; + + position = this.$clipPosition(position); + if (this.getLength() <= 1) + this.$detectNewLine(text); + + var lines = this.$split(text); + var firstLine = lines.splice(0, 1)[0]; + var lastLine = lines.length == 0 ? null : lines.splice(lines.length - 1, 1)[0]; + + position = this.insertInLine(position, firstLine); + if (lastLine !== null) { + position = this.insertNewLine(position); // terminate first line + position = this._insertLines(position.row, lines); + position = this.insertInLine(position, lastLine || ""); + } + return position; + }; + this.insertLines = function(row, lines) { + if (row >= this.getLength()) + return this.insert({row: row, column: 0}, "\n" + lines.join("\n")); + return this._insertLines(Math.max(row, 0), lines); + }; + this._insertLines = function(row, lines) { + if (lines.length == 0) + return {row: row, column: 0}; + while (lines.length > 0xF000) { + var end = this._insertLines(row, lines.slice(0, 0xF000)); + lines = lines.slice(0xF000); + row = end.row; + } + + var args = [row, 0]; + args.push.apply(args, lines); + this.$lines.splice.apply(this.$lines, args); + + var range = new Range(row, 0, row + lines.length, 0); + var delta = { + action: "insertLines", + range: range, + lines: lines + }; + this._signal("change", { data: delta }); + return range.end; + }; + this.insertNewLine = function(position) { + position = this.$clipPosition(position); + var line = this.$lines[position.row] || ""; + + this.$lines[position.row] = line.substring(0, position.column); + this.$lines.splice(position.row + 1, 0, line.substring(position.column, line.length)); + + var end = { + row : position.row + 1, + column : 0 + }; + + var delta = { + action: "insertText", + range: Range.fromPoints(position, end), + text: this.getNewLineCharacter() + }; + this._signal("change", { data: delta }); + + return end; + }; + this.insertInLine = function(position, text) { + if (text.length == 0) + return position; + + var line = this.$lines[position.row] || ""; + + this.$lines[position.row] = line.substring(0, position.column) + text + + line.substring(position.column); + + var end = { + row : position.row, + column : position.column + text.length + }; + + var delta = { + action: "insertText", + range: Range.fromPoints(position, end), + text: text + }; + this._signal("change", { data: delta }); + + return end; + }; + this.remove = function(range) { + if (!(range instanceof Range)) + range = Range.fromPoints(range.start, range.end); + range.start = this.$clipPosition(range.start); + range.end = this.$clipPosition(range.end); + + if (range.isEmpty()) + return range.start; + + var firstRow = range.start.row; + var lastRow = range.end.row; + + if (range.isMultiLine()) { + var firstFullRow = range.start.column == 0 ? firstRow : firstRow + 1; + var lastFullRow = lastRow - 1; + + if (range.end.column > 0) + this.removeInLine(lastRow, 0, range.end.column); + + if (lastFullRow >= firstFullRow) + this._removeLines(firstFullRow, lastFullRow); + + if (firstFullRow != firstRow) { + this.removeInLine(firstRow, range.start.column, this.getLine(firstRow).length); + this.removeNewLine(range.start.row); + } + } + else { + this.removeInLine(firstRow, range.start.column, range.end.column); + } + return range.start; + }; + this.removeInLine = function(row, startColumn, endColumn) { + if (startColumn == endColumn) + return; + + var range = new Range(row, startColumn, row, endColumn); + var line = this.getLine(row); + var removed = line.substring(startColumn, endColumn); + var newLine = line.substring(0, startColumn) + line.substring(endColumn, line.length); + this.$lines.splice(row, 1, newLine); + + var delta = { + action: "removeText", + range: range, + text: removed + }; + this._signal("change", { data: delta }); + return range.start; + }; + this.removeLines = function(firstRow, lastRow) { + if (firstRow < 0 || lastRow >= this.getLength()) + return this.remove(new Range(firstRow, 0, lastRow + 1, 0)); + return this._removeLines(firstRow, lastRow); + }; + + this._removeLines = function(firstRow, lastRow) { + var range = new Range(firstRow, 0, lastRow + 1, 0); + var removed = this.$lines.splice(firstRow, lastRow - firstRow + 1); + + var delta = { + action: "removeLines", + range: range, + nl: this.getNewLineCharacter(), + lines: removed + }; + this._signal("change", { data: delta }); + return removed; + }; + this.removeNewLine = function(row) { + var firstLine = this.getLine(row); + var secondLine = this.getLine(row+1); + + var range = new Range(row, firstLine.length, row+1, 0); + var line = firstLine + secondLine; + + this.$lines.splice(row, 2, line); + + var delta = { + action: "removeText", + range: range, + text: this.getNewLineCharacter() + }; + this._signal("change", { data: delta }); + }; + this.replace = function(range, text) { + if (!(range instanceof Range)) + range = Range.fromPoints(range.start, range.end); + if (text.length == 0 && range.isEmpty()) + return range.start; + if (text == this.getTextRange(range)) + return range.end; + + this.remove(range); + if (text) { + var end = this.insert(range.start, text); + } + else { + end = range.start; + } + + return end; + }; + this.applyDeltas = function(deltas) { + for (var i=0; i=0; i--) { + var delta = deltas[i]; + + var range = Range.fromPoints(delta.range.start, delta.range.end); + + if (delta.action == "insertLines") + this._removeLines(range.start.row, range.end.row - 1); + else if (delta.action == "insertText") + this.remove(range); + else if (delta.action == "removeLines") + this._insertLines(range.start.row, delta.lines); + else if (delta.action == "removeText") + this.insert(range.start, delta.text); + } + }; + this.indexToPosition = function(index, startRow) { + var lines = this.$lines || this.getAllLines(); + var newlineLength = this.getNewLineCharacter().length; + for (var i = startRow || 0, l = lines.length; i < l; i++) { + index -= lines[i].length + newlineLength; + if (index < 0) + return {row: i, column: index + lines[i].length + newlineLength}; + } + return {row: l-1, column: lines[l-1].length}; + }; + this.positionToIndex = function(pos, startRow) { + var lines = this.$lines || this.getAllLines(); + var newlineLength = this.getNewLineCharacter().length; + var index = 0; + var row = Math.min(pos.row, lines.length); + for (var i = startRow || 0; i < row; ++i) + index += lines[i].length + newlineLength; + + return index + pos.column; + }; + +}).call(Document.prototype); + +exports.Document = Document; +}); + +define("ace/background_tokenizer",["require","exports","module","ace/lib/oop","ace/lib/event_emitter"], function(require, exports, module) { +"use strict"; + +var oop = require("./lib/oop"); +var EventEmitter = require("./lib/event_emitter").EventEmitter; + +var BackgroundTokenizer = function(tokenizer, editor) { + this.running = false; + this.lines = []; + this.states = []; + this.currentLine = 0; + this.tokenizer = tokenizer; + + var self = this; + + this.$worker = function() { + if (!self.running) { return; } + + var workerStart = new Date(); + var currentLine = self.currentLine; + var endLine = -1; + var doc = self.doc; + + while (self.lines[currentLine]) + currentLine++; + + var startLine = currentLine; + + var len = doc.getLength(); + var processedLines = 0; + self.running = false; + while (currentLine < len) { + self.$tokenizeRow(currentLine); + endLine = currentLine; + do { + currentLine++; + } while (self.lines[currentLine]); + processedLines ++; + if ((processedLines % 5 === 0) && (new Date() - workerStart) > 20) { + self.running = setTimeout(self.$worker, 20); + break; + } + } + self.currentLine = currentLine; + + if (startLine <= endLine) + self.fireUpdateEvent(startLine, endLine); + }; +}; + +(function(){ + + oop.implement(this, EventEmitter); + this.setTokenizer = function(tokenizer) { + this.tokenizer = tokenizer; + this.lines = []; + this.states = []; + + this.start(0); + }; + this.setDocument = function(doc) { + this.doc = doc; + this.lines = []; + this.states = []; + + this.stop(); + }; + this.fireUpdateEvent = function(firstRow, lastRow) { + var data = { + first: firstRow, + last: lastRow + }; + this._signal("update", {data: data}); + }; + this.start = function(startRow) { + this.currentLine = Math.min(startRow || 0, this.currentLine, this.doc.getLength()); + this.lines.splice(this.currentLine, this.lines.length); + this.states.splice(this.currentLine, this.states.length); + + this.stop(); + this.running = setTimeout(this.$worker, 700); + }; + + this.scheduleStart = function() { + if (!this.running) + this.running = setTimeout(this.$worker, 700); + } + + this.$updateOnChange = function(delta) { + var range = delta.range; + var startRow = range.start.row; + var len = range.end.row - startRow; + + if (len === 0) { + this.lines[startRow] = null; + } else if (delta.action == "removeText" || delta.action == "removeLines") { + this.lines.splice(startRow, len + 1, null); + this.states.splice(startRow, len + 1, null); + } else { + var args = Array(len + 1); + args.unshift(startRow, 1); + this.lines.splice.apply(this.lines, args); + this.states.splice.apply(this.states, args); + } + + this.currentLine = Math.min(startRow, this.currentLine, this.doc.getLength()); + + this.stop(); + }; + this.stop = function() { + if (this.running) + clearTimeout(this.running); + this.running = false; + }; + this.getTokens = function(row) { + return this.lines[row] || this.$tokenizeRow(row); + }; + this.getState = function(row) { + if (this.currentLine == row) + this.$tokenizeRow(row); + return this.states[row] || "start"; + }; + + this.$tokenizeRow = function(row) { + var line = this.doc.getLine(row); + var state = this.states[row - 1]; + + var data = this.tokenizer.getLineTokens(line, state, row); + + if (this.states[row] + "" !== data.state + "") { + this.states[row] = data.state; + this.lines[row + 1] = null; + if (this.currentLine > row + 1) + this.currentLine = row + 1; + } else if (this.currentLine == row) { + this.currentLine = row + 1; + } + + return this.lines[row] = data.tokens; + }; + +}).call(BackgroundTokenizer.prototype); + +exports.BackgroundTokenizer = BackgroundTokenizer; +}); + +define("ace/search_highlight",["require","exports","module","ace/lib/lang","ace/lib/oop","ace/range"], function(require, exports, module) { +"use strict"; + +var lang = require("./lib/lang"); +var oop = require("./lib/oop"); +var Range = require("./range").Range; + +var SearchHighlight = function(regExp, clazz, type) { + this.setRegexp(regExp); + this.clazz = clazz; + this.type = type || "text"; +}; + +(function() { + this.MAX_RANGES = 500; + + this.setRegexp = function(regExp) { + if (this.regExp+"" == regExp+"") + return; + this.regExp = regExp; + this.cache = []; + }; + + this.update = function(html, markerLayer, session, config) { + if (!this.regExp) + return; + var start = config.firstRow, end = config.lastRow; + + for (var i = start; i <= end; i++) { + var ranges = this.cache[i]; + if (ranges == null) { + ranges = lang.getMatchOffsets(session.getLine(i), this.regExp); + if (ranges.length > this.MAX_RANGES) + ranges = ranges.slice(0, this.MAX_RANGES); + ranges = ranges.map(function(match) { + return new Range(i, match.offset, i, match.offset + match.length); + }); + this.cache[i] = ranges.length ? ranges : ""; + } + + for (var j = ranges.length; j --; ) { + markerLayer.drawSingleLineMarker( + html, ranges[j].toScreenRange(session), this.clazz, config); + } + } + }; + +}).call(SearchHighlight.prototype); + +exports.SearchHighlight = SearchHighlight; +}); + +define("ace/edit_session/fold_line",["require","exports","module","ace/range"], function(require, exports, module) { +"use strict"; + +var Range = require("../range").Range; +function FoldLine(foldData, folds) { + this.foldData = foldData; + if (Array.isArray(folds)) { + this.folds = folds; + } else { + folds = this.folds = [ folds ]; + } + + var last = folds[folds.length - 1]; + this.range = new Range(folds[0].start.row, folds[0].start.column, + last.end.row, last.end.column); + this.start = this.range.start; + this.end = this.range.end; + + this.folds.forEach(function(fold) { + fold.setFoldLine(this); + }, this); +} + +(function() { + this.shiftRow = function(shift) { + this.start.row += shift; + this.end.row += shift; + this.folds.forEach(function(fold) { + fold.start.row += shift; + fold.end.row += shift; + }); + }; + + this.addFold = function(fold) { + if (fold.sameRow) { + if (fold.start.row < this.startRow || fold.endRow > this.endRow) { + throw new Error("Can't add a fold to this FoldLine as it has no connection"); + } + this.folds.push(fold); + this.folds.sort(function(a, b) { + return -a.range.compareEnd(b.start.row, b.start.column); + }); + if (this.range.compareEnd(fold.start.row, fold.start.column) > 0) { + this.end.row = fold.end.row; + this.end.column = fold.end.column; + } else if (this.range.compareStart(fold.end.row, fold.end.column) < 0) { + this.start.row = fold.start.row; + this.start.column = fold.start.column; + } + } else if (fold.start.row == this.end.row) { + this.folds.push(fold); + this.end.row = fold.end.row; + this.end.column = fold.end.column; + } else if (fold.end.row == this.start.row) { + this.folds.unshift(fold); + this.start.row = fold.start.row; + this.start.column = fold.start.column; + } else { + throw new Error("Trying to add fold to FoldRow that doesn't have a matching row"); + } + fold.foldLine = this; + }; + + this.containsRow = function(row) { + return row >= this.start.row && row <= this.end.row; + }; + + this.walk = function(callback, endRow, endColumn) { + var lastEnd = 0, + folds = this.folds, + fold, + cmp, stop, isNewRow = true; + + if (endRow == null) { + endRow = this.end.row; + endColumn = this.end.column; + } + + for (var i = 0; i < folds.length; i++) { + fold = folds[i]; + + cmp = fold.range.compareStart(endRow, endColumn); + if (cmp == -1) { + callback(null, endRow, endColumn, lastEnd, isNewRow); + return; + } + + stop = callback(null, fold.start.row, fold.start.column, lastEnd, isNewRow); + stop = !stop && callback(fold.placeholder, fold.start.row, fold.start.column, lastEnd); + if (stop || cmp === 0) { + return; + } + isNewRow = !fold.sameRow; + lastEnd = fold.end.column; + } + callback(null, endRow, endColumn, lastEnd, isNewRow); + }; + + this.getNextFoldTo = function(row, column) { + var fold, cmp; + for (var i = 0; i < this.folds.length; i++) { + fold = this.folds[i]; + cmp = fold.range.compareEnd(row, column); + if (cmp == -1) { + return { + fold: fold, + kind: "after" + }; + } else if (cmp === 0) { + return { + fold: fold, + kind: "inside" + }; + } + } + return null; + }; + + this.addRemoveChars = function(row, column, len) { + var ret = this.getNextFoldTo(row, column), + fold, folds; + if (ret) { + fold = ret.fold; + if (ret.kind == "inside" + && fold.start.column != column + && fold.start.row != row) + { + window.console && window.console.log(row, column, fold); + } else if (fold.start.row == row) { + folds = this.folds; + var i = folds.indexOf(fold); + if (i === 0) { + this.start.column += len; + } + for (i; i < folds.length; i++) { + fold = folds[i]; + fold.start.column += len; + if (!fold.sameRow) { + return; + } + fold.end.column += len; + } + this.end.column += len; + } + } + }; + + this.split = function(row, column) { + var pos = this.getNextFoldTo(row, column); + + if (!pos || pos.kind == "inside") + return null; + + var fold = pos.fold; + var folds = this.folds; + var foldData = this.foldData; + + var i = folds.indexOf(fold); + var foldBefore = folds[i - 1]; + this.end.row = foldBefore.end.row; + this.end.column = foldBefore.end.column; + folds = folds.splice(i, folds.length - i); + + var newFoldLine = new FoldLine(foldData, folds); + foldData.splice(foldData.indexOf(this) + 1, 0, newFoldLine); + return newFoldLine; + }; + + this.merge = function(foldLineNext) { + var folds = foldLineNext.folds; + for (var i = 0; i < folds.length; i++) { + this.addFold(folds[i]); + } + var foldData = this.foldData; + foldData.splice(foldData.indexOf(foldLineNext), 1); + }; + + this.toString = function() { + var ret = [this.range.toString() + ": [" ]; + + this.folds.forEach(function(fold) { + ret.push(" " + fold.toString()); + }); + ret.push("]"); + return ret.join("\n"); + }; + + this.idxToPosition = function(idx) { + var lastFoldEndColumn = 0; + + for (var i = 0; i < this.folds.length; i++) { + var fold = this.folds[i]; + + idx -= fold.start.column - lastFoldEndColumn; + if (idx < 0) { + return { + row: fold.start.row, + column: fold.start.column + idx + }; + } + + idx -= fold.placeholder.length; + if (idx < 0) { + return fold.start; + } + + lastFoldEndColumn = fold.end.column; + } + + return { + row: this.end.row, + column: this.end.column + idx + }; + }; +}).call(FoldLine.prototype); + +exports.FoldLine = FoldLine; +}); + +define("ace/range_list",["require","exports","module","ace/range"], function(require, exports, module) { +"use strict"; +var Range = require("./range").Range; +var comparePoints = Range.comparePoints; + +var RangeList = function() { + this.ranges = []; +}; + +(function() { + this.comparePoints = comparePoints; + + this.pointIndex = function(pos, excludeEdges, startIndex) { + var list = this.ranges; + + for (var i = startIndex || 0; i < list.length; i++) { + var range = list[i]; + var cmpEnd = comparePoints(pos, range.end); + if (cmpEnd > 0) + continue; + var cmpStart = comparePoints(pos, range.start); + if (cmpEnd === 0) + return excludeEdges && cmpStart !== 0 ? -i-2 : i; + if (cmpStart > 0 || (cmpStart === 0 && !excludeEdges)) + return i; + + return -i-1; + } + return -i - 1; + }; + + this.add = function(range) { + var excludeEdges = !range.isEmpty(); + var startIndex = this.pointIndex(range.start, excludeEdges); + if (startIndex < 0) + startIndex = -startIndex - 1; + + var endIndex = this.pointIndex(range.end, excludeEdges, startIndex); + + if (endIndex < 0) + endIndex = -endIndex - 1; + else + endIndex++; + return this.ranges.splice(startIndex, endIndex - startIndex, range); + }; + + this.addList = function(list) { + var removed = []; + for (var i = list.length; i--; ) { + removed.push.call(removed, this.add(list[i])); + } + return removed; + }; + + this.substractPoint = function(pos) { + var i = this.pointIndex(pos); + + if (i >= 0) + return this.ranges.splice(i, 1); + }; + this.merge = function() { + var removed = []; + var list = this.ranges; + + list = list.sort(function(a, b) { + return comparePoints(a.start, b.start); + }); + + var next = list[0], range; + for (var i = 1; i < list.length; i++) { + range = next; + next = list[i]; + var cmp = comparePoints(range.end, next.start); + if (cmp < 0) + continue; + + if (cmp == 0 && !range.isEmpty() && !next.isEmpty()) + continue; + + if (comparePoints(range.end, next.end) < 0) { + range.end.row = next.end.row; + range.end.column = next.end.column; + } + + list.splice(i, 1); + removed.push(next); + next = range; + i--; + } + + this.ranges = list; + + return removed; + }; + + this.contains = function(row, column) { + return this.pointIndex({row: row, column: column}) >= 0; + }; + + this.containsPoint = function(pos) { + return this.pointIndex(pos) >= 0; + }; + + this.rangeAtPoint = function(pos) { + var i = this.pointIndex(pos); + if (i >= 0) + return this.ranges[i]; + }; + + + this.clipRows = function(startRow, endRow) { + var list = this.ranges; + if (list[0].start.row > endRow || list[list.length - 1].start.row < startRow) + return []; + + var startIndex = this.pointIndex({row: startRow, column: 0}); + if (startIndex < 0) + startIndex = -startIndex - 1; + var endIndex = this.pointIndex({row: endRow, column: 0}, startIndex); + if (endIndex < 0) + endIndex = -endIndex - 1; + + var clipped = []; + for (var i = startIndex; i < endIndex; i++) { + clipped.push(list[i]); + } + return clipped; + }; + + this.removeAll = function() { + return this.ranges.splice(0, this.ranges.length); + }; + + this.attach = function(session) { + if (this.session) + this.detach(); + + this.session = session; + this.onChange = this.$onChange.bind(this); + + this.session.on('change', this.onChange); + }; + + this.detach = function() { + if (!this.session) + return; + this.session.removeListener('change', this.onChange); + this.session = null; + }; + + this.$onChange = function(e) { + var changeRange = e.data.range; + if (e.data.action[0] == "i"){ + var start = changeRange.start; + var end = changeRange.end; + } else { + var end = changeRange.start; + var start = changeRange.end; + } + var startRow = start.row; + var endRow = end.row; + var lineDif = endRow - startRow; + + var colDiff = -start.column + end.column; + var ranges = this.ranges; + + for (var i = 0, n = ranges.length; i < n; i++) { + var r = ranges[i]; + if (r.end.row < startRow) + continue; + if (r.start.row > startRow) + break; + + if (r.start.row == startRow && r.start.column >= start.column ) { + if (r.start.column == start.column && this.$insertRight) { + } else { + r.start.column += colDiff; + r.start.row += lineDif; + } + } + if (r.end.row == startRow && r.end.column >= start.column) { + if (r.end.column == start.column && this.$insertRight) { + continue; + } + if (r.end.column == start.column && colDiff > 0 && i < n - 1) { + if (r.end.column > r.start.column && r.end.column == ranges[i+1].start.column) + r.end.column -= colDiff; + } + r.end.column += colDiff; + r.end.row += lineDif; + } + } + + if (lineDif != 0 && i < n) { + for (; i < n; i++) { + var r = ranges[i]; + r.start.row += lineDif; + r.end.row += lineDif; + } + } + }; + +}).call(RangeList.prototype); + +exports.RangeList = RangeList; +}); + +define("ace/edit_session/fold",["require","exports","module","ace/range","ace/range_list","ace/lib/oop"], function(require, exports, module) { +"use strict"; + +var Range = require("../range").Range; +var RangeList = require("../range_list").RangeList; +var oop = require("../lib/oop") +var Fold = exports.Fold = function(range, placeholder) { + this.foldLine = null; + this.placeholder = placeholder; + this.range = range; + this.start = range.start; + this.end = range.end; + + this.sameRow = range.start.row == range.end.row; + this.subFolds = this.ranges = []; +}; + +oop.inherits(Fold, RangeList); + +(function() { + + this.toString = function() { + return '"' + this.placeholder + '" ' + this.range.toString(); + }; + + this.setFoldLine = function(foldLine) { + this.foldLine = foldLine; + this.subFolds.forEach(function(fold) { + fold.setFoldLine(foldLine); + }); + }; + + this.clone = function() { + var range = this.range.clone(); + var fold = new Fold(range, this.placeholder); + this.subFolds.forEach(function(subFold) { + fold.subFolds.push(subFold.clone()); + }); + fold.collapseChildren = this.collapseChildren; + return fold; + }; + + this.addSubFold = function(fold) { + if (this.range.isEqual(fold)) + return; + + if (!this.range.containsRange(fold)) + throw new Error("A fold can't intersect already existing fold" + fold.range + this.range); + consumeRange(fold, this.start); + + var row = fold.start.row, column = fold.start.column; + for (var i = 0, cmp = -1; i < this.subFolds.length; i++) { + cmp = this.subFolds[i].range.compare(row, column); + if (cmp != 1) + break; + } + var afterStart = this.subFolds[i]; + + if (cmp == 0) + return afterStart.addSubFold(fold); + var row = fold.range.end.row, column = fold.range.end.column; + for (var j = i, cmp = -1; j < this.subFolds.length; j++) { + cmp = this.subFolds[j].range.compare(row, column); + if (cmp != 1) + break; + } + var afterEnd = this.subFolds[j]; + + if (cmp == 0) + throw new Error("A fold can't intersect already existing fold" + fold.range + this.range); + + var consumedFolds = this.subFolds.splice(i, j - i, fold); + fold.setFoldLine(this.foldLine); + + return fold; + }; + + this.restoreRange = function(range) { + return restoreRange(range, this.start); + }; + +}).call(Fold.prototype); + +function consumePoint(point, anchor) { + point.row -= anchor.row; + if (point.row == 0) + point.column -= anchor.column; +} +function consumeRange(range, anchor) { + consumePoint(range.start, anchor); + consumePoint(range.end, anchor); +} +function restorePoint(point, anchor) { + if (point.row == 0) + point.column += anchor.column; + point.row += anchor.row; +} +function restoreRange(range, anchor) { + restorePoint(range.start, anchor); + restorePoint(range.end, anchor); +} + +}); + +define("ace/edit_session/folding",["require","exports","module","ace/range","ace/edit_session/fold_line","ace/edit_session/fold","ace/token_iterator"], function(require, exports, module) { +"use strict"; + +var Range = require("../range").Range; +var FoldLine = require("./fold_line").FoldLine; +var Fold = require("./fold").Fold; +var TokenIterator = require("../token_iterator").TokenIterator; + +function Folding() { + this.getFoldAt = function(row, column, side) { + var foldLine = this.getFoldLine(row); + if (!foldLine) + return null; + + var folds = foldLine.folds; + for (var i = 0; i < folds.length; i++) { + var fold = folds[i]; + if (fold.range.contains(row, column)) { + if (side == 1 && fold.range.isEnd(row, column)) { + continue; + } else if (side == -1 && fold.range.isStart(row, column)) { + continue; + } + return fold; + } + } + }; + this.getFoldsInRange = function(range) { + var start = range.start; + var end = range.end; + var foldLines = this.$foldData; + var foundFolds = []; + + start.column += 1; + end.column -= 1; + + for (var i = 0; i < foldLines.length; i++) { + var cmp = foldLines[i].range.compareRange(range); + if (cmp == 2) { + continue; + } + else if (cmp == -2) { + break; + } + + var folds = foldLines[i].folds; + for (var j = 0; j < folds.length; j++) { + var fold = folds[j]; + cmp = fold.range.compareRange(range); + if (cmp == -2) { + break; + } else if (cmp == 2) { + continue; + } else + if (cmp == 42) { + break; + } + foundFolds.push(fold); + } + } + start.column -= 1; + end.column += 1; + + return foundFolds; + }; + + this.getFoldsInRangeList = function(ranges) { + if (Array.isArray(ranges)) { + var folds = []; + ranges.forEach(function(range) { + folds = folds.concat(this.getFoldsInRange(range)); + }, this); + } else { + var folds = this.getFoldsInRange(ranges); + } + return folds; + } + this.getAllFolds = function() { + var folds = []; + var foldLines = this.$foldData; + + for (var i = 0; i < foldLines.length; i++) + for (var j = 0; j < foldLines[i].folds.length; j++) + folds.push(foldLines[i].folds[j]); + + return folds; + }; + this.getFoldStringAt = function(row, column, trim, foldLine) { + foldLine = foldLine || this.getFoldLine(row); + if (!foldLine) + return null; + + var lastFold = { + end: { column: 0 } + }; + var str, fold; + for (var i = 0; i < foldLine.folds.length; i++) { + fold = foldLine.folds[i]; + var cmp = fold.range.compareEnd(row, column); + if (cmp == -1) { + str = this + .getLine(fold.start.row) + .substring(lastFold.end.column, fold.start.column); + break; + } + else if (cmp === 0) { + return null; + } + lastFold = fold; + } + if (!str) + str = this.getLine(fold.start.row).substring(lastFold.end.column); + + if (trim == -1) + return str.substring(0, column - lastFold.end.column); + else if (trim == 1) + return str.substring(column - lastFold.end.column); + else + return str; + }; + + this.getFoldLine = function(docRow, startFoldLine) { + var foldData = this.$foldData; + var i = 0; + if (startFoldLine) + i = foldData.indexOf(startFoldLine); + if (i == -1) + i = 0; + for (i; i < foldData.length; i++) { + var foldLine = foldData[i]; + if (foldLine.start.row <= docRow && foldLine.end.row >= docRow) { + return foldLine; + } else if (foldLine.end.row > docRow) { + return null; + } + } + return null; + }; + this.getNextFoldLine = function(docRow, startFoldLine) { + var foldData = this.$foldData; + var i = 0; + if (startFoldLine) + i = foldData.indexOf(startFoldLine); + if (i == -1) + i = 0; + for (i; i < foldData.length; i++) { + var foldLine = foldData[i]; + if (foldLine.end.row >= docRow) { + return foldLine; + } + } + return null; + }; + + this.getFoldedRowCount = function(first, last) { + var foldData = this.$foldData, rowCount = last-first+1; + for (var i = 0; i < foldData.length; i++) { + var foldLine = foldData[i], + end = foldLine.end.row, + start = foldLine.start.row; + if (end >= last) { + if(start < last) { + if(start >= first) + rowCount -= last-start; + else + rowCount = 0;//in one fold + } + break; + } else if(end >= first){ + if (start >= first) //fold inside range + rowCount -= end-start; + else + rowCount -= end-first+1; + } + } + return rowCount; + }; + + this.$addFoldLine = function(foldLine) { + this.$foldData.push(foldLine); + this.$foldData.sort(function(a, b) { + return a.start.row - b.start.row; + }); + return foldLine; + }; + this.addFold = function(placeholder, range) { + var foldData = this.$foldData; + var added = false; + var fold; + + if (placeholder instanceof Fold) + fold = placeholder; + else { + fold = new Fold(range, placeholder); + fold.collapseChildren = range.collapseChildren; + } + this.$clipRangeToDocument(fold.range); + + var startRow = fold.start.row; + var startColumn = fold.start.column; + var endRow = fold.end.row; + var endColumn = fold.end.column; + if (!(startRow < endRow || + startRow == endRow && startColumn <= endColumn - 2)) + throw new Error("The range has to be at least 2 characters width"); + + var startFold = this.getFoldAt(startRow, startColumn, 1); + var endFold = this.getFoldAt(endRow, endColumn, -1); + if (startFold && endFold == startFold) + return startFold.addSubFold(fold); + + if (startFold && !startFold.range.isStart(startRow, startColumn)) + this.removeFold(startFold); + + if (endFold && !endFold.range.isEnd(endRow, endColumn)) + this.removeFold(endFold); + var folds = this.getFoldsInRange(fold.range); + if (folds.length > 0) { + this.removeFolds(folds); + folds.forEach(function(subFold) { + fold.addSubFold(subFold); + }); + } + + for (var i = 0; i < foldData.length; i++) { + var foldLine = foldData[i]; + if (endRow == foldLine.start.row) { + foldLine.addFold(fold); + added = true; + break; + } else if (startRow == foldLine.end.row) { + foldLine.addFold(fold); + added = true; + if (!fold.sameRow) { + var foldLineNext = foldData[i + 1]; + if (foldLineNext && foldLineNext.start.row == endRow) { + foldLine.merge(foldLineNext); + break; + } + } + break; + } else if (endRow <= foldLine.start.row) { + break; + } + } + + if (!added) + foldLine = this.$addFoldLine(new FoldLine(this.$foldData, fold)); + + if (this.$useWrapMode) + this.$updateWrapData(foldLine.start.row, foldLine.start.row); + else + this.$updateRowLengthCache(foldLine.start.row, foldLine.start.row); + this.$modified = true; + this._emit("changeFold", { data: fold, action: "add" }); + + return fold; + }; + + this.addFolds = function(folds) { + folds.forEach(function(fold) { + this.addFold(fold); + }, this); + }; + + this.removeFold = function(fold) { + var foldLine = fold.foldLine; + var startRow = foldLine.start.row; + var endRow = foldLine.end.row; + + var foldLines = this.$foldData; + var folds = foldLine.folds; + if (folds.length == 1) { + foldLines.splice(foldLines.indexOf(foldLine), 1); + } else + if (foldLine.range.isEnd(fold.end.row, fold.end.column)) { + folds.pop(); + foldLine.end.row = folds[folds.length - 1].end.row; + foldLine.end.column = folds[folds.length - 1].end.column; + } else + if (foldLine.range.isStart(fold.start.row, fold.start.column)) { + folds.shift(); + foldLine.start.row = folds[0].start.row; + foldLine.start.column = folds[0].start.column; + } else + if (fold.sameRow) { + folds.splice(folds.indexOf(fold), 1); + } else + { + var newFoldLine = foldLine.split(fold.start.row, fold.start.column); + folds = newFoldLine.folds; + folds.shift(); + newFoldLine.start.row = folds[0].start.row; + newFoldLine.start.column = folds[0].start.column; + } + + if (!this.$updating) { + if (this.$useWrapMode) + this.$updateWrapData(startRow, endRow); + else + this.$updateRowLengthCache(startRow, endRow); + } + this.$modified = true; + this._emit("changeFold", { data: fold, action: "remove" }); + }; + + this.removeFolds = function(folds) { + var cloneFolds = []; + for (var i = 0; i < folds.length; i++) { + cloneFolds.push(folds[i]); + } + + cloneFolds.forEach(function(fold) { + this.removeFold(fold); + }, this); + this.$modified = true; + }; + + this.expandFold = function(fold) { + this.removeFold(fold); + fold.subFolds.forEach(function(subFold) { + fold.restoreRange(subFold); + this.addFold(subFold); + }, this); + if (fold.collapseChildren > 0) { + this.foldAll(fold.start.row+1, fold.end.row, fold.collapseChildren-1); + } + fold.subFolds = []; + }; + + this.expandFolds = function(folds) { + folds.forEach(function(fold) { + this.expandFold(fold); + }, this); + }; + + this.unfold = function(location, expandInner) { + var range, folds; + if (location == null) { + range = new Range(0, 0, this.getLength(), 0); + expandInner = true; + } else if (typeof location == "number") + range = new Range(location, 0, location, this.getLine(location).length); + else if ("row" in location) + range = Range.fromPoints(location, location); + else + range = location; + + folds = this.getFoldsInRangeList(range); + if (expandInner) { + this.removeFolds(folds); + } else { + var subFolds = folds; + while (subFolds.length) { + this.expandFolds(subFolds); + subFolds = this.getFoldsInRangeList(range); + } + } + if (folds.length) + return folds; + }; + this.isRowFolded = function(docRow, startFoldRow) { + return !!this.getFoldLine(docRow, startFoldRow); + }; + + this.getRowFoldEnd = function(docRow, startFoldRow) { + var foldLine = this.getFoldLine(docRow, startFoldRow); + return foldLine ? foldLine.end.row : docRow; + }; + + this.getRowFoldStart = function(docRow, startFoldRow) { + var foldLine = this.getFoldLine(docRow, startFoldRow); + return foldLine ? foldLine.start.row : docRow; + }; + + this.getFoldDisplayLine = function(foldLine, endRow, endColumn, startRow, startColumn) { + if (startRow == null) + startRow = foldLine.start.row; + if (startColumn == null) + startColumn = 0; + if (endRow == null) + endRow = foldLine.end.row; + if (endColumn == null) + endColumn = this.getLine(endRow).length; + var doc = this.doc; + var textLine = ""; + + foldLine.walk(function(placeholder, row, column, lastColumn) { + if (row < startRow) + return; + if (row == startRow) { + if (column < startColumn) + return; + lastColumn = Math.max(startColumn, lastColumn); + } + + if (placeholder != null) { + textLine += placeholder; + } else { + textLine += doc.getLine(row).substring(lastColumn, column); + } + }, endRow, endColumn); + return textLine; + }; + + this.getDisplayLine = function(row, endColumn, startRow, startColumn) { + var foldLine = this.getFoldLine(row); + + if (!foldLine) { + var line; + line = this.doc.getLine(row); + return line.substring(startColumn || 0, endColumn || line.length); + } else { + return this.getFoldDisplayLine( + foldLine, row, endColumn, startRow, startColumn); + } + }; + + this.$cloneFoldData = function() { + var fd = []; + fd = this.$foldData.map(function(foldLine) { + var folds = foldLine.folds.map(function(fold) { + return fold.clone(); + }); + return new FoldLine(fd, folds); + }); + + return fd; + }; + + this.toggleFold = function(tryToUnfold) { + var selection = this.selection; + var range = selection.getRange(); + var fold; + var bracketPos; + + if (range.isEmpty()) { + var cursor = range.start; + fold = this.getFoldAt(cursor.row, cursor.column); + + if (fold) { + this.expandFold(fold); + return; + } else if (bracketPos = this.findMatchingBracket(cursor)) { + if (range.comparePoint(bracketPos) == 1) { + range.end = bracketPos; + } else { + range.start = bracketPos; + range.start.column++; + range.end.column--; + } + } else if (bracketPos = this.findMatchingBracket({row: cursor.row, column: cursor.column + 1})) { + if (range.comparePoint(bracketPos) == 1) + range.end = bracketPos; + else + range.start = bracketPos; + + range.start.column++; + } else { + range = this.getCommentFoldRange(cursor.row, cursor.column) || range; + } + } else { + var folds = this.getFoldsInRange(range); + if (tryToUnfold && folds.length) { + this.expandFolds(folds); + return; + } else if (folds.length == 1 ) { + fold = folds[0]; + } + } + + if (!fold) + fold = this.getFoldAt(range.start.row, range.start.column); + + if (fold && fold.range.toString() == range.toString()) { + this.expandFold(fold); + return; + } + + var placeholder = "..."; + if (!range.isMultiLine()) { + placeholder = this.getTextRange(range); + if(placeholder.length < 4) + return; + placeholder = placeholder.trim().substring(0, 2) + ".."; + } + + this.addFold(placeholder, range); + }; + + this.getCommentFoldRange = function(row, column, dir) { + var iterator = new TokenIterator(this, row, column); + var token = iterator.getCurrentToken(); + if (token && /^comment|string/.test(token.type)) { + var range = new Range(); + var re = new RegExp(token.type.replace(/\..*/, "\\.")); + if (dir != 1) { + do { + token = iterator.stepBackward(); + } while(token && re.test(token.type)); + iterator.stepForward(); + } + + range.start.row = iterator.getCurrentTokenRow(); + range.start.column = iterator.getCurrentTokenColumn() + 2; + + iterator = new TokenIterator(this, row, column); + + if (dir != -1) { + do { + token = iterator.stepForward(); + } while(token && re.test(token.type)); + token = iterator.stepBackward(); + } else + token = iterator.getCurrentToken(); + + range.end.row = iterator.getCurrentTokenRow(); + range.end.column = iterator.getCurrentTokenColumn() + token.value.length - 2; + return range; + } + }; + + this.foldAll = function(startRow, endRow, depth) { + if (depth == undefined) + depth = 100000; // JSON.stringify doesn't hanle Infinity + var foldWidgets = this.foldWidgets; + if (!foldWidgets) + return; // mode doesn't support folding + endRow = endRow || this.getLength(); + startRow = startRow || 0; + for (var row = startRow; row < endRow; row++) { + if (foldWidgets[row] == null) + foldWidgets[row] = this.getFoldWidget(row); + if (foldWidgets[row] != "start") + continue; + + var range = this.getFoldWidgetRange(row); + if (range && range.isMultiLine() + && range.end.row <= endRow + && range.start.row >= startRow + ) { + row = range.end.row; + try { + var fold = this.addFold("...", range); + if (fold) + fold.collapseChildren = depth; + } catch(e) {} + } + } + }; + this.$foldStyles = { + "manual": 1, + "markbegin": 1, + "markbeginend": 1 + }; + this.$foldStyle = "markbegin"; + this.setFoldStyle = function(style) { + if (!this.$foldStyles[style]) + throw new Error("invalid fold style: " + style + "[" + Object.keys(this.$foldStyles).join(", ") + "]"); + + if (this.$foldStyle == style) + return; + + this.$foldStyle = style; + + if (style == "manual") + this.unfold(); + var mode = this.$foldMode; + this.$setFolding(null); + this.$setFolding(mode); + }; + + this.$setFolding = function(foldMode) { + if (this.$foldMode == foldMode) + return; + + this.$foldMode = foldMode; + + this.off('change', this.$updateFoldWidgets); + this.off('tokenizerUpdate', this.$tokenizerUpdateFoldWidgets); + this._emit("changeAnnotation"); + + if (!foldMode || this.$foldStyle == "manual") { + this.foldWidgets = null; + return; + } + + this.foldWidgets = []; + this.getFoldWidget = foldMode.getFoldWidget.bind(foldMode, this, this.$foldStyle); + this.getFoldWidgetRange = foldMode.getFoldWidgetRange.bind(foldMode, this, this.$foldStyle); + + this.$updateFoldWidgets = this.updateFoldWidgets.bind(this); + this.$tokenizerUpdateFoldWidgets = this.tokenizerUpdateFoldWidgets.bind(this); + this.on('change', this.$updateFoldWidgets); + this.on('tokenizerUpdate', this.$tokenizerUpdateFoldWidgets); + }; + + this.getParentFoldRangeData = function (row, ignoreCurrent) { + var fw = this.foldWidgets; + if (!fw || (ignoreCurrent && fw[row])) + return {}; + + var i = row - 1, firstRange; + while (i >= 0) { + var c = fw[i]; + if (c == null) + c = fw[i] = this.getFoldWidget(i); + + if (c == "start") { + var range = this.getFoldWidgetRange(i); + if (!firstRange) + firstRange = range; + if (range && range.end.row >= row) + break; + } + i--; + } + + return { + range: i !== -1 && range, + firstRange: firstRange + }; + } + + this.onFoldWidgetClick = function(row, e) { + e = e.domEvent; + var options = { + children: e.shiftKey, + all: e.ctrlKey || e.metaKey, + siblings: e.altKey + }; + + var range = this.$toggleFoldWidget(row, options); + if (!range) { + var el = (e.target || e.srcElement) + if (el && /ace_fold-widget/.test(el.className)) + el.className += " ace_invalid"; + } + }; + + this.$toggleFoldWidget = function(row, options) { + if (!this.getFoldWidget) + return; + var type = this.getFoldWidget(row); + var line = this.getLine(row); + + var dir = type === "end" ? -1 : 1; + var fold = this.getFoldAt(row, dir === -1 ? 0 : line.length, dir); + + if (fold) { + if (options.children || options.all) + this.removeFold(fold); + else + this.expandFold(fold); + return; + } + + var range = this.getFoldWidgetRange(row, true); + if (range && !range.isMultiLine()) { + fold = this.getFoldAt(range.start.row, range.start.column, 1); + if (fold && range.isEqual(fold.range)) { + this.removeFold(fold); + return; + } + } + + if (options.siblings) { + var data = this.getParentFoldRangeData(row); + if (data.range) { + var startRow = data.range.start.row + 1; + var endRow = data.range.end.row; + } + this.foldAll(startRow, endRow, options.all ? 10000 : 0); + } else if (options.children) { + endRow = range ? range.end.row : this.getLength(); + this.foldAll(row + 1, range.end.row, options.all ? 10000 : 0); + } else if (range) { + if (options.all) + range.collapseChildren = 10000; + this.addFold("...", range); + } + + return range; + }; + + + + this.toggleFoldWidget = function(toggleParent) { + var row = this.selection.getCursor().row; + row = this.getRowFoldStart(row); + var range = this.$toggleFoldWidget(row, {}); + + if (range) + return; + var data = this.getParentFoldRangeData(row, true); + range = data.range || data.firstRange; + + if (range) { + row = range.start.row; + var fold = this.getFoldAt(row, this.getLine(row).length, 1); + + if (fold) { + this.removeFold(fold); + } else { + this.addFold("...", range); + } + } + }; + + this.updateFoldWidgets = function(e) { + var delta = e.data; + var range = delta.range; + var firstRow = range.start.row; + var len = range.end.row - firstRow; + + if (len === 0) { + this.foldWidgets[firstRow] = null; + } else if (delta.action == "removeText" || delta.action == "removeLines") { + this.foldWidgets.splice(firstRow, len + 1, null); + } else { + var args = Array(len + 1); + args.unshift(firstRow, 1); + this.foldWidgets.splice.apply(this.foldWidgets, args); + } + }; + this.tokenizerUpdateFoldWidgets = function(e) { + var rows = e.data; + if (rows.first != rows.last) { + if (this.foldWidgets.length > rows.first) + this.foldWidgets.splice(rows.first, this.foldWidgets.length); + } + } +} + +exports.Folding = Folding; + +}); + +define("ace/edit_session/bracket_match",["require","exports","module","ace/token_iterator","ace/range"], function(require, exports, module) { +"use strict"; + +var TokenIterator = require("../token_iterator").TokenIterator; +var Range = require("../range").Range; + + +function BracketMatch() { + + this.findMatchingBracket = function(position, chr) { + if (position.column == 0) return null; + + var charBeforeCursor = chr || this.getLine(position.row).charAt(position.column-1); + if (charBeforeCursor == "") return null; + + var match = charBeforeCursor.match(/([\(\[\{])|([\)\]\}])/); + if (!match) + return null; + + if (match[1]) + return this.$findClosingBracket(match[1], position); + else + return this.$findOpeningBracket(match[2], position); + }; + + this.getBracketRange = function(pos) { + var line = this.getLine(pos.row); + var before = true, range; + + var chr = line.charAt(pos.column-1); + var match = chr && chr.match(/([\(\[\{])|([\)\]\}])/); + if (!match) { + chr = line.charAt(pos.column); + pos = {row: pos.row, column: pos.column + 1}; + match = chr && chr.match(/([\(\[\{])|([\)\]\}])/); + before = false; + } + if (!match) + return null; + + if (match[1]) { + var bracketPos = this.$findClosingBracket(match[1], pos); + if (!bracketPos) + return null; + range = Range.fromPoints(pos, bracketPos); + if (!before) { + range.end.column++; + range.start.column--; + } + range.cursor = range.end; + } else { + var bracketPos = this.$findOpeningBracket(match[2], pos); + if (!bracketPos) + return null; + range = Range.fromPoints(bracketPos, pos); + if (!before) { + range.start.column++; + range.end.column--; + } + range.cursor = range.start; + } + + return range; + }; + + this.$brackets = { + ")": "(", + "(": ")", + "]": "[", + "[": "]", + "{": "}", + "}": "{" + }; + + this.$findOpeningBracket = function(bracket, position, typeRe) { + var openBracket = this.$brackets[bracket]; + var depth = 1; + + var iterator = new TokenIterator(this, position.row, position.column); + var token = iterator.getCurrentToken(); + if (!token) + token = iterator.stepForward(); + if (!token) + return; + + if (!typeRe){ + typeRe = new RegExp( + "(\\.?" + + token.type.replace(".", "\\.").replace("rparen", ".paren") + .replace(/\b(?:end|start|begin)\b/, "") + + ")+" + ); + } + var valueIndex = position.column - iterator.getCurrentTokenColumn() - 2; + var value = token.value; + + while (true) { + + while (valueIndex >= 0) { + var chr = value.charAt(valueIndex); + if (chr == openBracket) { + depth -= 1; + if (depth == 0) { + return {row: iterator.getCurrentTokenRow(), + column: valueIndex + iterator.getCurrentTokenColumn()}; + } + } + else if (chr == bracket) { + depth += 1; + } + valueIndex -= 1; + } + do { + token = iterator.stepBackward(); + } while (token && !typeRe.test(token.type)); + + if (token == null) + break; + + value = token.value; + valueIndex = value.length - 1; + } + + return null; + }; + + this.$findClosingBracket = function(bracket, position, typeRe) { + var closingBracket = this.$brackets[bracket]; + var depth = 1; + + var iterator = new TokenIterator(this, position.row, position.column); + var token = iterator.getCurrentToken(); + if (!token) + token = iterator.stepForward(); + if (!token) + return; + + if (!typeRe){ + typeRe = new RegExp( + "(\\.?" + + token.type.replace(".", "\\.").replace("lparen", ".paren") + .replace(/\b(?:end|start|begin)\b/, "") + + ")+" + ); + } + var valueIndex = position.column - iterator.getCurrentTokenColumn(); + + while (true) { + + var value = token.value; + var valueLength = value.length; + while (valueIndex < valueLength) { + var chr = value.charAt(valueIndex); + if (chr == closingBracket) { + depth -= 1; + if (depth == 0) { + return {row: iterator.getCurrentTokenRow(), + column: valueIndex + iterator.getCurrentTokenColumn()}; + } + } + else if (chr == bracket) { + depth += 1; + } + valueIndex += 1; + } + do { + token = iterator.stepForward(); + } while (token && !typeRe.test(token.type)); + + if (token == null) + break; + + valueIndex = 0; + } + + return null; + }; +} +exports.BracketMatch = BracketMatch; + +}); + +define("ace/edit_session",["require","exports","module","ace/lib/oop","ace/lib/lang","ace/config","ace/lib/event_emitter","ace/selection","ace/mode/text","ace/range","ace/document","ace/background_tokenizer","ace/search_highlight","ace/edit_session/folding","ace/edit_session/bracket_match"], function(require, exports, module) { +"use strict"; + +var oop = require("./lib/oop"); +var lang = require("./lib/lang"); +var config = require("./config"); +var EventEmitter = require("./lib/event_emitter").EventEmitter; +var Selection = require("./selection").Selection; +var TextMode = require("./mode/text").Mode; +var Range = require("./range").Range; +var Document = require("./document").Document; +var BackgroundTokenizer = require("./background_tokenizer").BackgroundTokenizer; +var SearchHighlight = require("./search_highlight").SearchHighlight; + +var EditSession = function(text, mode) { + this.$breakpoints = []; + this.$decorations = []; + this.$frontMarkers = {}; + this.$backMarkers = {}; + this.$markerId = 1; + this.$undoSelect = true; + + this.$foldData = []; + this.$foldData.toString = function() { + return this.join("\n"); + } + this.on("changeFold", this.onChangeFold.bind(this)); + this.$onChange = this.onChange.bind(this); + + if (typeof text != "object" || !text.getLine) + text = new Document(text); + + this.setDocument(text); + this.selection = new Selection(this); + + config.resetOptions(this); + this.setMode(mode); + config._signal("session", this); +}; + + +(function() { + + oop.implement(this, EventEmitter); + this.setDocument = function(doc) { + if (this.doc) + this.doc.removeListener("change", this.$onChange); + + this.doc = doc; + doc.on("change", this.$onChange); + + if (this.bgTokenizer) + this.bgTokenizer.setDocument(this.getDocument()); + + this.resetCaches(); + }; + this.getDocument = function() { + return this.doc; + }; + this.$resetRowCache = function(docRow) { + if (!docRow) { + this.$docRowCache = []; + this.$screenRowCache = []; + return; + } + var l = this.$docRowCache.length; + var i = this.$getRowCacheIndex(this.$docRowCache, docRow) + 1; + if (l > i) { + this.$docRowCache.splice(i, l); + this.$screenRowCache.splice(i, l); + } + }; + + this.$getRowCacheIndex = function(cacheArray, val) { + var low = 0; + var hi = cacheArray.length - 1; + + while (low <= hi) { + var mid = (low + hi) >> 1; + var c = cacheArray[mid]; + + if (val > c) + low = mid + 1; + else if (val < c) + hi = mid - 1; + else + return mid; + } + + return low -1; + }; + + this.resetCaches = function() { + this.$modified = true; + this.$wrapData = []; + this.$rowLengthCache = []; + this.$resetRowCache(0); + if (this.bgTokenizer) + this.bgTokenizer.start(0); + }; + + this.onChangeFold = function(e) { + var fold = e.data; + this.$resetRowCache(fold.start.row); + }; + + this.onChange = function(e) { + var delta = e.data; + this.$modified = true; + + this.$resetRowCache(delta.range.start.row); + + var removedFolds = this.$updateInternalDataOnChange(e); + if (!this.$fromUndo && this.$undoManager && !delta.ignore) { + this.$deltasDoc.push(delta); + if (removedFolds && removedFolds.length != 0) { + this.$deltasFold.push({ + action: "removeFolds", + folds: removedFolds + }); + } + + this.$informUndoManager.schedule(); + } + + this.bgTokenizer && this.bgTokenizer.$updateOnChange(delta); + this._signal("change", e); + }; + this.setValue = function(text) { + this.doc.setValue(text); + this.selection.moveTo(0, 0); + + this.$resetRowCache(0); + this.$deltas = []; + this.$deltasDoc = []; + this.$deltasFold = []; + this.setUndoManager(this.$undoManager); + this.getUndoManager().reset(); + }; + this.getValue = + this.toString = function() { + return this.doc.getValue(); + }; + this.getSelection = function() { + return this.selection; + }; + this.getState = function(row) { + return this.bgTokenizer.getState(row); + }; + this.getTokens = function(row) { + return this.bgTokenizer.getTokens(row); + }; + this.getTokenAt = function(row, column) { + var tokens = this.bgTokenizer.getTokens(row); + var token, c = 0; + if (column == null) { + i = tokens.length - 1; + c = this.getLine(row).length; + } else { + for (var i = 0; i < tokens.length; i++) { + c += tokens[i].value.length; + if (c >= column) + break; + } + } + token = tokens[i]; + if (!token) + return null; + token.index = i; + token.start = c - token.value.length; + return token; + }; + this.setUndoManager = function(undoManager) { + this.$undoManager = undoManager; + this.$deltas = []; + this.$deltasDoc = []; + this.$deltasFold = []; + + if (this.$informUndoManager) + this.$informUndoManager.cancel(); + + if (undoManager) { + var self = this; + + this.$syncInformUndoManager = function() { + self.$informUndoManager.cancel(); + + if (self.$deltasFold.length) { + self.$deltas.push({ + group: "fold", + deltas: self.$deltasFold + }); + self.$deltasFold = []; + } + + if (self.$deltasDoc.length) { + self.$deltas.push({ + group: "doc", + deltas: self.$deltasDoc + }); + self.$deltasDoc = []; + } + + if (self.$deltas.length > 0) { + undoManager.execute({ + action: "aceupdate", + args: [self.$deltas, self], + merge: self.mergeUndoDeltas + }); + } + self.mergeUndoDeltas = false; + self.$deltas = []; + }; + this.$informUndoManager = lang.delayedCall(this.$syncInformUndoManager); + } + }; + this.markUndoGroup = function() { + if (this.$syncInformUndoManager) + this.$syncInformUndoManager(); + }; + + this.$defaultUndoManager = { + undo: function() {}, + redo: function() {}, + reset: function() {} + }; + this.getUndoManager = function() { + return this.$undoManager || this.$defaultUndoManager; + }; + this.getTabString = function() { + if (this.getUseSoftTabs()) { + return lang.stringRepeat(" ", this.getTabSize()); + } else { + return "\t"; + } + }; + this.setUseSoftTabs = function(val) { + this.setOption("useSoftTabs", val); + }; + this.getUseSoftTabs = function() { + return this.$useSoftTabs && !this.$mode.$indentWithTabs; + }; + this.setTabSize = function(tabSize) { + this.setOption("tabSize", tabSize); + }; + this.getTabSize = function() { + return this.$tabSize; + }; + this.isTabStop = function(position) { + return this.$useSoftTabs && (position.column % this.$tabSize === 0); + }; + + this.$overwrite = false; + this.setOverwrite = function(overwrite) { + this.setOption("overwrite", overwrite); + }; + this.getOverwrite = function() { + return this.$overwrite; + }; + this.toggleOverwrite = function() { + this.setOverwrite(!this.$overwrite); + }; + this.addGutterDecoration = function(row, className) { + if (!this.$decorations[row]) + this.$decorations[row] = ""; + this.$decorations[row] += " " + className; + this._signal("changeBreakpoint", {}); + }; + this.removeGutterDecoration = function(row, className) { + this.$decorations[row] = (this.$decorations[row] || "").replace(" " + className, ""); + this._signal("changeBreakpoint", {}); + }; + this.getBreakpoints = function() { + return this.$breakpoints; + }; + this.setBreakpoints = function(rows) { + this.$breakpoints = []; + for (var i=0; i 0) + inToken = !!line.charAt(column - 1).match(this.tokenRe); + + if (!inToken) + inToken = !!line.charAt(column).match(this.tokenRe); + + if (inToken) + var re = this.tokenRe; + else if (/^\s+$/.test(line.slice(column-1, column+1))) + var re = /\s/; + else + var re = this.nonTokenRe; + + var start = column; + if (start > 0) { + do { + start--; + } + while (start >= 0 && line.charAt(start).match(re)); + start++; + } + + var end = column; + while (end < line.length && line.charAt(end).match(re)) { + end++; + } + + return new Range(row, start, row, end); + }; + this.getAWordRange = function(row, column) { + var wordRange = this.getWordRange(row, column); + var line = this.getLine(wordRange.end.row); + + while (line.charAt(wordRange.end.column).match(/[ \t]/)) { + wordRange.end.column += 1; + } + return wordRange; + }; + this.setNewLineMode = function(newLineMode) { + this.doc.setNewLineMode(newLineMode); + }; + this.getNewLineMode = function() { + return this.doc.getNewLineMode(); + }; + this.setUseWorker = function(useWorker) { this.setOption("useWorker", useWorker); }; + this.getUseWorker = function() { return this.$useWorker; }; + this.onReloadTokenizer = function(e) { + var rows = e.data; + this.bgTokenizer.start(rows.first); + this._signal("tokenizerUpdate", e); + }; + + this.$modes = {}; + this.$mode = null; + this.$modeId = null; + this.setMode = function(mode, cb) { + if (mode && typeof mode === "object") { + if (mode.getTokenizer) + return this.$onChangeMode(mode); + var options = mode; + var path = options.path; + } else { + path = mode || "ace/mode/text"; + } + if (!this.$modes["ace/mode/text"]) + this.$modes["ace/mode/text"] = new TextMode(); + + if (this.$modes[path] && !options) { + this.$onChangeMode(this.$modes[path]); + cb && cb(); + return; + } + this.$modeId = path; + config.loadModule(["mode", path], function(m) { + if (this.$modeId !== path) + return cb && cb(); + if (this.$modes[path] && !options) + return this.$onChangeMode(this.$modes[path]); + if (m && m.Mode) { + m = new m.Mode(options); + if (!options) { + this.$modes[path] = m; + m.$id = path; + } + this.$onChangeMode(m); + cb && cb(); + } + }.bind(this)); + if (!this.$mode) + this.$onChangeMode(this.$modes["ace/mode/text"], true); + }; + + this.$onChangeMode = function(mode, $isPlaceholder) { + if (!$isPlaceholder) + this.$modeId = mode.$id; + if (this.$mode === mode) + return; + + this.$mode = mode; + + this.$stopWorker(); + + if (this.$useWorker) + this.$startWorker(); + + var tokenizer = mode.getTokenizer(); + + if(tokenizer.addEventListener !== undefined) { + var onReloadTokenizer = this.onReloadTokenizer.bind(this); + tokenizer.addEventListener("update", onReloadTokenizer); + } + + if (!this.bgTokenizer) { + this.bgTokenizer = new BackgroundTokenizer(tokenizer); + var _self = this; + this.bgTokenizer.addEventListener("update", function(e) { + _self._signal("tokenizerUpdate", e); + }); + } else { + this.bgTokenizer.setTokenizer(tokenizer); + } + + this.bgTokenizer.setDocument(this.getDocument()); + + this.tokenRe = mode.tokenRe; + this.nonTokenRe = mode.nonTokenRe; + + + if (!$isPlaceholder) { + if (mode.attachToSession) + mode.attachToSession(this); + this.$options.wrapMethod.set.call(this, this.$wrapMethod); + this.$setFolding(mode.foldingRules); + this.bgTokenizer.start(0); + this._emit("changeMode"); + } + }; + + this.$stopWorker = function() { + if (this.$worker) { + this.$worker.terminate(); + this.$worker = null; + } + }; + + this.$startWorker = function() { + try { + this.$worker = this.$mode.createWorker(this); + } catch (e) { + config.warn("Could not load worker", e); + this.$worker = null; + } + }; + this.getMode = function() { + return this.$mode; + }; + + this.$scrollTop = 0; + this.setScrollTop = function(scrollTop) { + if (this.$scrollTop === scrollTop || isNaN(scrollTop)) + return; + + this.$scrollTop = scrollTop; + this._signal("changeScrollTop", scrollTop); + }; + this.getScrollTop = function() { + return this.$scrollTop; + }; + + this.$scrollLeft = 0; + this.setScrollLeft = function(scrollLeft) { + if (this.$scrollLeft === scrollLeft || isNaN(scrollLeft)) + return; + + this.$scrollLeft = scrollLeft; + this._signal("changeScrollLeft", scrollLeft); + }; + this.getScrollLeft = function() { + return this.$scrollLeft; + }; + this.getScreenWidth = function() { + this.$computeWidth(); + if (this.lineWidgets) + return Math.max(this.getLineWidgetMaxWidth(), this.screenWidth); + return this.screenWidth; + }; + + this.getLineWidgetMaxWidth = function() { + if (this.lineWidgetsWidth != null) return this.lineWidgetsWidth; + var width = 0; + this.lineWidgets.forEach(function(w) { + if (w && w.screenWidth > width) + width = w.screenWidth; + }); + return this.lineWidgetWidth = width; + }; + + this.$computeWidth = function(force) { + if (this.$modified || force) { + this.$modified = false; + + if (this.$useWrapMode) + return this.screenWidth = this.$wrapLimit; + + var lines = this.doc.getAllLines(); + var cache = this.$rowLengthCache; + var longestScreenLine = 0; + var foldIndex = 0; + var foldLine = this.$foldData[foldIndex]; + var foldStart = foldLine ? foldLine.start.row : Infinity; + var len = lines.length; + + for (var i = 0; i < len; i++) { + if (i > foldStart) { + i = foldLine.end.row + 1; + if (i >= len) + break; + foldLine = this.$foldData[foldIndex++]; + foldStart = foldLine ? foldLine.start.row : Infinity; + } + + if (cache[i] == null) + cache[i] = this.$getStringScreenWidth(lines[i])[0]; + + if (cache[i] > longestScreenLine) + longestScreenLine = cache[i]; + } + this.screenWidth = longestScreenLine; + } + }; + this.getLine = function(row) { + return this.doc.getLine(row); + }; + this.getLines = function(firstRow, lastRow) { + return this.doc.getLines(firstRow, lastRow); + }; + this.getLength = function() { + return this.doc.getLength(); + }; + this.getTextRange = function(range) { + return this.doc.getTextRange(range || this.selection.getRange()); + }; + this.insert = function(position, text) { + return this.doc.insert(position, text); + }; + this.remove = function(range) { + return this.doc.remove(range); + }; + this.undoChanges = function(deltas, dontSelect) { + if (!deltas.length) + return; + + this.$fromUndo = true; + var lastUndoRange = null; + for (var i = deltas.length - 1; i != -1; i--) { + var delta = deltas[i]; + if (delta.group == "doc") { + this.doc.revertDeltas(delta.deltas); + lastUndoRange = + this.$getUndoSelection(delta.deltas, true, lastUndoRange); + } else { + delta.deltas.forEach(function(foldDelta) { + this.addFolds(foldDelta.folds); + }, this); + } + } + this.$fromUndo = false; + lastUndoRange && + this.$undoSelect && + !dontSelect && + this.selection.setSelectionRange(lastUndoRange); + return lastUndoRange; + }; + this.redoChanges = function(deltas, dontSelect) { + if (!deltas.length) + return; + + this.$fromUndo = true; + var lastUndoRange = null; + for (var i = 0; i < deltas.length; i++) { + var delta = deltas[i]; + if (delta.group == "doc") { + this.doc.applyDeltas(delta.deltas); + lastUndoRange = + this.$getUndoSelection(delta.deltas, false, lastUndoRange); + } + } + this.$fromUndo = false; + lastUndoRange && + this.$undoSelect && + !dontSelect && + this.selection.setSelectionRange(lastUndoRange); + return lastUndoRange; + }; + this.setUndoSelect = function(enable) { + this.$undoSelect = enable; + }; + + this.$getUndoSelection = function(deltas, isUndo, lastUndoRange) { + function isInsert(delta) { + var insert = + delta.action === "insertText" || delta.action === "insertLines"; + return isUndo ? !insert : insert; + } + + var delta = deltas[0]; + var range, point; + var lastDeltaIsInsert = false; + if (isInsert(delta)) { + range = Range.fromPoints(delta.range.start, delta.range.end); + lastDeltaIsInsert = true; + } else { + range = Range.fromPoints(delta.range.start, delta.range.start); + lastDeltaIsInsert = false; + } + + for (var i = 1; i < deltas.length; i++) { + delta = deltas[i]; + if (isInsert(delta)) { + point = delta.range.start; + if (range.compare(point.row, point.column) == -1) { + range.setStart(delta.range.start); + } + point = delta.range.end; + if (range.compare(point.row, point.column) == 1) { + range.setEnd(delta.range.end); + } + lastDeltaIsInsert = true; + } else { + point = delta.range.start; + if (range.compare(point.row, point.column) == -1) { + range = + Range.fromPoints(delta.range.start, delta.range.start); + } + lastDeltaIsInsert = false; + } + } + if (lastUndoRange != null) { + if (Range.comparePoints(lastUndoRange.start, range.start) === 0) { + lastUndoRange.start.column += range.end.column - range.start.column; + lastUndoRange.end.column += range.end.column - range.start.column; + } + + var cmp = lastUndoRange.compareRange(range); + if (cmp == 1) { + range.setStart(lastUndoRange.start); + } else if (cmp == -1) { + range.setEnd(lastUndoRange.end); + } + } + + return range; + }; + this.replace = function(range, text) { + return this.doc.replace(range, text); + }; + this.moveText = function(fromRange, toPosition, copy) { + var text = this.getTextRange(fromRange); + var folds = this.getFoldsInRange(fromRange); + + var toRange = Range.fromPoints(toPosition, toPosition); + if (!copy) { + this.remove(fromRange); + var rowDiff = fromRange.start.row - fromRange.end.row; + var collDiff = rowDiff ? -fromRange.end.column : fromRange.start.column - fromRange.end.column; + if (collDiff) { + if (toRange.start.row == fromRange.end.row && toRange.start.column > fromRange.end.column) + toRange.start.column += collDiff; + if (toRange.end.row == fromRange.end.row && toRange.end.column > fromRange.end.column) + toRange.end.column += collDiff; + } + if (rowDiff && toRange.start.row >= fromRange.end.row) { + toRange.start.row += rowDiff; + toRange.end.row += rowDiff; + } + } + + toRange.end = this.insert(toRange.start, text); + if (folds.length) { + var oldStart = fromRange.start; + var newStart = toRange.start; + var rowDiff = newStart.row - oldStart.row; + var collDiff = newStart.column - oldStart.column; + this.addFolds(folds.map(function(x) { + x = x.clone(); + if (x.start.row == oldStart.row) + x.start.column += collDiff; + if (x.end.row == oldStart.row) + x.end.column += collDiff; + x.start.row += rowDiff; + x.end.row += rowDiff; + return x; + })); + } + + return toRange; + }; + this.indentRows = function(startRow, endRow, indentString) { + indentString = indentString.replace(/\t/g, this.getTabString()); + for (var row=startRow; row<=endRow; row++) + this.insert({row: row, column:0}, indentString); + }; + this.outdentRows = function (range) { + var rowRange = range.collapseRows(); + var deleteRange = new Range(0, 0, 0, 0); + var size = this.getTabSize(); + + for (var i = rowRange.start.row; i <= rowRange.end.row; ++i) { + var line = this.getLine(i); + + deleteRange.start.row = i; + deleteRange.end.row = i; + for (var j = 0; j < size; ++j) + if (line.charAt(j) != ' ') + break; + if (j < size && line.charAt(j) == '\t') { + deleteRange.start.column = j; + deleteRange.end.column = j + 1; + } else { + deleteRange.start.column = 0; + deleteRange.end.column = j; + } + this.remove(deleteRange); + } + }; + + this.$moveLines = function(firstRow, lastRow, dir) { + firstRow = this.getRowFoldStart(firstRow); + lastRow = this.getRowFoldEnd(lastRow); + if (dir < 0) { + var row = this.getRowFoldStart(firstRow + dir); + if (row < 0) return 0; + var diff = row-firstRow; + } else if (dir > 0) { + var row = this.getRowFoldEnd(lastRow + dir); + if (row > this.doc.getLength()-1) return 0; + var diff = row-lastRow; + } else { + firstRow = this.$clipRowToDocument(firstRow); + lastRow = this.$clipRowToDocument(lastRow); + var diff = lastRow - firstRow + 1; + } + + var range = new Range(firstRow, 0, lastRow, Number.MAX_VALUE); + var folds = this.getFoldsInRange(range).map(function(x){ + x = x.clone(); + x.start.row += diff; + x.end.row += diff; + return x; + }); + + var lines = dir == 0 + ? this.doc.getLines(firstRow, lastRow) + : this.doc.removeLines(firstRow, lastRow); + this.doc.insertLines(firstRow+diff, lines); + folds.length && this.addFolds(folds); + return diff; + }; + this.moveLinesUp = function(firstRow, lastRow) { + return this.$moveLines(firstRow, lastRow, -1); + }; + this.moveLinesDown = function(firstRow, lastRow) { + return this.$moveLines(firstRow, lastRow, 1); + }; + this.duplicateLines = function(firstRow, lastRow) { + return this.$moveLines(firstRow, lastRow, 0); + }; + + + this.$clipRowToDocument = function(row) { + return Math.max(0, Math.min(row, this.doc.getLength()-1)); + }; + + this.$clipColumnToRow = function(row, column) { + if (column < 0) + return 0; + return Math.min(this.doc.getLine(row).length, column); + }; + + + this.$clipPositionToDocument = function(row, column) { + column = Math.max(0, column); + + if (row < 0) { + row = 0; + column = 0; + } else { + var len = this.doc.getLength(); + if (row >= len) { + row = len - 1; + column = this.doc.getLine(len-1).length; + } else { + column = Math.min(this.doc.getLine(row).length, column); + } + } + + return { + row: row, + column: column + }; + }; + + this.$clipRangeToDocument = function(range) { + if (range.start.row < 0) { + range.start.row = 0; + range.start.column = 0; + } else { + range.start.column = this.$clipColumnToRow( + range.start.row, + range.start.column + ); + } + + var len = this.doc.getLength() - 1; + if (range.end.row > len) { + range.end.row = len; + range.end.column = this.doc.getLine(len).length; + } else { + range.end.column = this.$clipColumnToRow( + range.end.row, + range.end.column + ); + } + return range; + }; + this.$wrapLimit = 80; + this.$useWrapMode = false; + this.$wrapLimitRange = { + min : null, + max : null + }; + this.setUseWrapMode = function(useWrapMode) { + if (useWrapMode != this.$useWrapMode) { + this.$useWrapMode = useWrapMode; + this.$modified = true; + this.$resetRowCache(0); + if (useWrapMode) { + var len = this.getLength(); + this.$wrapData = Array(len); + this.$updateWrapData(0, len - 1); + } + + this._signal("changeWrapMode"); + } + }; + this.getUseWrapMode = function() { + return this.$useWrapMode; + }; + this.setWrapLimitRange = function(min, max) { + if (this.$wrapLimitRange.min !== min || this.$wrapLimitRange.max !== max) { + this.$wrapLimitRange = { + min: min, + max: max + }; + this.$modified = true; + this._signal("changeWrapMode"); + } + }; + this.adjustWrapLimit = function(desiredLimit, $printMargin) { + var limits = this.$wrapLimitRange; + if (limits.max < 0) + limits = {min: $printMargin, max: $printMargin}; + var wrapLimit = this.$constrainWrapLimit(desiredLimit, limits.min, limits.max); + if (wrapLimit != this.$wrapLimit && wrapLimit > 1) { + this.$wrapLimit = wrapLimit; + this.$modified = true; + if (this.$useWrapMode) { + this.$updateWrapData(0, this.getLength() - 1); + this.$resetRowCache(0); + this._signal("changeWrapLimit"); + } + return true; + } + return false; + }; + + this.$constrainWrapLimit = function(wrapLimit, min, max) { + if (min) + wrapLimit = Math.max(min, wrapLimit); + + if (max) + wrapLimit = Math.min(max, wrapLimit); + + return wrapLimit; + }; + this.getWrapLimit = function() { + return this.$wrapLimit; + }; + this.setWrapLimit = function (limit) { + this.setWrapLimitRange(limit, limit); + }; + this.getWrapLimitRange = function() { + return { + min : this.$wrapLimitRange.min, + max : this.$wrapLimitRange.max + }; + }; + + this.$updateInternalDataOnChange = function(e) { + var useWrapMode = this.$useWrapMode; + var len; + var action = e.data.action; + var firstRow = e.data.range.start.row; + var lastRow = e.data.range.end.row; + var start = e.data.range.start; + var end = e.data.range.end; + var removedFolds = null; + + if (action.indexOf("Lines") != -1) { + if (action == "insertLines") { + lastRow = firstRow + (e.data.lines.length); + } else { + lastRow = firstRow; + } + len = e.data.lines ? e.data.lines.length : lastRow - firstRow; + } else { + len = lastRow - firstRow; + } + + this.$updating = true; + if (len != 0) { + if (action.indexOf("remove") != -1) { + this[useWrapMode ? "$wrapData" : "$rowLengthCache"].splice(firstRow, len); + + var foldLines = this.$foldData; + removedFolds = this.getFoldsInRange(e.data.range); + this.removeFolds(removedFolds); + + var foldLine = this.getFoldLine(end.row); + var idx = 0; + if (foldLine) { + foldLine.addRemoveChars(end.row, end.column, start.column - end.column); + foldLine.shiftRow(-len); + + var foldLineBefore = this.getFoldLine(firstRow); + if (foldLineBefore && foldLineBefore !== foldLine) { + foldLineBefore.merge(foldLine); + foldLine = foldLineBefore; + } + idx = foldLines.indexOf(foldLine) + 1; + } + + for (idx; idx < foldLines.length; idx++) { + var foldLine = foldLines[idx]; + if (foldLine.start.row >= end.row) { + foldLine.shiftRow(-len); + } + } + + lastRow = firstRow; + } else { + var args = Array(len); + args.unshift(firstRow, 0); + var arr = useWrapMode ? this.$wrapData : this.$rowLengthCache + arr.splice.apply(arr, args); + var foldLines = this.$foldData; + var foldLine = this.getFoldLine(firstRow); + var idx = 0; + if (foldLine) { + var cmp = foldLine.range.compareInside(start.row, start.column); + if (cmp == 0) { + foldLine = foldLine.split(start.row, start.column); + if (foldLine) { + foldLine.shiftRow(len); + foldLine.addRemoveChars(lastRow, 0, end.column - start.column); + } + } else + if (cmp == -1) { + foldLine.addRemoveChars(firstRow, 0, end.column - start.column); + foldLine.shiftRow(len); + } + idx = foldLines.indexOf(foldLine) + 1; + } + + for (idx; idx < foldLines.length; idx++) { + var foldLine = foldLines[idx]; + if (foldLine.start.row >= firstRow) { + foldLine.shiftRow(len); + } + } + } + } else { + len = Math.abs(e.data.range.start.column - e.data.range.end.column); + if (action.indexOf("remove") != -1) { + removedFolds = this.getFoldsInRange(e.data.range); + this.removeFolds(removedFolds); + + len = -len; + } + var foldLine = this.getFoldLine(firstRow); + if (foldLine) { + foldLine.addRemoveChars(firstRow, start.column, len); + } + } + + if (useWrapMode && this.$wrapData.length != this.doc.getLength()) { + console.error("doc.getLength() and $wrapData.length have to be the same!"); + } + this.$updating = false; + + if (useWrapMode) + this.$updateWrapData(firstRow, lastRow); + else + this.$updateRowLengthCache(firstRow, lastRow); + + return removedFolds; + }; + + this.$updateRowLengthCache = function(firstRow, lastRow, b) { + this.$rowLengthCache[firstRow] = null; + this.$rowLengthCache[lastRow] = null; + }; + + this.$updateWrapData = function(firstRow, lastRow) { + var lines = this.doc.getAllLines(); + var tabSize = this.getTabSize(); + var wrapData = this.$wrapData; + var wrapLimit = this.$wrapLimit; + var tokens; + var foldLine; + + var row = firstRow; + lastRow = Math.min(lastRow, lines.length - 1); + while (row <= lastRow) { + foldLine = this.getFoldLine(row, foldLine); + if (!foldLine) { + tokens = this.$getDisplayTokens(lines[row]); + wrapData[row] = this.$computeWrapSplits(tokens, wrapLimit, tabSize); + row ++; + } else { + tokens = []; + foldLine.walk(function(placeholder, row, column, lastColumn) { + var walkTokens; + if (placeholder != null) { + walkTokens = this.$getDisplayTokens( + placeholder, tokens.length); + walkTokens[0] = PLACEHOLDER_START; + for (var i = 1; i < walkTokens.length; i++) { + walkTokens[i] = PLACEHOLDER_BODY; + } + } else { + walkTokens = this.$getDisplayTokens( + lines[row].substring(lastColumn, column), + tokens.length); + } + tokens = tokens.concat(walkTokens); + }.bind(this), + foldLine.end.row, + lines[foldLine.end.row].length + 1 + ); + + wrapData[foldLine.start.row] = this.$computeWrapSplits(tokens, wrapLimit, tabSize); + row = foldLine.end.row + 1; + } + } + }; + var CHAR = 1, + CHAR_EXT = 2, + PLACEHOLDER_START = 3, + PLACEHOLDER_BODY = 4, + PUNCTUATION = 9, + SPACE = 10, + TAB = 11, + TAB_SPACE = 12; + + + this.$computeWrapSplits = function(tokens, wrapLimit) { + if (tokens.length == 0) { + return []; + } + + var splits = []; + var displayLength = tokens.length; + var lastSplit = 0, lastDocSplit = 0; + + var isCode = this.$wrapAsCode; + + function addSplit(screenPos) { + var displayed = tokens.slice(lastSplit, screenPos); + var len = displayed.length; + displayed.join(""). + replace(/12/g, function() { + len -= 1; + }). + replace(/2/g, function() { + len -= 1; + }); + + lastDocSplit += len; + splits.push(lastDocSplit); + lastSplit = screenPos; + } + + while (displayLength - lastSplit > wrapLimit) { + var split = lastSplit + wrapLimit; + if (tokens[split - 1] >= SPACE && tokens[split] >= SPACE) { + addSplit(split); + continue; + } + if (tokens[split] == PLACEHOLDER_START || tokens[split] == PLACEHOLDER_BODY) { + for (split; split != lastSplit - 1; split--) { + if (tokens[split] == PLACEHOLDER_START) { + break; + } + } + if (split > lastSplit) { + addSplit(split); + continue; + } + split = lastSplit + wrapLimit; + for (split; split < tokens.length; split++) { + if (tokens[split] != PLACEHOLDER_BODY) { + break; + } + } + if (split == tokens.length) { + break; // Breaks the while-loop. + } + addSplit(split); + continue; + } + var minSplit = Math.max(split - (isCode ? 10 : wrapLimit-(wrapLimit>>2)), lastSplit - 1); + while (split > minSplit && tokens[split] < PLACEHOLDER_START) { + split --; + } + if (isCode) { + while (split > minSplit && tokens[split] < PLACEHOLDER_START) { + split --; + } + while (split > minSplit && tokens[split] == PUNCTUATION) { + split --; + } + } else { + while (split > minSplit && tokens[split] < SPACE) { + split --; + } + } + if (split > minSplit) { + addSplit(++split); + continue; + } + split = lastSplit + wrapLimit; + if (tokens[split] == CHAR_EXT) + split--; + addSplit(split); + } + return splits; + }; + this.$getDisplayTokens = function(str, offset) { + var arr = []; + var tabSize; + offset = offset || 0; + + for (var i = 0; i < str.length; i++) { + var c = str.charCodeAt(i); + if (c == 9) { + tabSize = this.getScreenTabSize(arr.length + offset); + arr.push(TAB); + for (var n = 1; n < tabSize; n++) { + arr.push(TAB_SPACE); + } + } + else if (c == 32) { + arr.push(SPACE); + } else if((c > 39 && c < 48) || (c > 57 && c < 64)) { + arr.push(PUNCTUATION); + } + else if (c >= 0x1100 && isFullWidth(c)) { + arr.push(CHAR, CHAR_EXT); + } else { + arr.push(CHAR); + } + } + return arr; + }; + this.$getStringScreenWidth = function(str, maxScreenColumn, screenColumn) { + if (maxScreenColumn == 0) + return [0, 0]; + if (maxScreenColumn == null) + maxScreenColumn = Infinity; + screenColumn = screenColumn || 0; + + var c, column; + for (column = 0; column < str.length; column++) { + c = str.charCodeAt(column); + if (c == 9) { + screenColumn += this.getScreenTabSize(screenColumn); + } + else if (c >= 0x1100 && isFullWidth(c)) { + screenColumn += 2; + } else { + screenColumn += 1; + } + if (screenColumn > maxScreenColumn) { + break; + } + } + + return [screenColumn, column]; + }; + + this.lineWidgets = null; + this.getRowLength = function(row) { + if (this.lineWidgets) + var h = this.lineWidgets[row] && this.lineWidgets[row].rowCount || 0; + else + h = 0 + if (!this.$useWrapMode || !this.$wrapData[row]) { + return 1 + h; + } else { + return this.$wrapData[row].length + 1 + h; + } + }; + this.getRowLineCount = function(row) { + if (!this.$useWrapMode || !this.$wrapData[row]) { + return 1; + } else { + return this.$wrapData[row].length + 1; + } + }; + this.getScreenLastRowColumn = function(screenRow) { + var pos = this.screenToDocumentPosition(screenRow, Number.MAX_VALUE); + return this.documentToScreenColumn(pos.row, pos.column); + }; + this.getDocumentLastRowColumn = function(docRow, docColumn) { + var screenRow = this.documentToScreenRow(docRow, docColumn); + return this.getScreenLastRowColumn(screenRow); + }; + this.getDocumentLastRowColumnPosition = function(docRow, docColumn) { + var screenRow = this.documentToScreenRow(docRow, docColumn); + return this.screenToDocumentPosition(screenRow, Number.MAX_VALUE / 10); + }; + this.getRowSplitData = function(row) { + if (!this.$useWrapMode) { + return undefined; + } else { + return this.$wrapData[row]; + } + }; + this.getScreenTabSize = function(screenColumn) { + return this.$tabSize - screenColumn % this.$tabSize; + }; + + + this.screenToDocumentRow = function(screenRow, screenColumn) { + return this.screenToDocumentPosition(screenRow, screenColumn).row; + }; + + + this.screenToDocumentColumn = function(screenRow, screenColumn) { + return this.screenToDocumentPosition(screenRow, screenColumn).column; + }; + this.screenToDocumentPosition = function(screenRow, screenColumn) { + if (screenRow < 0) + return {row: 0, column: 0}; + + var line; + var docRow = 0; + var docColumn = 0; + var column; + var row = 0; + var rowLength = 0; + + var rowCache = this.$screenRowCache; + var i = this.$getRowCacheIndex(rowCache, screenRow); + var l = rowCache.length; + if (l && i >= 0) { + var row = rowCache[i]; + var docRow = this.$docRowCache[i]; + var doCache = screenRow > rowCache[l - 1]; + } else { + var doCache = !l; + } + + var maxRow = this.getLength() - 1; + var foldLine = this.getNextFoldLine(docRow); + var foldStart = foldLine ? foldLine.start.row : Infinity; + + while (row <= screenRow) { + rowLength = this.getRowLength(docRow); + if (row + rowLength > screenRow || docRow >= maxRow) { + break; + } else { + row += rowLength; + docRow++; + if (docRow > foldStart) { + docRow = foldLine.end.row+1; + foldLine = this.getNextFoldLine(docRow, foldLine); + foldStart = foldLine ? foldLine.start.row : Infinity; + } + } + + if (doCache) { + this.$docRowCache.push(docRow); + this.$screenRowCache.push(row); + } + } + + if (foldLine && foldLine.start.row <= docRow) { + line = this.getFoldDisplayLine(foldLine); + docRow = foldLine.start.row; + } else if (row + rowLength <= screenRow || docRow > maxRow) { + return { + row: maxRow, + column: this.getLine(maxRow).length + }; + } else { + line = this.getLine(docRow); + foldLine = null; + } + + if (this.$useWrapMode) { + var splits = this.$wrapData[docRow]; + if (splits) { + var splitIndex = Math.floor(screenRow - row); + column = splits[splitIndex]; + if(splitIndex > 0 && splits.length) { + docColumn = splits[splitIndex - 1] || splits[splits.length - 1]; + line = line.substring(docColumn); + } + } + } + + docColumn += this.$getStringScreenWidth(line, screenColumn)[1]; + if (this.$useWrapMode && docColumn >= column) + docColumn = column - 1; + + if (foldLine) + return foldLine.idxToPosition(docColumn); + + return {row: docRow, column: docColumn}; + }; + this.documentToScreenPosition = function(docRow, docColumn) { + if (typeof docColumn === "undefined") + var pos = this.$clipPositionToDocument(docRow.row, docRow.column); + else + pos = this.$clipPositionToDocument(docRow, docColumn); + + docRow = pos.row; + docColumn = pos.column; + + var screenRow = 0; + var foldStartRow = null; + var fold = null; + fold = this.getFoldAt(docRow, docColumn, 1); + if (fold) { + docRow = fold.start.row; + docColumn = fold.start.column; + } + + var rowEnd, row = 0; + + + var rowCache = this.$docRowCache; + var i = this.$getRowCacheIndex(rowCache, docRow); + var l = rowCache.length; + if (l && i >= 0) { + var row = rowCache[i]; + var screenRow = this.$screenRowCache[i]; + var doCache = docRow > rowCache[l - 1]; + } else { + var doCache = !l; + } + + var foldLine = this.getNextFoldLine(row); + var foldStart = foldLine ?foldLine.start.row :Infinity; + + while (row < docRow) { + if (row >= foldStart) { + rowEnd = foldLine.end.row + 1; + if (rowEnd > docRow) + break; + foldLine = this.getNextFoldLine(rowEnd, foldLine); + foldStart = foldLine ?foldLine.start.row :Infinity; + } + else { + rowEnd = row + 1; + } + + screenRow += this.getRowLength(row); + row = rowEnd; + + if (doCache) { + this.$docRowCache.push(row); + this.$screenRowCache.push(screenRow); + } + } + var textLine = ""; + if (foldLine && row >= foldStart) { + textLine = this.getFoldDisplayLine(foldLine, docRow, docColumn); + foldStartRow = foldLine.start.row; + } else { + textLine = this.getLine(docRow).substring(0, docColumn); + foldStartRow = docRow; + } + if (this.$useWrapMode) { + var wrapRow = this.$wrapData[foldStartRow]; + if (wrapRow) { + var screenRowOffset = 0; + while (textLine.length >= wrapRow[screenRowOffset]) { + screenRow ++; + screenRowOffset++; + } + textLine = textLine.substring( + wrapRow[screenRowOffset - 1] || 0, textLine.length + ); + } + } + + return { + row: screenRow, + column: this.$getStringScreenWidth(textLine)[0] + }; + }; + this.documentToScreenColumn = function(row, docColumn) { + return this.documentToScreenPosition(row, docColumn).column; + }; + this.documentToScreenRow = function(docRow, docColumn) { + return this.documentToScreenPosition(docRow, docColumn).row; + }; + this.getScreenLength = function() { + var screenRows = 0; + var fold = null; + if (!this.$useWrapMode) { + screenRows = this.getLength(); + var foldData = this.$foldData; + for (var i = 0; i < foldData.length; i++) { + fold = foldData[i]; + screenRows -= fold.end.row - fold.start.row; + } + } else { + var lastRow = this.$wrapData.length; + var row = 0, i = 0; + var fold = this.$foldData[i++]; + var foldStart = fold ? fold.start.row :Infinity; + + while (row < lastRow) { + var splits = this.$wrapData[row]; + screenRows += splits ? splits.length + 1 : 1; + row ++; + if (row > foldStart) { + row = fold.end.row+1; + fold = this.$foldData[i++]; + foldStart = fold ?fold.start.row :Infinity; + } + } + } + if (this.lineWidgets) + screenRows += this.$getWidgetScreenLength(); + + return screenRows; + }; + this.$setFontMetrics = function(fm) { + }; + + this.destroy = function() { + if (this.bgTokenizer) { + this.bgTokenizer.setDocument(null); + this.bgTokenizer = null; + } + this.$stopWorker(); + }; + function isFullWidth(c) { + if (c < 0x1100) + return false; + return c >= 0x1100 && c <= 0x115F || + c >= 0x11A3 && c <= 0x11A7 || + c >= 0x11FA && c <= 0x11FF || + c >= 0x2329 && c <= 0x232A || + c >= 0x2E80 && c <= 0x2E99 || + c >= 0x2E9B && c <= 0x2EF3 || + c >= 0x2F00 && c <= 0x2FD5 || + c >= 0x2FF0 && c <= 0x2FFB || + c >= 0x3000 && c <= 0x303E || + c >= 0x3041 && c <= 0x3096 || + c >= 0x3099 && c <= 0x30FF || + c >= 0x3105 && c <= 0x312D || + c >= 0x3131 && c <= 0x318E || + c >= 0x3190 && c <= 0x31BA || + c >= 0x31C0 && c <= 0x31E3 || + c >= 0x31F0 && c <= 0x321E || + c >= 0x3220 && c <= 0x3247 || + c >= 0x3250 && c <= 0x32FE || + c >= 0x3300 && c <= 0x4DBF || + c >= 0x4E00 && c <= 0xA48C || + c >= 0xA490 && c <= 0xA4C6 || + c >= 0xA960 && c <= 0xA97C || + c >= 0xAC00 && c <= 0xD7A3 || + c >= 0xD7B0 && c <= 0xD7C6 || + c >= 0xD7CB && c <= 0xD7FB || + c >= 0xF900 && c <= 0xFAFF || + c >= 0xFE10 && c <= 0xFE19 || + c >= 0xFE30 && c <= 0xFE52 || + c >= 0xFE54 && c <= 0xFE66 || + c >= 0xFE68 && c <= 0xFE6B || + c >= 0xFF01 && c <= 0xFF60 || + c >= 0xFFE0 && c <= 0xFFE6; + }; + +}).call(EditSession.prototype); + +require("./edit_session/folding").Folding.call(EditSession.prototype); +require("./edit_session/bracket_match").BracketMatch.call(EditSession.prototype); + + +config.defineOptions(EditSession.prototype, "session", { + wrap: { + set: function(value) { + if (!value || value == "off") + value = false; + else if (value == "free") + value = true; + else if (value == "printMargin") + value = -1; + else if (typeof value == "string") + value = parseInt(value, 10) || false; + + if (this.$wrap == value) + return; + if (!value) { + this.setUseWrapMode(false); + } else { + var col = typeof value == "number" ? value : null; + this.setWrapLimitRange(col, col); + this.setUseWrapMode(true); + } + this.$wrap = value; + }, + get: function() { + if (this.getUseWrapMode()) { + if (this.$wrap == -1) + return "printMargin"; + if (!this.getWrapLimitRange().min) + return "free"; + return this.$wrap; + } + return "off"; + }, + handlesSet: true + }, + wrapMethod: { + set: function(val) { + val = val == "auto" + ? this.$mode.type != "text" + : val != "text"; + if (val != this.$wrapAsCode) { + this.$wrapAsCode = val; + if (this.$useWrapMode) { + this.$modified = true; + this.$resetRowCache(0); + this.$updateWrapData(0, this.getLength() - 1); + } + } + }, + initialValue: "auto" + }, + firstLineNumber: { + set: function() {this._signal("changeBreakpoint");}, + initialValue: 1 + }, + useWorker: { + set: function(useWorker) { + this.$useWorker = useWorker; + + this.$stopWorker(); + if (useWorker) + this.$startWorker(); + }, + initialValue: true + }, + useSoftTabs: {initialValue: true}, + tabSize: { + set: function(tabSize) { + if (isNaN(tabSize) || this.$tabSize === tabSize) return; + + this.$modified = true; + this.$rowLengthCache = []; + this.$tabSize = tabSize; + this._signal("changeTabSize"); + }, + initialValue: 4, + handlesSet: true + }, + overwrite: { + set: function(val) {this._signal("changeOverwrite");}, + initialValue: false + }, + newLineMode: { + set: function(val) {this.doc.setNewLineMode(val)}, + get: function() {return this.doc.getNewLineMode()}, + handlesSet: true + }, + mode: { + set: function(val) { this.setMode(val) }, + get: function() { return this.$modeId } + } +}); + +exports.EditSession = EditSession; +}); + +define("ace/search",["require","exports","module","ace/lib/lang","ace/lib/oop","ace/range"], function(require, exports, module) { +"use strict"; + +var lang = require("./lib/lang"); +var oop = require("./lib/oop"); +var Range = require("./range").Range; + +var Search = function() { + this.$options = {}; +}; + +(function() { + this.set = function(options) { + oop.mixin(this.$options, options); + return this; + }; + this.getOptions = function() { + return lang.copyObject(this.$options); + }; + this.setOptions = function(options) { + this.$options = options; + }; + this.find = function(session) { + var options = this.$options; + var iterator = this.$matchIterator(session, options); + if (!iterator) + return false; + + var firstRange = null; + iterator.forEach(function(range, row, offset) { + if (!range.start) { + var column = range.offset + (offset || 0); + firstRange = new Range(row, column, row, column + range.length); + if (!range.length && options.start && options.start.start + && options.skipCurrent != false && firstRange.isEqual(options.start) + ) { + firstRange = null; + return false; + } + } else + firstRange = range; + return true; + }); + + return firstRange; + }; + this.findAll = function(session) { + var options = this.$options; + if (!options.needle) + return []; + this.$assembleRegExp(options); + + var range = options.range; + var lines = range + ? session.getLines(range.start.row, range.end.row) + : session.doc.getAllLines(); + + var ranges = []; + var re = options.re; + if (options.$isMultiLine) { + var len = re.length; + var maxRow = lines.length - len; + var prevRange; + outer: for (var row = re.offset || 0; row <= maxRow; row++) { + for (var j = 0; j < len; j++) + if (lines[row + j].search(re[j]) == -1) + continue outer; + + var startLine = lines[row]; + var line = lines[row + len - 1]; + var startIndex = startLine.length - startLine.match(re[0])[0].length; + var endIndex = line.match(re[len - 1])[0].length; + + if (prevRange && prevRange.end.row === row && + prevRange.end.column > startIndex + ) { + continue; + } + ranges.push(prevRange = new Range( + row, startIndex, row + len - 1, endIndex + )); + if (len > 2) + row = row + len - 2; + } + } else { + for (var i = 0; i < lines.length; i++) { + var matches = lang.getMatchOffsets(lines[i], re); + for (var j = 0; j < matches.length; j++) { + var match = matches[j]; + ranges.push(new Range(i, match.offset, i, match.offset + match.length)); + } + } + } + + if (range) { + var startColumn = range.start.column; + var endColumn = range.start.column; + var i = 0, j = ranges.length - 1; + while (i < j && ranges[i].start.column < startColumn && ranges[i].start.row == range.start.row) + i++; + + while (i < j && ranges[j].end.column > endColumn && ranges[j].end.row == range.end.row) + j--; + + ranges = ranges.slice(i, j + 1); + for (i = 0, j = ranges.length; i < j; i++) { + ranges[i].start.row += range.start.row; + ranges[i].end.row += range.start.row; + } + } + + return ranges; + }; + this.replace = function(input, replacement) { + var options = this.$options; + + var re = this.$assembleRegExp(options); + if (options.$isMultiLine) + return replacement; + + if (!re) + return; + + var match = re.exec(input); + if (!match || match[0].length != input.length) + return null; + + replacement = input.replace(re, replacement); + if (options.preserveCase) { + replacement = replacement.split(""); + for (var i = Math.min(input.length, input.length); i--; ) { + var ch = input[i]; + if (ch && ch.toLowerCase() != ch) + replacement[i] = replacement[i].toUpperCase(); + else + replacement[i] = replacement[i].toLowerCase(); + } + replacement = replacement.join(""); + } + + return replacement; + }; + + this.$matchIterator = function(session, options) { + var re = this.$assembleRegExp(options); + if (!re) + return false; + + var callback; + if (options.$isMultiLine) { + var len = re.length; + var matchIterator = function(line, row, offset) { + var startIndex = line.search(re[0]); + if (startIndex == -1) + return; + for (var i = 1; i < len; i++) { + line = session.getLine(row + i); + if (line.search(re[i]) == -1) + return; + } + + var endIndex = line.match(re[len - 1])[0].length; + + var range = new Range(row, startIndex, row + len - 1, endIndex); + if (re.offset == 1) { + range.start.row--; + range.start.column = Number.MAX_VALUE; + } else if (offset) + range.start.column += offset; + + if (callback(range)) + return true; + }; + } else if (options.backwards) { + var matchIterator = function(line, row, startIndex) { + var matches = lang.getMatchOffsets(line, re); + for (var i = matches.length-1; i >= 0; i--) + if (callback(matches[i], row, startIndex)) + return true; + }; + } else { + var matchIterator = function(line, row, startIndex) { + var matches = lang.getMatchOffsets(line, re); + for (var i = 0; i < matches.length; i++) + if (callback(matches[i], row, startIndex)) + return true; + }; + } + + var lineIterator = this.$lineIterator(session, options); + + return { + forEach: function(_callback) { + callback = _callback; + lineIterator.forEach(matchIterator); + } + }; + }; + + this.$assembleRegExp = function(options, $disableFakeMultiline) { + if (options.needle instanceof RegExp) + return options.re = options.needle; + + var needle = options.needle; + + if (!options.needle) + return options.re = false; + + if (!options.regExp) + needle = lang.escapeRegExp(needle); + + if (options.wholeWord) + needle = "\\b" + needle + "\\b"; + + var modifier = options.caseSensitive ? "gm" : "gmi"; + + options.$isMultiLine = !$disableFakeMultiline && /[\n\r]/.test(needle); + if (options.$isMultiLine) + return options.re = this.$assembleMultilineRegExp(needle, modifier); + + try { + var re = new RegExp(needle, modifier); + } catch(e) { + re = false; + } + return options.re = re; + }; + + this.$assembleMultilineRegExp = function(needle, modifier) { + var parts = needle.replace(/\r\n|\r|\n/g, "$\n^").split("\n"); + var re = []; + for (var i = 0; i < parts.length; i++) try { + re.push(new RegExp(parts[i], modifier)); + } catch(e) { + return false; + } + if (parts[0] == "") { + re.shift(); + re.offset = 1; + } else { + re.offset = 0; + } + return re; + }; + + this.$lineIterator = function(session, options) { + var backwards = options.backwards == true; + var skipCurrent = options.skipCurrent != false; + + var range = options.range; + var start = options.start; + if (!start) + start = range ? range[backwards ? "end" : "start"] : session.selection.getRange(); + + if (start.start) + start = start[skipCurrent != backwards ? "end" : "start"]; + + var firstRow = range ? range.start.row : 0; + var lastRow = range ? range.end.row : session.getLength() - 1; + + var forEach = backwards ? function(callback) { + var row = start.row; + + var line = session.getLine(row).substring(0, start.column); + if (callback(line, row)) + return; + + for (row--; row >= firstRow; row--) + if (callback(session.getLine(row), row)) + return; + + if (options.wrap == false) + return; + + for (row = lastRow, firstRow = start.row; row >= firstRow; row--) + if (callback(session.getLine(row), row)) + return; + } : function(callback) { + var row = start.row; + + var line = session.getLine(row).substr(start.column); + if (callback(line, row, start.column)) + return; + + for (row = row+1; row <= lastRow; row++) + if (callback(session.getLine(row), row)) + return; + + if (options.wrap == false) + return; + + for (row = firstRow, lastRow = start.row; row <= lastRow; row++) + if (callback(session.getLine(row), row)) + return; + }; + + return {forEach: forEach}; + }; + +}).call(Search.prototype); + +exports.Search = Search; +}); + +define("ace/keyboard/hash_handler",["require","exports","module","ace/lib/keys","ace/lib/useragent"], function(require, exports, module) { +"use strict"; + +var keyUtil = require("../lib/keys"); +var useragent = require("../lib/useragent"); +var KEY_MODS = keyUtil.KEY_MODS; + +function HashHandler(config, platform) { + this.platform = platform || (useragent.isMac ? "mac" : "win"); + this.commands = {}; + this.commandKeyBinding = {}; + this.addCommands(config); + this.$singleCommand = true; +} + +function MultiHashHandler(config, platform) { + HashHandler.call(this, config, platform); + this.$singleCommand = false; +} + +MultiHashHandler.prototype = HashHandler.prototype; + +(function() { + + + this.addCommand = function(command) { + if (this.commands[command.name]) + this.removeCommand(command); + + this.commands[command.name] = command; + + if (command.bindKey) + this._buildKeyHash(command); + }; + + this.removeCommand = function(command, keepCommand) { + var name = command && (typeof command === 'string' ? command : command.name); + command = this.commands[name]; + if (!keepCommand) + delete this.commands[name]; + var ckb = this.commandKeyBinding; + for (var keyId in ckb) { + var cmdGroup = ckb[keyId]; + if (cmdGroup == command) { + delete ckb[keyId]; + } else if (Array.isArray(cmdGroup)) { + var i = cmdGroup.indexOf(command); + if (i != -1) { + cmdGroup.splice(i, 1); + if (cmdGroup.length == 1) + ckb[keyId] = cmdGroup[0]; + } + } + } + }; + + this.bindKey = function(key, command, asDefault) { + if (typeof key == "object") + key = key[this.platform]; + if (!key) + return; + if (typeof command == "function") + return this.addCommand({exec: command, bindKey: key, name: command.name || key}); + + key.split("|").forEach(function(keyPart) { + var chain = ""; + if (keyPart.indexOf(" ") != -1) { + var parts = keyPart.split(/\s+/); + keyPart = parts.pop(); + parts.forEach(function(keyPart) { + var binding = this.parseKeys(keyPart); + var id = KEY_MODS[binding.hashId] + binding.key; + chain += (chain ? " " : "") + id; + this._addCommandToBinding(chain, "chainKeys"); + }, this); + chain += " "; + } + var binding = this.parseKeys(keyPart); + var id = KEY_MODS[binding.hashId] + binding.key; + this._addCommandToBinding(chain + id, command, asDefault); + }, this); + }; + + this._addCommandToBinding = function(keyId, command, asDefault) { + var ckb = this.commandKeyBinding, i; + if (!command) { + delete ckb[keyId]; + } else if (!ckb[keyId] || this.$singleCommand) { + ckb[keyId] = command; + } else { + if (!Array.isArray(ckb[keyId])) { + ckb[keyId] = [ckb[keyId]]; + } else if ((i = ckb[keyId].indexOf(command)) != -1) { + ckb[keyId].splice(i, 1); + } + + if (asDefault || command.isDefault) + ckb[keyId].unshift(command); + else + ckb[keyId].push(command); + } + }; + + this.addCommands = function(commands) { + commands && Object.keys(commands).forEach(function(name) { + var command = commands[name]; + if (!command) + return; + + if (typeof command === "string") + return this.bindKey(command, name); + + if (typeof command === "function") + command = { exec: command }; + + if (typeof command !== "object") + return; + + if (!command.name) + command.name = name; + + this.addCommand(command); + }, this); + }; + + this.removeCommands = function(commands) { + Object.keys(commands).forEach(function(name) { + this.removeCommand(commands[name]); + }, this); + }; + + this.bindKeys = function(keyList) { + Object.keys(keyList).forEach(function(key) { + this.bindKey(key, keyList[key]); + }, this); + }; + + this._buildKeyHash = function(command) { + this.bindKey(command.bindKey, command); + }; + this.parseKeys = function(keys) { + var parts = keys.toLowerCase().split(/[\-\+]([\-\+])?/).filter(function(x){return x}); + var key = parts.pop(); + + var keyCode = keyUtil[key]; + if (keyUtil.FUNCTION_KEYS[keyCode]) + key = keyUtil.FUNCTION_KEYS[keyCode].toLowerCase(); + else if (!parts.length) + return {key: key, hashId: -1}; + else if (parts.length == 1 && parts[0] == "shift") + return {key: key.toUpperCase(), hashId: -1}; + + var hashId = 0; + for (var i = parts.length; i--;) { + var modifier = keyUtil.KEY_MODS[parts[i]]; + if (modifier == null) { + if (typeof console != "undefined") + console.error("invalid modifier " + parts[i] + " in " + keys); + return false; + } + hashId |= modifier; + } + return {key: key, hashId: hashId}; + }; + + this.findKeyCommand = function findKeyCommand(hashId, keyString) { + var key = KEY_MODS[hashId] + keyString; + return this.commandKeyBinding[key]; + }; + + this.handleKeyboard = function(data, hashId, keyString, keyCode) { + var key = KEY_MODS[hashId] + keyString; + var command = this.commandKeyBinding[key]; + if (data.$keyChain) { + data.$keyChain += " " + key; + command = this.commandKeyBinding[data.$keyChain] || command; + } + + if (command) { + if (command == "chainKeys" || command[command.length - 1] == "chainKeys") { + data.$keyChain = data.$keyChain || key; + return {command: "null"}; + } + } + + if (data.$keyChain && keyCode > 0) + data.$keyChain = ""; + return {command: command}; + }; + +}).call(HashHandler.prototype); + +exports.HashHandler = HashHandler; +exports.MultiHashHandler = MultiHashHandler; +}); + +define("ace/commands/command_manager",["require","exports","module","ace/lib/oop","ace/keyboard/hash_handler","ace/lib/event_emitter"], function(require, exports, module) { +"use strict"; + +var oop = require("../lib/oop"); +var MultiHashHandler = require("../keyboard/hash_handler").MultiHashHandler; +var EventEmitter = require("../lib/event_emitter").EventEmitter; + +var CommandManager = function(platform, commands) { + MultiHashHandler.call(this, commands, platform); + this.byName = this.commands; + this.setDefaultHandler("exec", function(e) { + return e.command.exec(e.editor, e.args || {}); + }); +}; + +oop.inherits(CommandManager, MultiHashHandler); + +(function() { + + oop.implement(this, EventEmitter); + + this.exec = function(command, editor, args) { + if (Array.isArray(command)) { + for (var i = command.length; i--; ) { + if (this.exec(command[i], editor, args)) return true; + } + return false; + } + + if (typeof command === "string") + command = this.commands[command]; + + if (!command) + return false; + + if (editor && editor.$readOnly && !command.readOnly) + return false; + + var e = {editor: editor, command: command, args: args}; + e.returnValue = this._emit("exec", e); + this._signal("afterExec", e); + + return e.returnValue === false ? false : true; + }; + + this.toggleRecording = function(editor) { + if (this.$inReplay) + return; + + editor && editor._emit("changeStatus"); + if (this.recording) { + this.macro.pop(); + this.removeEventListener("exec", this.$addCommandToMacro); + + if (!this.macro.length) + this.macro = this.oldMacro; + + return this.recording = false; + } + if (!this.$addCommandToMacro) { + this.$addCommandToMacro = function(e) { + this.macro.push([e.command, e.args]); + }.bind(this); + } + + this.oldMacro = this.macro; + this.macro = []; + this.on("exec", this.$addCommandToMacro); + return this.recording = true; + }; + + this.replay = function(editor) { + if (this.$inReplay || !this.macro) + return; + + if (this.recording) + return this.toggleRecording(editor); + + try { + this.$inReplay = true; + this.macro.forEach(function(x) { + if (typeof x == "string") + this.exec(x, editor); + else + this.exec(x[0], editor, x[1]); + }, this); + } finally { + this.$inReplay = false; + } + }; + + this.trimMacro = function(m) { + return m.map(function(x){ + if (typeof x[0] != "string") + x[0] = x[0].name; + if (!x[1]) + x = x[0]; + return x; + }); + }; + +}).call(CommandManager.prototype); + +exports.CommandManager = CommandManager; + +}); + +define("ace/commands/default_commands",["require","exports","module","ace/lib/lang","ace/config","ace/range"], function(require, exports, module) { +"use strict"; + +var lang = require("../lib/lang"); +var config = require("../config"); +var Range = require("../range").Range; + +function bindKey(win, mac) { + return {win: win, mac: mac}; +} +exports.commands = [{ + name: "showSettingsMenu", + bindKey: bindKey("Ctrl-,", "Command-,"), + exec: function(editor) { + config.loadModule("ace/ext/settings_menu", function(module) { + module.init(editor); + editor.showSettingsMenu(); + }); + }, + readOnly: true +}, { + name: "goToNextError", + bindKey: bindKey("Alt-E", "Ctrl-E"), + exec: function(editor) { + config.loadModule("ace/ext/error_marker", function(module) { + module.showErrorMarker(editor, 1); + }); + }, + scrollIntoView: "animate", + readOnly: true +}, { + name: "goToPreviousError", + bindKey: bindKey("Alt-Shift-E", "Ctrl-Shift-E"), + exec: function(editor) { + config.loadModule("ace/ext/error_marker", function(module) { + module.showErrorMarker(editor, -1); + }); + }, + scrollIntoView: "animate", + readOnly: true +}, { + name: "selectall", + bindKey: bindKey("Ctrl-A", "Command-A"), + exec: function(editor) { editor.selectAll(); }, + readOnly: true +}, { + name: "centerselection", + bindKey: bindKey(null, "Ctrl-L"), + exec: function(editor) { editor.centerSelection(); }, + readOnly: true +}, { + name: "gotoline", + bindKey: bindKey("Ctrl-L", "Command-L"), + exec: function(editor) { + var line = parseInt(prompt("Enter line number:"), 10); + if (!isNaN(line)) { + editor.gotoLine(line); + } + }, + readOnly: true +}, { + name: "fold", + bindKey: bindKey("Alt-L|Ctrl-F1", "Command-Alt-L|Command-F1"), + exec: function(editor) { editor.session.toggleFold(false); }, + scrollIntoView: "center", + readOnly: true +}, { + name: "unfold", + bindKey: bindKey("Alt-Shift-L|Ctrl-Shift-F1", "Command-Alt-Shift-L|Command-Shift-F1"), + exec: function(editor) { editor.session.toggleFold(true); }, + scrollIntoView: "center", + readOnly: true +}, { + name: "toggleFoldWidget", + bindKey: bindKey("F2", "F2"), + exec: function(editor) { editor.session.toggleFoldWidget(); }, + scrollIntoView: "center", + readOnly: true +}, { + name: "toggleParentFoldWidget", + bindKey: bindKey("Alt-F2", "Alt-F2"), + exec: function(editor) { editor.session.toggleFoldWidget(true); }, + scrollIntoView: "center", + readOnly: true +}, { + name: "foldall", + bindKey: bindKey("Ctrl-Alt-0", "Ctrl-Command-Option-0"), + exec: function(editor) { editor.session.foldAll(); }, + scrollIntoView: "center", + readOnly: true +}, { + name: "foldOther", + bindKey: bindKey("Alt-0", "Command-Option-0"), + exec: function(editor) { + editor.session.foldAll(); + editor.session.unfold(editor.selection.getAllRanges()); + }, + scrollIntoView: "center", + readOnly: true +}, { + name: "unfoldall", + bindKey: bindKey("Alt-Shift-0", "Command-Option-Shift-0"), + exec: function(editor) { editor.session.unfold(); }, + scrollIntoView: "center", + readOnly: true +}, { + name: "findnext", + bindKey: bindKey("Ctrl-K", "Command-G"), + exec: function(editor) { editor.findNext(); }, + multiSelectAction: "forEach", + scrollIntoView: "center", + readOnly: true +}, { + name: "findprevious", + bindKey: bindKey("Ctrl-Shift-K", "Command-Shift-G"), + exec: function(editor) { editor.findPrevious(); }, + multiSelectAction: "forEach", + scrollIntoView: "center", + readOnly: true +}, { + name: "selectOrFindNext", + bindKey: bindKey("Alt-K", "Ctrl-G"), + exec: function(editor) { + if (editor.selection.isEmpty()) + editor.selection.selectWord(); + else + editor.findNext(); + }, + readOnly: true +}, { + name: "selectOrFindPrevious", + bindKey: bindKey("Alt-Shift-K", "Ctrl-Shift-G"), + exec: function(editor) { + if (editor.selection.isEmpty()) + editor.selection.selectWord(); + else + editor.findPrevious(); + }, + readOnly: true +}, { + name: "find", + bindKey: bindKey("Ctrl-F", "Command-F"), + exec: function(editor) { + config.loadModule("ace/ext/searchbox", function(e) {e.Search(editor)}); + }, + readOnly: true +}, { + name: "overwrite", + bindKey: "Insert", + exec: function(editor) { editor.toggleOverwrite(); }, + readOnly: true +}, { + name: "selecttostart", + bindKey: bindKey("Ctrl-Shift-Home", "Command-Shift-Up"), + exec: function(editor) { editor.getSelection().selectFileStart(); }, + multiSelectAction: "forEach", + readOnly: true, + scrollIntoView: "animate", + aceCommandGroup: "fileJump" +}, { + name: "gotostart", + bindKey: bindKey("Ctrl-Home", "Command-Home|Command-Up"), + exec: function(editor) { editor.navigateFileStart(); }, + multiSelectAction: "forEach", + readOnly: true, + scrollIntoView: "animate", + aceCommandGroup: "fileJump" +}, { + name: "selectup", + bindKey: bindKey("Shift-Up", "Shift-Up"), + exec: function(editor) { editor.getSelection().selectUp(); }, + multiSelectAction: "forEach", + scrollIntoView: "cursor", + readOnly: true +}, { + name: "golineup", + bindKey: bindKey("Up", "Up|Ctrl-P"), + exec: function(editor, args) { editor.navigateUp(args.times); }, + multiSelectAction: "forEach", + scrollIntoView: "cursor", + readOnly: true +}, { + name: "selecttoend", + bindKey: bindKey("Ctrl-Shift-End", "Command-Shift-Down"), + exec: function(editor) { editor.getSelection().selectFileEnd(); }, + multiSelectAction: "forEach", + readOnly: true, + scrollIntoView: "animate", + aceCommandGroup: "fileJump" +}, { + name: "gotoend", + bindKey: bindKey("Ctrl-End", "Command-End|Command-Down"), + exec: function(editor) { editor.navigateFileEnd(); }, + multiSelectAction: "forEach", + readOnly: true, + scrollIntoView: "animate", + aceCommandGroup: "fileJump" +}, { + name: "selectdown", + bindKey: bindKey("Shift-Down", "Shift-Down"), + exec: function(editor) { editor.getSelection().selectDown(); }, + multiSelectAction: "forEach", + scrollIntoView: "cursor", + readOnly: true +}, { + name: "golinedown", + bindKey: bindKey("Down", "Down|Ctrl-N"), + exec: function(editor, args) { editor.navigateDown(args.times); }, + multiSelectAction: "forEach", + scrollIntoView: "cursor", + readOnly: true +}, { + name: "selectwordleft", + bindKey: bindKey("Ctrl-Shift-Left", "Option-Shift-Left"), + exec: function(editor) { editor.getSelection().selectWordLeft(); }, + multiSelectAction: "forEach", + scrollIntoView: "cursor", + readOnly: true +}, { + name: "gotowordleft", + bindKey: bindKey("Ctrl-Left", "Option-Left"), + exec: function(editor) { editor.navigateWordLeft(); }, + multiSelectAction: "forEach", + scrollIntoView: "cursor", + readOnly: true +}, { + name: "selecttolinestart", + bindKey: bindKey("Alt-Shift-Left", "Command-Shift-Left"), + exec: function(editor) { editor.getSelection().selectLineStart(); }, + multiSelectAction: "forEach", + scrollIntoView: "cursor", + readOnly: true +}, { + name: "gotolinestart", + bindKey: bindKey("Alt-Left|Home", "Command-Left|Home|Ctrl-A"), + exec: function(editor) { editor.navigateLineStart(); }, + multiSelectAction: "forEach", + scrollIntoView: "cursor", + readOnly: true +}, { + name: "selectleft", + bindKey: bindKey("Shift-Left", "Shift-Left"), + exec: function(editor) { editor.getSelection().selectLeft(); }, + multiSelectAction: "forEach", + scrollIntoView: "cursor", + readOnly: true +}, { + name: "gotoleft", + bindKey: bindKey("Left", "Left|Ctrl-B"), + exec: function(editor, args) { editor.navigateLeft(args.times); }, + multiSelectAction: "forEach", + scrollIntoView: "cursor", + readOnly: true +}, { + name: "selectwordright", + bindKey: bindKey("Ctrl-Shift-Right", "Option-Shift-Right"), + exec: function(editor) { editor.getSelection().selectWordRight(); }, + multiSelectAction: "forEach", + scrollIntoView: "cursor", + readOnly: true +}, { + name: "gotowordright", + bindKey: bindKey("Ctrl-Right", "Option-Right"), + exec: function(editor) { editor.navigateWordRight(); }, + multiSelectAction: "forEach", + scrollIntoView: "cursor", + readOnly: true +}, { + name: "selecttolineend", + bindKey: bindKey("Alt-Shift-Right", "Command-Shift-Right"), + exec: function(editor) { editor.getSelection().selectLineEnd(); }, + multiSelectAction: "forEach", + scrollIntoView: "cursor", + readOnly: true +}, { + name: "gotolineend", + bindKey: bindKey("Alt-Right|End", "Command-Right|End|Ctrl-E"), + exec: function(editor) { editor.navigateLineEnd(); }, + multiSelectAction: "forEach", + scrollIntoView: "cursor", + readOnly: true +}, { + name: "selectright", + bindKey: bindKey("Shift-Right", "Shift-Right"), + exec: function(editor) { editor.getSelection().selectRight(); }, + multiSelectAction: "forEach", + scrollIntoView: "cursor", + readOnly: true +}, { + name: "gotoright", + bindKey: bindKey("Right", "Right|Ctrl-F"), + exec: function(editor, args) { editor.navigateRight(args.times); }, + multiSelectAction: "forEach", + scrollIntoView: "cursor", + readOnly: true +}, { + name: "selectpagedown", + bindKey: "Shift-PageDown", + exec: function(editor) { editor.selectPageDown(); }, + readOnly: true +}, { + name: "pagedown", + bindKey: bindKey(null, "Option-PageDown"), + exec: function(editor) { editor.scrollPageDown(); }, + readOnly: true +}, { + name: "gotopagedown", + bindKey: bindKey("PageDown", "PageDown|Ctrl-V"), + exec: function(editor) { editor.gotoPageDown(); }, + readOnly: true +}, { + name: "selectpageup", + bindKey: "Shift-PageUp", + exec: function(editor) { editor.selectPageUp(); }, + readOnly: true +}, { + name: "pageup", + bindKey: bindKey(null, "Option-PageUp"), + exec: function(editor) { editor.scrollPageUp(); }, + readOnly: true +}, { + name: "gotopageup", + bindKey: "PageUp", + exec: function(editor) { editor.gotoPageUp(); }, + readOnly: true +}, { + name: "scrollup", + bindKey: bindKey("Ctrl-Up", null), + exec: function(e) { e.renderer.scrollBy(0, -2 * e.renderer.layerConfig.lineHeight); }, + readOnly: true +}, { + name: "scrolldown", + bindKey: bindKey("Ctrl-Down", null), + exec: function(e) { e.renderer.scrollBy(0, 2 * e.renderer.layerConfig.lineHeight); }, + readOnly: true +}, { + name: "selectlinestart", + bindKey: "Shift-Home", + exec: function(editor) { editor.getSelection().selectLineStart(); }, + multiSelectAction: "forEach", + scrollIntoView: "cursor", + readOnly: true +}, { + name: "selectlineend", + bindKey: "Shift-End", + exec: function(editor) { editor.getSelection().selectLineEnd(); }, + multiSelectAction: "forEach", + scrollIntoView: "cursor", + readOnly: true +}, { + name: "togglerecording", + bindKey: bindKey("Ctrl-Alt-E", "Command-Option-E"), + exec: function(editor) { editor.commands.toggleRecording(editor); }, + readOnly: true +}, { + name: "replaymacro", + bindKey: bindKey("Ctrl-Shift-E", "Command-Shift-E"), + exec: function(editor) { editor.commands.replay(editor); }, + readOnly: true +}, { + name: "jumptomatching", + bindKey: bindKey("Ctrl-P", "Ctrl-P"), + exec: function(editor) { editor.jumpToMatching(); }, + multiSelectAction: "forEach", + scrollIntoView: "animate", + readOnly: true +}, { + name: "selecttomatching", + bindKey: bindKey("Ctrl-Shift-P", "Ctrl-Shift-P"), + exec: function(editor) { editor.jumpToMatching(true); }, + multiSelectAction: "forEach", + scrollIntoView: "animate", + readOnly: true +}, { + name: "expandToMatching", + bindKey: bindKey("Ctrl-Shift-M", "Ctrl-Shift-M"), + exec: function(editor) { editor.jumpToMatching(true, true); }, + multiSelectAction: "forEach", + scrollIntoView: "animate", + readOnly: true +}, { + name: "passKeysToBrowser", + bindKey: bindKey("null", "null"), + exec: function() {}, + passEvent: true, + readOnly: true +}, +{ + name: "cut", + exec: function(editor) { + var range = editor.getSelectionRange(); + editor._emit("cut", range); + + if (!editor.selection.isEmpty()) { + editor.session.remove(range); + editor.clearSelection(); + } + }, + scrollIntoView: "cursor", + multiSelectAction: "forEach" +}, { + name: "removeline", + bindKey: bindKey("Ctrl-D", "Command-D"), + exec: function(editor) { editor.removeLines(); }, + scrollIntoView: "cursor", + multiSelectAction: "forEachLine" +}, { + name: "duplicateSelection", + bindKey: bindKey("Ctrl-Shift-D", "Command-Shift-D"), + exec: function(editor) { editor.duplicateSelection(); }, + scrollIntoView: "cursor", + multiSelectAction: "forEach" +}, { + name: "sortlines", + bindKey: bindKey("Ctrl-Alt-S", "Command-Alt-S"), + exec: function(editor) { editor.sortLines(); }, + scrollIntoView: "selection", + multiSelectAction: "forEachLine" +}, { + name: "togglecomment", + bindKey: bindKey("Ctrl-/", "Command-/"), + exec: function(editor) { editor.toggleCommentLines(); }, + multiSelectAction: "forEachLine", + scrollIntoView: "selectionPart" +}, { + name: "toggleBlockComment", + bindKey: bindKey("Ctrl-Shift-/", "Command-Shift-/"), + exec: function(editor) { editor.toggleBlockComment(); }, + multiSelectAction: "forEach", + scrollIntoView: "selectionPart" +}, { + name: "modifyNumberUp", + bindKey: bindKey("Ctrl-Shift-Up", "Alt-Shift-Up"), + exec: function(editor) { editor.modifyNumber(1); }, + scrollIntoView: "cursor", + multiSelectAction: "forEach" +}, { + name: "modifyNumberDown", + bindKey: bindKey("Ctrl-Shift-Down", "Alt-Shift-Down"), + exec: function(editor) { editor.modifyNumber(-1); }, + scrollIntoView: "cursor", + multiSelectAction: "forEach" +}, { + name: "replace", + bindKey: bindKey("Ctrl-H", "Command-Option-F"), + exec: function(editor) { + config.loadModule("ace/ext/searchbox", function(e) {e.Search(editor, true)}); + } +}, { + name: "undo", + bindKey: bindKey("Ctrl-Z", "Command-Z"), + exec: function(editor) { editor.undo(); } +}, { + name: "redo", + bindKey: bindKey("Ctrl-Shift-Z|Ctrl-Y", "Command-Shift-Z|Command-Y"), + exec: function(editor) { editor.redo(); } +}, { + name: "copylinesup", + bindKey: bindKey("Alt-Shift-Up", "Command-Option-Up"), + exec: function(editor) { editor.copyLinesUp(); }, + scrollIntoView: "cursor" +}, { + name: "movelinesup", + bindKey: bindKey("Alt-Up", "Option-Up"), + exec: function(editor) { editor.moveLinesUp(); }, + scrollIntoView: "cursor" +}, { + name: "copylinesdown", + bindKey: bindKey("Alt-Shift-Down", "Command-Option-Down"), + exec: function(editor) { editor.copyLinesDown(); }, + scrollIntoView: "cursor" +}, { + name: "movelinesdown", + bindKey: bindKey("Alt-Down", "Option-Down"), + exec: function(editor) { editor.moveLinesDown(); }, + scrollIntoView: "cursor" +}, { + name: "del", + bindKey: bindKey("Delete", "Delete|Ctrl-D|Shift-Delete"), + exec: function(editor) { editor.remove("right"); }, + multiSelectAction: "forEach", + scrollIntoView: "cursor" +}, { + name: "backspace", + bindKey: bindKey( + "Shift-Backspace|Backspace", + "Ctrl-Backspace|Shift-Backspace|Backspace|Ctrl-H" + ), + exec: function(editor) { editor.remove("left"); }, + multiSelectAction: "forEach", + scrollIntoView: "cursor" +}, { + name: "cut_or_delete", + bindKey: bindKey("Shift-Delete", null), + exec: function(editor) { + if (editor.selection.isEmpty()) { + editor.remove("left"); + } else { + return false; + } + }, + multiSelectAction: "forEach", + scrollIntoView: "cursor" +}, { + name: "removetolinestart", + bindKey: bindKey("Alt-Backspace", "Command-Backspace"), + exec: function(editor) { editor.removeToLineStart(); }, + multiSelectAction: "forEach", + scrollIntoView: "cursor" +}, { + name: "removetolineend", + bindKey: bindKey("Alt-Delete", "Ctrl-K"), + exec: function(editor) { editor.removeToLineEnd(); }, + multiSelectAction: "forEach", + scrollIntoView: "cursor" +}, { + name: "removewordleft", + bindKey: bindKey("Ctrl-Backspace", "Alt-Backspace|Ctrl-Alt-Backspace"), + exec: function(editor) { editor.removeWordLeft(); }, + multiSelectAction: "forEach", + scrollIntoView: "cursor" +}, { + name: "removewordright", + bindKey: bindKey("Ctrl-Delete", "Alt-Delete"), + exec: function(editor) { editor.removeWordRight(); }, + multiSelectAction: "forEach", + scrollIntoView: "cursor" +}, { + name: "outdent", + bindKey: bindKey("Shift-Tab", "Shift-Tab"), + exec: function(editor) { editor.blockOutdent(); }, + multiSelectAction: "forEach", + scrollIntoView: "selectionPart" +}, { + name: "indent", + bindKey: bindKey("Tab", "Tab"), + exec: function(editor) { editor.indent(); }, + multiSelectAction: "forEach", + scrollIntoView: "selectionPart" +}, { + name: "blockoutdent", + bindKey: bindKey("Ctrl-[", "Ctrl-["), + exec: function(editor) { editor.blockOutdent(); }, + multiSelectAction: "forEachLine", + scrollIntoView: "selectionPart" +}, { + name: "blockindent", + bindKey: bindKey("Ctrl-]", "Ctrl-]"), + exec: function(editor) { editor.blockIndent(); }, + multiSelectAction: "forEachLine", + scrollIntoView: "selectionPart" +}, { + name: "insertstring", + exec: function(editor, str) { editor.insert(str); }, + multiSelectAction: "forEach", + scrollIntoView: "cursor" +}, { + name: "inserttext", + exec: function(editor, args) { + editor.insert(lang.stringRepeat(args.text || "", args.times || 1)); + }, + multiSelectAction: "forEach", + scrollIntoView: "cursor" +}, { + name: "splitline", + bindKey: bindKey(null, "Ctrl-O"), + exec: function(editor) { editor.splitLine(); }, + multiSelectAction: "forEach", + scrollIntoView: "cursor" +}, { + name: "transposeletters", + bindKey: bindKey("Ctrl-T", "Ctrl-T"), + exec: function(editor) { editor.transposeLetters(); }, + multiSelectAction: function(editor) {editor.transposeSelections(1); }, + scrollIntoView: "cursor" +}, { + name: "touppercase", + bindKey: bindKey("Ctrl-U", "Ctrl-U"), + exec: function(editor) { editor.toUpperCase(); }, + multiSelectAction: "forEach", + scrollIntoView: "cursor" +}, { + name: "tolowercase", + bindKey: bindKey("Ctrl-Shift-U", "Ctrl-Shift-U"), + exec: function(editor) { editor.toLowerCase(); }, + multiSelectAction: "forEach", + scrollIntoView: "cursor" +}, { + name: "expandtoline", + bindKey: bindKey("Ctrl-Shift-L", "Command-Shift-L"), + exec: function(editor) { + var range = editor.selection.getRange(); + + range.start.column = range.end.column = 0; + range.end.row++; + editor.selection.setRange(range, false); + }, + multiSelectAction: "forEach", + scrollIntoView: "cursor", + readOnly: true +}, { + name: "joinlines", + bindKey: bindKey(null, null), + exec: function(editor) { + var isBackwards = editor.selection.isBackwards(); + var selectionStart = isBackwards ? editor.selection.getSelectionLead() : editor.selection.getSelectionAnchor(); + var selectionEnd = isBackwards ? editor.selection.getSelectionAnchor() : editor.selection.getSelectionLead(); + var firstLineEndCol = editor.session.doc.getLine(selectionStart.row).length; + var selectedText = editor.session.doc.getTextRange(editor.selection.getRange()); + var selectedCount = selectedText.replace(/\n\s*/, " ").length; + var insertLine = editor.session.doc.getLine(selectionStart.row); + + for (var i = selectionStart.row + 1; i <= selectionEnd.row + 1; i++) { + var curLine = lang.stringTrimLeft(lang.stringTrimRight(editor.session.doc.getLine(i))); + if (curLine.length !== 0) { + curLine = " " + curLine; + } + insertLine += curLine; + } + + if (selectionEnd.row + 1 < (editor.session.doc.getLength() - 1)) { + insertLine += editor.session.doc.getNewLineCharacter(); + } + + editor.clearSelection(); + editor.session.doc.replace(new Range(selectionStart.row, 0, selectionEnd.row + 2, 0), insertLine); + + if (selectedCount > 0) { + editor.selection.moveCursorTo(selectionStart.row, selectionStart.column); + editor.selection.selectTo(selectionStart.row, selectionStart.column + selectedCount); + } else { + firstLineEndCol = editor.session.doc.getLine(selectionStart.row).length > firstLineEndCol ? (firstLineEndCol + 1) : firstLineEndCol; + editor.selection.moveCursorTo(selectionStart.row, firstLineEndCol); + } + }, + multiSelectAction: "forEach", + readOnly: true +}, { + name: "invertSelection", + bindKey: bindKey(null, null), + exec: function(editor) { + var endRow = editor.session.doc.getLength() - 1; + var endCol = editor.session.doc.getLine(endRow).length; + var ranges = editor.selection.rangeList.ranges; + var newRanges = []; + if (ranges.length < 1) { + ranges = [editor.selection.getRange()]; + } + + for (var i = 0; i < ranges.length; i++) { + if (i == (ranges.length - 1)) { + if (!(ranges[i].end.row === endRow && ranges[i].end.column === endCol)) { + newRanges.push(new Range(ranges[i].end.row, ranges[i].end.column, endRow, endCol)); + } + } + + if (i === 0) { + if (!(ranges[i].start.row === 0 && ranges[i].start.column === 0)) { + newRanges.push(new Range(0, 0, ranges[i].start.row, ranges[i].start.column)); + } + } else { + newRanges.push(new Range(ranges[i-1].end.row, ranges[i-1].end.column, ranges[i].start.row, ranges[i].start.column)); + } + } + + editor.exitMultiSelectMode(); + editor.clearSelection(); + + for(var i = 0; i < newRanges.length; i++) { + editor.selection.addRange(newRanges[i], false); + } + }, + readOnly: true, + scrollIntoView: "none" +}]; + +}); + +define("ace/editor",["require","exports","module","ace/lib/fixoldbrowsers","ace/lib/oop","ace/lib/dom","ace/lib/lang","ace/lib/useragent","ace/keyboard/textinput","ace/mouse/mouse_handler","ace/mouse/fold_handler","ace/keyboard/keybinding","ace/edit_session","ace/search","ace/range","ace/lib/event_emitter","ace/commands/command_manager","ace/commands/default_commands","ace/config","ace/token_iterator"], function(require, exports, module) { +"use strict"; + +require("./lib/fixoldbrowsers"); + +var oop = require("./lib/oop"); +var dom = require("./lib/dom"); +var lang = require("./lib/lang"); +var useragent = require("./lib/useragent"); +var TextInput = require("./keyboard/textinput").TextInput; +var MouseHandler = require("./mouse/mouse_handler").MouseHandler; +var FoldHandler = require("./mouse/fold_handler").FoldHandler; +var KeyBinding = require("./keyboard/keybinding").KeyBinding; +var EditSession = require("./edit_session").EditSession; +var Search = require("./search").Search; +var Range = require("./range").Range; +var EventEmitter = require("./lib/event_emitter").EventEmitter; +var CommandManager = require("./commands/command_manager").CommandManager; +var defaultCommands = require("./commands/default_commands").commands; +var config = require("./config"); +var TokenIterator = require("./token_iterator").TokenIterator; +var Editor = function(renderer, session) { + var container = renderer.getContainerElement(); + this.container = container; + this.renderer = renderer; + + this.commands = new CommandManager(useragent.isMac ? "mac" : "win", defaultCommands); + this.textInput = new TextInput(renderer.getTextAreaContainer(), this); + this.renderer.textarea = this.textInput.getElement(); + this.keyBinding = new KeyBinding(this); + this.$mouseHandler = new MouseHandler(this); + new FoldHandler(this); + + this.$blockScrolling = 0; + this.$search = new Search().set({ + wrap: true + }); + + this.$historyTracker = this.$historyTracker.bind(this); + this.commands.on("exec", this.$historyTracker); + + this.$initOperationListeners(); + + this._$emitInputEvent = lang.delayedCall(function() { + this._signal("input", {}); + if (this.session && this.session.bgTokenizer) + this.session.bgTokenizer.scheduleStart(); + }.bind(this)); + + this.on("change", function(_, _self) { + _self._$emitInputEvent.schedule(31); + }); + + this.setSession(session || new EditSession("")); + config.resetOptions(this); + config._signal("editor", this); +}; + +(function(){ + + oop.implement(this, EventEmitter); + + this.$initOperationListeners = function() { + function last(a) {return a[a.length - 1]} + + this.selections = []; + this.commands.on("exec", this.startOperation.bind(this), true); + this.commands.on("afterExec", this.endOperation.bind(this), true); + + this.$opResetTimer = lang.delayedCall(this.endOperation.bind(this)); + + this.on("change", function() { + this.curOp || this.startOperation(); + this.curOp.docChanged = true; + }.bind(this), true); + + this.on("changeSelection", function() { + this.curOp || this.startOperation(); + this.curOp.selectionChanged = true; + }.bind(this), true); + }; + + this.curOp = null; + this.prevOp = {}; + this.startOperation = function(commadEvent) { + if (this.curOp) { + if (!commadEvent || this.curOp.command) + return; + this.prevOp = this.curOp; + } + if (!commadEvent) { + this.previousCommand = null; + commadEvent = {}; + } + + this.$opResetTimer.schedule(); + this.curOp = { + command: commadEvent.command || {}, + args: commadEvent.args, + scrollTop: this.renderer.scrollTop + }; + if (this.curOp.command.name) + this.$blockScrolling++; + }; + + this.endOperation = function(e) { + if (this.curOp) { + if (e && e.returnValue === false) + return this.curOp = null; + this._signal("beforeEndOperation"); + var command = this.curOp.command; + if (command.name && this.$blockScrolling) + this.$blockScrolling--; + if (command && command.scrollIntoView) { + switch (command.scrollIntoView) { + case "center": + this.renderer.scrollCursorIntoView(null, 0.5); + break; + case "animate": + case "cursor": + this.renderer.scrollCursorIntoView(); + break; + case "selectionPart": + var range = this.selection.getRange(); + var config = this.renderer.layerConfig; + if (range.start.row >= config.lastRow || range.end.row <= config.firstRow) { + this.renderer.scrollSelectionIntoView(this.selection.anchor, this.selection.lead); + } + break; + default: + break; + } + if (command.scrollIntoView == "animate") + this.renderer.animateScrolling(this.curOp.scrollTop); + } + + this.prevOp = this.curOp; + this.curOp = null; + } + }; + this.$mergeableCommands = ["backspace", "del", "insertstring"]; + this.$historyTracker = function(e) { + if (!this.$mergeUndoDeltas) + return; + + var prev = this.prevOp; + var mergeableCommands = this.$mergeableCommands; + var shouldMerge = prev.command && (e.command.name == prev.command.name); + if (e.command.name == "insertstring") { + var text = e.args; + if (this.mergeNextCommand === undefined) + this.mergeNextCommand = true; + + shouldMerge = shouldMerge + && this.mergeNextCommand // previous command allows to coalesce with + && (!/\s/.test(text) || /\s/.test(prev.args)); // previous insertion was of same type + + this.mergeNextCommand = true; + } else { + shouldMerge = shouldMerge + && mergeableCommands.indexOf(e.command.name) !== -1; // the command is mergeable + } + + if ( + this.$mergeUndoDeltas != "always" + && Date.now() - this.sequenceStartTime > 2000 + ) { + shouldMerge = false; // the sequence is too long + } + + if (shouldMerge) + this.session.mergeUndoDeltas = true; + else if (mergeableCommands.indexOf(e.command.name) !== -1) + this.sequenceStartTime = Date.now(); + }; + this.setKeyboardHandler = function(keyboardHandler, cb) { + if (keyboardHandler && typeof keyboardHandler === "string") { + this.$keybindingId = keyboardHandler; + var _self = this; + config.loadModule(["keybinding", keyboardHandler], function(module) { + if (_self.$keybindingId == keyboardHandler) + _self.keyBinding.setKeyboardHandler(module && module.handler); + cb && cb(); + }); + } else { + this.$keybindingId = null; + this.keyBinding.setKeyboardHandler(keyboardHandler); + cb && cb(); + } + }; + this.getKeyboardHandler = function() { + return this.keyBinding.getKeyboardHandler(); + }; + this.setSession = function(session) { + if (this.session == session) + return; + + var oldSession = this.session; + if (oldSession) { + this.session.removeEventListener("change", this.$onDocumentChange); + this.session.removeEventListener("changeMode", this.$onChangeMode); + this.session.removeEventListener("tokenizerUpdate", this.$onTokenizerUpdate); + this.session.removeEventListener("changeTabSize", this.$onChangeTabSize); + this.session.removeEventListener("changeWrapLimit", this.$onChangeWrapLimit); + this.session.removeEventListener("changeWrapMode", this.$onChangeWrapMode); + this.session.removeEventListener("onChangeFold", this.$onChangeFold); + this.session.removeEventListener("changeFrontMarker", this.$onChangeFrontMarker); + this.session.removeEventListener("changeBackMarker", this.$onChangeBackMarker); + this.session.removeEventListener("changeBreakpoint", this.$onChangeBreakpoint); + this.session.removeEventListener("changeAnnotation", this.$onChangeAnnotation); + this.session.removeEventListener("changeOverwrite", this.$onCursorChange); + this.session.removeEventListener("changeScrollTop", this.$onScrollTopChange); + this.session.removeEventListener("changeScrollLeft", this.$onScrollLeftChange); + + var selection = this.session.getSelection(); + selection.removeEventListener("changeCursor", this.$onCursorChange); + selection.removeEventListener("changeSelection", this.$onSelectionChange); + } + + this.session = session; + if (session) { + this.$onDocumentChange = this.onDocumentChange.bind(this); + session.addEventListener("change", this.$onDocumentChange); + this.renderer.setSession(session); + + this.$onChangeMode = this.onChangeMode.bind(this); + session.addEventListener("changeMode", this.$onChangeMode); + + this.$onTokenizerUpdate = this.onTokenizerUpdate.bind(this); + session.addEventListener("tokenizerUpdate", this.$onTokenizerUpdate); + + this.$onChangeTabSize = this.renderer.onChangeTabSize.bind(this.renderer); + session.addEventListener("changeTabSize", this.$onChangeTabSize); + + this.$onChangeWrapLimit = this.onChangeWrapLimit.bind(this); + session.addEventListener("changeWrapLimit", this.$onChangeWrapLimit); + + this.$onChangeWrapMode = this.onChangeWrapMode.bind(this); + session.addEventListener("changeWrapMode", this.$onChangeWrapMode); + + this.$onChangeFold = this.onChangeFold.bind(this); + session.addEventListener("changeFold", this.$onChangeFold); + + this.$onChangeFrontMarker = this.onChangeFrontMarker.bind(this); + this.session.addEventListener("changeFrontMarker", this.$onChangeFrontMarker); + + this.$onChangeBackMarker = this.onChangeBackMarker.bind(this); + this.session.addEventListener("changeBackMarker", this.$onChangeBackMarker); + + this.$onChangeBreakpoint = this.onChangeBreakpoint.bind(this); + this.session.addEventListener("changeBreakpoint", this.$onChangeBreakpoint); + + this.$onChangeAnnotation = this.onChangeAnnotation.bind(this); + this.session.addEventListener("changeAnnotation", this.$onChangeAnnotation); + + this.$onCursorChange = this.onCursorChange.bind(this); + this.session.addEventListener("changeOverwrite", this.$onCursorChange); + + this.$onScrollTopChange = this.onScrollTopChange.bind(this); + this.session.addEventListener("changeScrollTop", this.$onScrollTopChange); + + this.$onScrollLeftChange = this.onScrollLeftChange.bind(this); + this.session.addEventListener("changeScrollLeft", this.$onScrollLeftChange); + + this.selection = session.getSelection(); + this.selection.addEventListener("changeCursor", this.$onCursorChange); + + this.$onSelectionChange = this.onSelectionChange.bind(this); + this.selection.addEventListener("changeSelection", this.$onSelectionChange); + + this.onChangeMode(); + + this.$blockScrolling += 1; + this.onCursorChange(); + this.$blockScrolling -= 1; + + this.onScrollTopChange(); + this.onScrollLeftChange(); + this.onSelectionChange(); + this.onChangeFrontMarker(); + this.onChangeBackMarker(); + this.onChangeBreakpoint(); + this.onChangeAnnotation(); + this.session.getUseWrapMode() && this.renderer.adjustWrapLimit(); + this.renderer.updateFull(); + } else { + this.selection = null; + this.renderer.setSession(session); + } + + this._signal("changeSession", { + session: session, + oldSession: oldSession + }); + + oldSession && oldSession._signal("changeEditor", {oldEditor: this}); + session && session._signal("changeEditor", {editor: this}); + }; + this.getSession = function() { + return this.session; + }; + this.setValue = function(val, cursorPos) { + this.session.doc.setValue(val); + + if (!cursorPos) + this.selectAll(); + else if (cursorPos == 1) + this.navigateFileEnd(); + else if (cursorPos == -1) + this.navigateFileStart(); + + return val; + }; + this.getValue = function() { + return this.session.getValue(); + }; + this.getSelection = function() { + return this.selection; + }; + this.resize = function(force) { + this.renderer.onResize(force); + }; + this.setTheme = function(theme, cb) { + this.renderer.setTheme(theme, cb); + }; + this.getTheme = function() { + return this.renderer.getTheme(); + }; + this.setStyle = function(style) { + this.renderer.setStyle(style); + }; + this.unsetStyle = function(style) { + this.renderer.unsetStyle(style); + }; + this.getFontSize = function () { + return this.getOption("fontSize") || + dom.computedStyle(this.container, "fontSize"); + }; + this.setFontSize = function(size) { + this.setOption("fontSize", size); + }; + + this.$highlightBrackets = function() { + if (this.session.$bracketHighlight) { + this.session.removeMarker(this.session.$bracketHighlight); + this.session.$bracketHighlight = null; + } + + if (this.$highlightPending) { + return; + } + var self = this; + this.$highlightPending = true; + setTimeout(function() { + self.$highlightPending = false; + var session = self.session; + if (!session || !session.bgTokenizer) return; + var pos = session.findMatchingBracket(self.getCursorPosition()); + if (pos) { + var range = new Range(pos.row, pos.column, pos.row, pos.column + 1); + } else if (session.$mode.getMatching) { + var range = session.$mode.getMatching(self.session); + } + if (range) + session.$bracketHighlight = session.addMarker(range, "ace_bracket", "text"); + }, 50); + }; + this.$highlightTags = function() { + if (this.$highlightTagPending) + return; + var self = this; + this.$highlightTagPending = true; + setTimeout(function() { + self.$highlightTagPending = false; + + var session = self.session; + if (!session || !session.bgTokenizer) return; + + var pos = self.getCursorPosition(); + var iterator = new TokenIterator(self.session, pos.row, pos.column); + var token = iterator.getCurrentToken(); + + if (!token || !/\b(?:tag-open|tag-name)/.test(token.type)) { + session.removeMarker(session.$tagHighlight); + session.$tagHighlight = null; + return; + } + + if (token.type.indexOf("tag-open") != -1) + token = iterator.stepForward(); + + var tag = token.value; + var depth = 0; + var prevToken = iterator.stepBackward(); + + if (prevToken.value == '<'){ + do { + prevToken = token; + token = iterator.stepForward(); + + if (token && token.value === tag && token.type.indexOf('tag-name') !== -1) { + if (prevToken.value === '<'){ + depth++; + } else if (prevToken.value === '= 0); + } else { + do { + token = prevToken; + prevToken = iterator.stepBackward(); + + if (token && token.value === tag && token.type.indexOf('tag-name') !== -1) { + if (prevToken.value === '<') { + depth++; + } else if (prevToken.value === ' 1)) + highlight = false; + } + + if (session.$highlightLineMarker && !highlight) { + session.removeMarker(session.$highlightLineMarker.id); + session.$highlightLineMarker = null; + } else if (!session.$highlightLineMarker && highlight) { + var range = new Range(highlight.row, highlight.column, highlight.row, Infinity); + range.id = session.addMarker(range, "ace_active-line", "screenLine"); + session.$highlightLineMarker = range; + } else if (highlight) { + session.$highlightLineMarker.start.row = highlight.row; + session.$highlightLineMarker.end.row = highlight.row; + session.$highlightLineMarker.start.column = highlight.column; + session._signal("changeBackMarker"); + } + }; + + this.onSelectionChange = function(e) { + var session = this.session; + + if (session.$selectionMarker) { + session.removeMarker(session.$selectionMarker); + } + session.$selectionMarker = null; + + if (!this.selection.isEmpty()) { + var range = this.selection.getRange(); + var style = this.getSelectionStyle(); + session.$selectionMarker = session.addMarker(range, "ace_selection", style); + } else { + this.$updateHighlightActiveLine(); + } + + var re = this.$highlightSelectedWord && this.$getSelectionHighLightRegexp(); + this.session.highlight(re); + + this._signal("changeSelection"); + }; + + this.$getSelectionHighLightRegexp = function() { + var session = this.session; + + var selection = this.getSelectionRange(); + if (selection.isEmpty() || selection.isMultiLine()) + return; + + var startOuter = selection.start.column - 1; + var endOuter = selection.end.column + 1; + var line = session.getLine(selection.start.row); + var lineCols = line.length; + var needle = line.substring(Math.max(startOuter, 0), + Math.min(endOuter, lineCols)); + if ((startOuter >= 0 && /^[\w\d]/.test(needle)) || + (endOuter <= lineCols && /[\w\d]$/.test(needle))) + return; + + needle = line.substring(selection.start.column, selection.end.column); + if (!/^[\w\d]+$/.test(needle)) + return; + + var re = this.$search.$assembleRegExp({ + wholeWord: true, + caseSensitive: true, + needle: needle + }); + + return re; + }; + + + this.onChangeFrontMarker = function() { + this.renderer.updateFrontMarkers(); + }; + + this.onChangeBackMarker = function() { + this.renderer.updateBackMarkers(); + }; + + + this.onChangeBreakpoint = function() { + this.renderer.updateBreakpoints(); + }; + + this.onChangeAnnotation = function() { + this.renderer.setAnnotations(this.session.getAnnotations()); + }; + + + this.onChangeMode = function(e) { + this.renderer.updateText(); + this._emit("changeMode", e); + }; + + + this.onChangeWrapLimit = function() { + this.renderer.updateFull(); + }; + + this.onChangeWrapMode = function() { + this.renderer.onResize(true); + }; + + + this.onChangeFold = function() { + this.$updateHighlightActiveLine(); + this.renderer.updateFull(); + }; + this.getSelectedText = function() { + return this.session.getTextRange(this.getSelectionRange()); + }; + this.getCopyText = function() { + var text = this.getSelectedText(); + this._signal("copy", text); + return text; + }; + this.onCopy = function() { + this.commands.exec("copy", this); + }; + this.onCut = function() { + this.commands.exec("cut", this); + }; + this.onPaste = function(text) { + if (this.$readOnly) + return; + + var e = {text: text}; + this._signal("paste", e); + text = e.text; + if (!this.inMultiSelectMode || this.inVirtualSelectionMode) { + this.insert(text); + } else { + var lines = text.split(/\r\n|\r|\n/); + var ranges = this.selection.rangeList.ranges; + + if (lines.length > ranges.length || lines.length < 2 || !lines[1]) + return this.commands.exec("insertstring", this, text); + + for (var i = ranges.length; i--;) { + var range = ranges[i]; + if (!range.isEmpty()) + this.session.remove(range); + + this.session.insert(range.start, lines[i]); + } + } + this.renderer.scrollCursorIntoView(); + }; + + this.execCommand = function(command, args) { + return this.commands.exec(command, this, args); + }; + this.insert = function(text, pasted) { + var session = this.session; + var mode = session.getMode(); + var cursor = this.getCursorPosition(); + + if (this.getBehavioursEnabled() && !pasted) { + var transform = mode.transformAction(session.getState(cursor.row), 'insertion', this, session, text); + if (transform) { + if (text !== transform.text) { + this.session.mergeUndoDeltas = false; + this.$mergeNextCommand = false; + } + text = transform.text; + + } + } + + if (text == "\t") + text = this.session.getTabString(); + if (!this.selection.isEmpty()) { + var range = this.getSelectionRange(); + cursor = this.session.remove(range); + this.clearSelection(); + } + else if (this.session.getOverwrite()) { + var range = new Range.fromPoints(cursor, cursor); + range.end.column += text.length; + this.session.remove(range); + } + + if (text == "\n" || text == "\r\n") { + var line = session.getLine(cursor.row); + if (cursor.column > line.search(/\S|$/)) { + var d = line.substr(cursor.column).search(/\S|$/); + session.doc.removeInLine(cursor.row, cursor.column, cursor.column + d); + } + } + this.clearSelection(); + + var start = cursor.column; + var lineState = session.getState(cursor.row); + var line = session.getLine(cursor.row); + var shouldOutdent = mode.checkOutdent(lineState, line, text); + var end = session.insert(cursor, text); + + if (transform && transform.selection) { + if (transform.selection.length == 2) { // Transform relative to the current column + this.selection.setSelectionRange( + new Range(cursor.row, start + transform.selection[0], + cursor.row, start + transform.selection[1])); + } else { // Transform relative to the current row. + this.selection.setSelectionRange( + new Range(cursor.row + transform.selection[0], + transform.selection[1], + cursor.row + transform.selection[2], + transform.selection[3])); + } + } + + if (session.getDocument().isNewLine(text)) { + var lineIndent = mode.getNextLineIndent(lineState, line.slice(0, cursor.column), session.getTabString()); + + session.insert({row: cursor.row+1, column: 0}, lineIndent); + } + if (shouldOutdent) + mode.autoOutdent(lineState, session, cursor.row); + }; + + this.onTextInput = function(text) { + this.keyBinding.onTextInput(text); + }; + + this.onCommandKey = function(e, hashId, keyCode) { + this.keyBinding.onCommandKey(e, hashId, keyCode); + }; + this.setOverwrite = function(overwrite) { + this.session.setOverwrite(overwrite); + }; + this.getOverwrite = function() { + return this.session.getOverwrite(); + }; + this.toggleOverwrite = function() { + this.session.toggleOverwrite(); + }; + this.setScrollSpeed = function(speed) { + this.setOption("scrollSpeed", speed); + }; + this.getScrollSpeed = function() { + return this.getOption("scrollSpeed"); + }; + this.setDragDelay = function(dragDelay) { + this.setOption("dragDelay", dragDelay); + }; + this.getDragDelay = function() { + return this.getOption("dragDelay"); + }; + this.setSelectionStyle = function(val) { + this.setOption("selectionStyle", val); + }; + this.getSelectionStyle = function() { + return this.getOption("selectionStyle"); + }; + this.setHighlightActiveLine = function(shouldHighlight) { + this.setOption("highlightActiveLine", shouldHighlight); + }; + this.getHighlightActiveLine = function() { + return this.getOption("highlightActiveLine"); + }; + this.setHighlightGutterLine = function(shouldHighlight) { + this.setOption("highlightGutterLine", shouldHighlight); + }; + + this.getHighlightGutterLine = function() { + return this.getOption("highlightGutterLine"); + }; + this.setHighlightSelectedWord = function(shouldHighlight) { + this.setOption("highlightSelectedWord", shouldHighlight); + }; + this.getHighlightSelectedWord = function() { + return this.$highlightSelectedWord; + }; + + this.setAnimatedScroll = function(shouldAnimate){ + this.renderer.setAnimatedScroll(shouldAnimate); + }; + + this.getAnimatedScroll = function(){ + return this.renderer.getAnimatedScroll(); + }; + this.setShowInvisibles = function(showInvisibles) { + this.renderer.setShowInvisibles(showInvisibles); + }; + this.getShowInvisibles = function() { + return this.renderer.getShowInvisibles(); + }; + + this.setDisplayIndentGuides = function(display) { + this.renderer.setDisplayIndentGuides(display); + }; + + this.getDisplayIndentGuides = function() { + return this.renderer.getDisplayIndentGuides(); + }; + this.setShowPrintMargin = function(showPrintMargin) { + this.renderer.setShowPrintMargin(showPrintMargin); + }; + this.getShowPrintMargin = function() { + return this.renderer.getShowPrintMargin(); + }; + this.setPrintMarginColumn = function(showPrintMargin) { + this.renderer.setPrintMarginColumn(showPrintMargin); + }; + this.getPrintMarginColumn = function() { + return this.renderer.getPrintMarginColumn(); + }; + this.setReadOnly = function(readOnly) { + this.setOption("readOnly", readOnly); + }; + this.getReadOnly = function() { + return this.getOption("readOnly"); + }; + this.setBehavioursEnabled = function (enabled) { + this.setOption("behavioursEnabled", enabled); + }; + this.getBehavioursEnabled = function () { + return this.getOption("behavioursEnabled"); + }; + this.setWrapBehavioursEnabled = function (enabled) { + this.setOption("wrapBehavioursEnabled", enabled); + }; + this.getWrapBehavioursEnabled = function () { + return this.getOption("wrapBehavioursEnabled"); + }; + this.setShowFoldWidgets = function(show) { + this.setOption("showFoldWidgets", show); + + }; + this.getShowFoldWidgets = function() { + return this.getOption("showFoldWidgets"); + }; + + this.setFadeFoldWidgets = function(fade) { + this.setOption("fadeFoldWidgets", fade); + }; + + this.getFadeFoldWidgets = function() { + return this.getOption("fadeFoldWidgets"); + }; + this.remove = function(dir) { + if (this.selection.isEmpty()){ + if (dir == "left") + this.selection.selectLeft(); + else + this.selection.selectRight(); + } + + var range = this.getSelectionRange(); + if (this.getBehavioursEnabled()) { + var session = this.session; + var state = session.getState(range.start.row); + var new_range = session.getMode().transformAction(state, 'deletion', this, session, range); + + if (range.end.column === 0) { + var text = session.getTextRange(range); + if (text[text.length - 1] == "\n") { + var line = session.getLine(range.end.row); + if (/^\s+$/.test(line)) { + range.end.column = line.length; + } + } + } + if (new_range) + range = new_range; + } + + this.session.remove(range); + this.clearSelection(); + }; + this.removeWordRight = function() { + if (this.selection.isEmpty()) + this.selection.selectWordRight(); + + this.session.remove(this.getSelectionRange()); + this.clearSelection(); + }; + this.removeWordLeft = function() { + if (this.selection.isEmpty()) + this.selection.selectWordLeft(); + + this.session.remove(this.getSelectionRange()); + this.clearSelection(); + }; + this.removeToLineStart = function() { + if (this.selection.isEmpty()) + this.selection.selectLineStart(); + + this.session.remove(this.getSelectionRange()); + this.clearSelection(); + }; + this.removeToLineEnd = function() { + if (this.selection.isEmpty()) + this.selection.selectLineEnd(); + + var range = this.getSelectionRange(); + if (range.start.column == range.end.column && range.start.row == range.end.row) { + range.end.column = 0; + range.end.row++; + } + + this.session.remove(range); + this.clearSelection(); + }; + this.splitLine = function() { + if (!this.selection.isEmpty()) { + this.session.remove(this.getSelectionRange()); + this.clearSelection(); + } + + var cursor = this.getCursorPosition(); + this.insert("\n"); + this.moveCursorToPosition(cursor); + }; + this.transposeLetters = function() { + if (!this.selection.isEmpty()) { + return; + } + + var cursor = this.getCursorPosition(); + var column = cursor.column; + if (column === 0) + return; + + var line = this.session.getLine(cursor.row); + var swap, range; + if (column < line.length) { + swap = line.charAt(column) + line.charAt(column-1); + range = new Range(cursor.row, column-1, cursor.row, column+1); + } + else { + swap = line.charAt(column-1) + line.charAt(column-2); + range = new Range(cursor.row, column-2, cursor.row, column); + } + this.session.replace(range, swap); + }; + this.toLowerCase = function() { + var originalRange = this.getSelectionRange(); + if (this.selection.isEmpty()) { + this.selection.selectWord(); + } + + var range = this.getSelectionRange(); + var text = this.session.getTextRange(range); + this.session.replace(range, text.toLowerCase()); + this.selection.setSelectionRange(originalRange); + }; + this.toUpperCase = function() { + var originalRange = this.getSelectionRange(); + if (this.selection.isEmpty()) { + this.selection.selectWord(); + } + + var range = this.getSelectionRange(); + var text = this.session.getTextRange(range); + this.session.replace(range, text.toUpperCase()); + this.selection.setSelectionRange(originalRange); + }; + this.indent = function() { + var session = this.session; + var range = this.getSelectionRange(); + + if (range.start.row < range.end.row) { + var rows = this.$getSelectedRows(); + session.indentRows(rows.first, rows.last, "\t"); + return; + } else if (range.start.column < range.end.column) { + var text = session.getTextRange(range); + if (!/^\s+$/.test(text)) { + var rows = this.$getSelectedRows(); + session.indentRows(rows.first, rows.last, "\t"); + return; + } + } + + var line = session.getLine(range.start.row); + var position = range.start; + var size = session.getTabSize(); + var column = session.documentToScreenColumn(position.row, position.column); + + if (this.session.getUseSoftTabs()) { + var count = (size - column % size); + var indentString = lang.stringRepeat(" ", count); + } else { + var count = column % size; + while (line[range.start.column] == " " && count) { + range.start.column--; + count--; + } + this.selection.setSelectionRange(range); + indentString = "\t"; + } + return this.insert(indentString); + }; + this.blockIndent = function() { + var rows = this.$getSelectedRows(); + this.session.indentRows(rows.first, rows.last, "\t"); + }; + this.blockOutdent = function() { + var selection = this.session.getSelection(); + this.session.outdentRows(selection.getRange()); + }; + this.sortLines = function() { + var rows = this.$getSelectedRows(); + var session = this.session; + + var lines = []; + for (i = rows.first; i <= rows.last; i++) + lines.push(session.getLine(i)); + + lines.sort(function(a, b) { + if (a.toLowerCase() < b.toLowerCase()) return -1; + if (a.toLowerCase() > b.toLowerCase()) return 1; + return 0; + }); + + var deleteRange = new Range(0, 0, 0, 0); + for (var i = rows.first; i <= rows.last; i++) { + var line = session.getLine(i); + deleteRange.start.row = i; + deleteRange.end.row = i; + deleteRange.end.column = line.length; + session.replace(deleteRange, lines[i-rows.first]); + } + }; + this.toggleCommentLines = function() { + var state = this.session.getState(this.getCursorPosition().row); + var rows = this.$getSelectedRows(); + this.session.getMode().toggleCommentLines(state, this.session, rows.first, rows.last); + }; + + this.toggleBlockComment = function() { + var cursor = this.getCursorPosition(); + var state = this.session.getState(cursor.row); + var range = this.getSelectionRange(); + this.session.getMode().toggleBlockComment(state, this.session, range, cursor); + }; + this.getNumberAt = function(row, column) { + var _numberRx = /[\-]?[0-9]+(?:\.[0-9]+)?/g; + _numberRx.lastIndex = 0; + + var s = this.session.getLine(row); + while (_numberRx.lastIndex < column) { + var m = _numberRx.exec(s); + if(m.index <= column && m.index+m[0].length >= column){ + var number = { + value: m[0], + start: m.index, + end: m.index+m[0].length + }; + return number; + } + } + return null; + }; + this.modifyNumber = function(amount) { + var row = this.selection.getCursor().row; + var column = this.selection.getCursor().column; + var charRange = new Range(row, column-1, row, column); + + var c = this.session.getTextRange(charRange); + if (!isNaN(parseFloat(c)) && isFinite(c)) { + var nr = this.getNumberAt(row, column); + if (nr) { + var fp = nr.value.indexOf(".") >= 0 ? nr.start + nr.value.indexOf(".") + 1 : nr.end; + var decimals = nr.start + nr.value.length - fp; + + var t = parseFloat(nr.value); + t *= Math.pow(10, decimals); + + + if(fp !== nr.end && column < fp){ + amount *= Math.pow(10, nr.end - column - 1); + } else { + amount *= Math.pow(10, nr.end - column); + } + + t += amount; + t /= Math.pow(10, decimals); + var nnr = t.toFixed(decimals); + var replaceRange = new Range(row, nr.start, row, nr.end); + this.session.replace(replaceRange, nnr); + this.moveCursorTo(row, Math.max(nr.start +1, column + nnr.length - nr.value.length)); + + } + } + }; + this.removeLines = function() { + var rows = this.$getSelectedRows(); + var range; + if (rows.first === 0 || rows.last+1 < this.session.getLength()) + range = new Range(rows.first, 0, rows.last+1, 0); + else + range = new Range( + rows.first-1, this.session.getLine(rows.first-1).length, + rows.last, this.session.getLine(rows.last).length + ); + this.session.remove(range); + this.clearSelection(); + }; + + this.duplicateSelection = function() { + var sel = this.selection; + var doc = this.session; + var range = sel.getRange(); + var reverse = sel.isBackwards(); + if (range.isEmpty()) { + var row = range.start.row; + doc.duplicateLines(row, row); + } else { + var point = reverse ? range.start : range.end; + var endPoint = doc.insert(point, doc.getTextRange(range), false); + range.start = point; + range.end = endPoint; + + sel.setSelectionRange(range, reverse); + } + }; + this.moveLinesDown = function() { + this.$moveLines(1, false); + }; + this.moveLinesUp = function() { + this.$moveLines(-1, false); + }; + this.moveText = function(range, toPosition, copy) { + return this.session.moveText(range, toPosition, copy); + }; + this.copyLinesUp = function() { + this.$moveLines(-1, true); + }; + this.copyLinesDown = function() { + this.$moveLines(1, true); + }; + this.$moveLines = function(dir, copy) { + var rows, moved; + var selection = this.selection; + if (!selection.inMultiSelectMode || this.inVirtualSelectionMode) { + var range = selection.toOrientedRange(); + rows = this.$getSelectedRows(range); + moved = this.session.$moveLines(rows.first, rows.last, copy ? 0 : dir); + if (copy && dir == -1) moved = 0; + range.moveBy(moved, 0); + selection.fromOrientedRange(range); + } else { + var ranges = selection.rangeList.ranges; + selection.rangeList.detach(this.session); + this.inVirtualSelectionMode = true; + + var diff = 0; + var totalDiff = 0; + var l = ranges.length; + for (var i = 0; i < l; i++) { + var rangeIndex = i; + ranges[i].moveBy(diff, 0); + rows = this.$getSelectedRows(ranges[i]); + var first = rows.first; + var last = rows.last; + while (++i < l) { + if (totalDiff) ranges[i].moveBy(totalDiff, 0); + var subRows = this.$getSelectedRows(ranges[i]); + if (copy && subRows.first != last) + break; + else if (!copy && subRows.first > last + 1) + break; + last = subRows.last; + } + i--; + diff = this.session.$moveLines(first, last, copy ? 0 : dir); + if (copy && dir == -1) rangeIndex = i + 1; + while (rangeIndex <= i) { + ranges[rangeIndex].moveBy(diff, 0); + rangeIndex++; + } + if (!copy) diff = 0; + totalDiff += diff; + } + + selection.fromOrientedRange(selection.ranges[0]); + selection.rangeList.attach(this.session); + this.inVirtualSelectionMode = false; + } + }; + this.$getSelectedRows = function(range) { + range = (range || this.getSelectionRange()).collapseRows(); + + return { + first: this.session.getRowFoldStart(range.start.row), + last: this.session.getRowFoldEnd(range.end.row) + }; + }; + + this.onCompositionStart = function(text) { + this.renderer.showComposition(this.getCursorPosition()); + }; + + this.onCompositionUpdate = function(text) { + this.renderer.setCompositionText(text); + }; + + this.onCompositionEnd = function() { + this.renderer.hideComposition(); + }; + this.getFirstVisibleRow = function() { + return this.renderer.getFirstVisibleRow(); + }; + this.getLastVisibleRow = function() { + return this.renderer.getLastVisibleRow(); + }; + this.isRowVisible = function(row) { + return (row >= this.getFirstVisibleRow() && row <= this.getLastVisibleRow()); + }; + this.isRowFullyVisible = function(row) { + return (row >= this.renderer.getFirstFullyVisibleRow() && row <= this.renderer.getLastFullyVisibleRow()); + }; + this.$getVisibleRowCount = function() { + return this.renderer.getScrollBottomRow() - this.renderer.getScrollTopRow() + 1; + }; + + this.$moveByPage = function(dir, select) { + var renderer = this.renderer; + var config = this.renderer.layerConfig; + var rows = dir * Math.floor(config.height / config.lineHeight); + + this.$blockScrolling++; + if (select === true) { + this.selection.$moveSelection(function(){ + this.moveCursorBy(rows, 0); + }); + } else if (select === false) { + this.selection.moveCursorBy(rows, 0); + this.selection.clearSelection(); + } + this.$blockScrolling--; + + var scrollTop = renderer.scrollTop; + + renderer.scrollBy(0, rows * config.lineHeight); + if (select != null) + renderer.scrollCursorIntoView(null, 0.5); + + renderer.animateScrolling(scrollTop); + }; + this.selectPageDown = function() { + this.$moveByPage(1, true); + }; + this.selectPageUp = function() { + this.$moveByPage(-1, true); + }; + this.gotoPageDown = function() { + this.$moveByPage(1, false); + }; + this.gotoPageUp = function() { + this.$moveByPage(-1, false); + }; + this.scrollPageDown = function() { + this.$moveByPage(1); + }; + this.scrollPageUp = function() { + this.$moveByPage(-1); + }; + this.scrollToRow = function(row) { + this.renderer.scrollToRow(row); + }; + this.scrollToLine = function(line, center, animate, callback) { + this.renderer.scrollToLine(line, center, animate, callback); + }; + this.centerSelection = function() { + var range = this.getSelectionRange(); + var pos = { + row: Math.floor(range.start.row + (range.end.row - range.start.row) / 2), + column: Math.floor(range.start.column + (range.end.column - range.start.column) / 2) + }; + this.renderer.alignCursor(pos, 0.5); + }; + this.getCursorPosition = function() { + return this.selection.getCursor(); + }; + this.getCursorPositionScreen = function() { + return this.session.documentToScreenPosition(this.getCursorPosition()); + }; + this.getSelectionRange = function() { + return this.selection.getRange(); + }; + this.selectAll = function() { + this.$blockScrolling += 1; + this.selection.selectAll(); + this.$blockScrolling -= 1; + }; + this.clearSelection = function() { + this.selection.clearSelection(); + }; + this.moveCursorTo = function(row, column) { + this.selection.moveCursorTo(row, column); + }; + this.moveCursorToPosition = function(pos) { + this.selection.moveCursorToPosition(pos); + }; + this.jumpToMatching = function(select, expand) { + var cursor = this.getCursorPosition(); + var iterator = new TokenIterator(this.session, cursor.row, cursor.column); + var prevToken = iterator.getCurrentToken(); + var token = prevToken || iterator.stepForward(); + + if (!token) return; + var matchType; + var found = false; + var depth = {}; + var i = cursor.column - token.start; + var bracketType; + var brackets = { + ")": "(", + "(": "(", + "]": "[", + "[": "[", + "{": "{", + "}": "{" + }; + + do { + if (token.value.match(/[{}()\[\]]/g)) { + for (; i < token.value.length && !found; i++) { + if (!brackets[token.value[i]]) { + continue; + } + + bracketType = brackets[token.value[i]] + '.' + token.type.replace("rparen", "lparen"); + + if (isNaN(depth[bracketType])) { + depth[bracketType] = 0; + } + + switch (token.value[i]) { + case '(': + case '[': + case '{': + depth[bracketType]++; + break; + case ')': + case ']': + case '}': + depth[bracketType]--; + + if (depth[bracketType] === -1) { + matchType = 'bracket'; + found = true; + } + break; + } + } + } + else if (token && token.type.indexOf('tag-name') !== -1) { + if (isNaN(depth[token.value])) { + depth[token.value] = 0; + } + + if (prevToken.value === '<') { + depth[token.value]++; + } + else if (prevToken.value === '= 0; --i) { + if(this.$tryReplace(ranges[i], replacement)) { + replaced++; + } + } + + this.selection.setSelectionRange(selection); + this.$blockScrolling -= 1; + + return replaced; + }; + + this.$tryReplace = function(range, replacement) { + var input = this.session.getTextRange(range); + replacement = this.$search.replace(input, replacement); + if (replacement !== null) { + range.end = this.session.replace(range, replacement); + return range; + } else { + return null; + } + }; + this.getLastSearchOptions = function() { + return this.$search.getOptions(); + }; + this.find = function(needle, options, animate) { + if (!options) + options = {}; + + if (typeof needle == "string" || needle instanceof RegExp) + options.needle = needle; + else if (typeof needle == "object") + oop.mixin(options, needle); + + var range = this.selection.getRange(); + if (options.needle == null) { + needle = this.session.getTextRange(range) + || this.$search.$options.needle; + if (!needle) { + range = this.session.getWordRange(range.start.row, range.start.column); + needle = this.session.getTextRange(range); + } + this.$search.set({needle: needle}); + } + + this.$search.set(options); + if (!options.start) + this.$search.set({start: range}); + + var newRange = this.$search.find(this.session); + if (options.preventScroll) + return newRange; + if (newRange) { + this.revealRange(newRange, animate); + return newRange; + } + if (options.backwards) + range.start = range.end; + else + range.end = range.start; + this.selection.setRange(range); + }; + this.findNext = function(options, animate) { + this.find({skipCurrent: true, backwards: false}, options, animate); + }; + this.findPrevious = function(options, animate) { + this.find(options, {skipCurrent: true, backwards: true}, animate); + }; + + this.revealRange = function(range, animate) { + this.$blockScrolling += 1; + this.session.unfold(range); + this.selection.setSelectionRange(range); + this.$blockScrolling -= 1; + + var scrollTop = this.renderer.scrollTop; + this.renderer.scrollSelectionIntoView(range.start, range.end, 0.5); + if (animate !== false) + this.renderer.animateScrolling(scrollTop); + }; + this.undo = function() { + this.$blockScrolling++; + this.session.getUndoManager().undo(); + this.$blockScrolling--; + this.renderer.scrollCursorIntoView(null, 0.5); + }; + this.redo = function() { + this.$blockScrolling++; + this.session.getUndoManager().redo(); + this.$blockScrolling--; + this.renderer.scrollCursorIntoView(null, 0.5); + }; + this.destroy = function() { + this.renderer.destroy(); + this._signal("destroy", this); + if (this.session) { + this.session.destroy(); + } + }; + this.setAutoScrollEditorIntoView = function(enable) { + if (!enable) + return; + var rect; + var self = this; + var shouldScroll = false; + if (!this.$scrollAnchor) + this.$scrollAnchor = document.createElement("div"); + var scrollAnchor = this.$scrollAnchor; + scrollAnchor.style.cssText = "position:absolute"; + this.container.insertBefore(scrollAnchor, this.container.firstChild); + var onChangeSelection = this.on("changeSelection", function() { + shouldScroll = true; + }); + var onBeforeRender = this.renderer.on("beforeRender", function() { + if (shouldScroll) + rect = self.renderer.container.getBoundingClientRect(); + }); + var onAfterRender = this.renderer.on("afterRender", function() { + if (shouldScroll && rect && (self.isFocused() + || self.searchBox && self.searchBox.isFocused()) + ) { + var renderer = self.renderer; + var pos = renderer.$cursorLayer.$pixelPos; + var config = renderer.layerConfig; + var top = pos.top - config.offset; + if (pos.top >= 0 && top + rect.top < 0) { + shouldScroll = true; + } else if (pos.top < config.height && + pos.top + rect.top + config.lineHeight > window.innerHeight) { + shouldScroll = false; + } else { + shouldScroll = null; + } + if (shouldScroll != null) { + scrollAnchor.style.top = top + "px"; + scrollAnchor.style.left = pos.left + "px"; + scrollAnchor.style.height = config.lineHeight + "px"; + scrollAnchor.scrollIntoView(shouldScroll); + } + shouldScroll = rect = null; + } + }); + this.setAutoScrollEditorIntoView = function(enable) { + if (enable) + return; + delete this.setAutoScrollEditorIntoView; + this.removeEventListener("changeSelection", onChangeSelection); + this.renderer.removeEventListener("afterRender", onAfterRender); + this.renderer.removeEventListener("beforeRender", onBeforeRender); + }; + }; + + + this.$resetCursorStyle = function() { + var style = this.$cursorStyle || "ace"; + var cursorLayer = this.renderer.$cursorLayer; + if (!cursorLayer) + return; + cursorLayer.setSmoothBlinking(/smooth/.test(style)); + cursorLayer.isBlinking = !this.$readOnly && style != "wide"; + dom.setCssClass(cursorLayer.element, "ace_slim-cursors", /slim/.test(style)); + }; + +}).call(Editor.prototype); + + + +config.defineOptions(Editor.prototype, "editor", { + selectionStyle: { + set: function(style) { + this.onSelectionChange(); + this._signal("changeSelectionStyle", {data: style}); + }, + initialValue: "line" + }, + highlightActiveLine: { + set: function() {this.$updateHighlightActiveLine();}, + initialValue: true + }, + highlightSelectedWord: { + set: function(shouldHighlight) {this.$onSelectionChange();}, + initialValue: true + }, + readOnly: { + set: function(readOnly) { + this.$resetCursorStyle(); + }, + initialValue: false + }, + cursorStyle: { + set: function(val) { this.$resetCursorStyle(); }, + values: ["ace", "slim", "smooth", "wide"], + initialValue: "ace" + }, + mergeUndoDeltas: { + values: [false, true, "always"], + initialValue: true + }, + behavioursEnabled: {initialValue: true}, + wrapBehavioursEnabled: {initialValue: true}, + autoScrollEditorIntoView: { + set: function(val) {this.setAutoScrollEditorIntoView(val)} + }, + + hScrollBarAlwaysVisible: "renderer", + vScrollBarAlwaysVisible: "renderer", + highlightGutterLine: "renderer", + animatedScroll: "renderer", + showInvisibles: "renderer", + showPrintMargin: "renderer", + printMarginColumn: "renderer", + printMargin: "renderer", + fadeFoldWidgets: "renderer", + showFoldWidgets: "renderer", + showLineNumbers: "renderer", + showGutter: "renderer", + displayIndentGuides: "renderer", + fontSize: "renderer", + fontFamily: "renderer", + maxLines: "renderer", + minLines: "renderer", + scrollPastEnd: "renderer", + fixedWidthGutter: "renderer", + theme: "renderer", + + scrollSpeed: "$mouseHandler", + dragDelay: "$mouseHandler", + dragEnabled: "$mouseHandler", + focusTimout: "$mouseHandler", + tooltipFollowsMouse: "$mouseHandler", + + firstLineNumber: "session", + overwrite: "session", + newLineMode: "session", + useWorker: "session", + useSoftTabs: "session", + tabSize: "session", + wrap: "session", + foldStyle: "session", + mode: "session" +}); + +exports.Editor = Editor; +}); + +define("ace/undomanager",["require","exports","module"], function(require, exports, module) { +"use strict"; +var UndoManager = function() { + this.reset(); +}; + +(function() { + this.execute = function(options) { + var deltas = options.args[0]; + this.$doc = options.args[1]; + if (options.merge && this.hasUndo()){ + this.dirtyCounter--; + deltas = this.$undoStack.pop().concat(deltas); + } + this.$undoStack.push(deltas); + this.$redoStack = []; + + if (this.dirtyCounter < 0) { + this.dirtyCounter = NaN; + } + this.dirtyCounter++; + }; + this.undo = function(dontSelect) { + var deltas = this.$undoStack.pop(); + var undoSelectionRange = null; + if (deltas) { + undoSelectionRange = + this.$doc.undoChanges(deltas, dontSelect); + this.$redoStack.push(deltas); + this.dirtyCounter--; + } + + return undoSelectionRange; + }; + this.redo = function(dontSelect) { + var deltas = this.$redoStack.pop(); + var redoSelectionRange = null; + if (deltas) { + redoSelectionRange = + this.$doc.redoChanges(deltas, dontSelect); + this.$undoStack.push(deltas); + this.dirtyCounter++; + } + + return redoSelectionRange; + }; + this.reset = function() { + this.$undoStack = []; + this.$redoStack = []; + this.dirtyCounter = 0; + }; + this.hasUndo = function() { + return this.$undoStack.length > 0; + }; + this.hasRedo = function() { + return this.$redoStack.length > 0; + }; + this.markClean = function() { + this.dirtyCounter = 0; + }; + this.isClean = function() { + return this.dirtyCounter === 0; + }; + +}).call(UndoManager.prototype); + +exports.UndoManager = UndoManager; +}); + +define("ace/layer/gutter",["require","exports","module","ace/lib/dom","ace/lib/oop","ace/lib/lang","ace/lib/event_emitter"], function(require, exports, module) { +"use strict"; + +var dom = require("../lib/dom"); +var oop = require("../lib/oop"); +var lang = require("../lib/lang"); +var EventEmitter = require("../lib/event_emitter").EventEmitter; + +var Gutter = function(parentEl) { + this.element = dom.createElement("div"); + this.element.className = "ace_layer ace_gutter-layer"; + parentEl.appendChild(this.element); + this.setShowFoldWidgets(this.$showFoldWidgets); + + this.gutterWidth = 0; + + this.$annotations = []; + this.$updateAnnotations = this.$updateAnnotations.bind(this); + + this.$cells = []; +}; + +(function() { + + oop.implement(this, EventEmitter); + + this.setSession = function(session) { + if (this.session) + this.session.removeEventListener("change", this.$updateAnnotations); + this.session = session; + if (session) + session.on("change", this.$updateAnnotations); + }; + + this.addGutterDecoration = function(row, className){ + if (window.console) + console.warn && console.warn("deprecated use session.addGutterDecoration"); + this.session.addGutterDecoration(row, className); + }; + + this.removeGutterDecoration = function(row, className){ + if (window.console) + console.warn && console.warn("deprecated use session.removeGutterDecoration"); + this.session.removeGutterDecoration(row, className); + }; + + this.setAnnotations = function(annotations) { + this.$annotations = []; + for (var i = 0; i < annotations.length; i++) { + var annotation = annotations[i]; + var row = annotation.row; + var rowInfo = this.$annotations[row]; + if (!rowInfo) + rowInfo = this.$annotations[row] = {text: []}; + + var annoText = annotation.text; + annoText = annoText ? lang.escapeHTML(annoText) : annotation.html || ""; + + if (rowInfo.text.indexOf(annoText) === -1) + rowInfo.text.push(annoText); + + var type = annotation.type; + if (type == "error") + rowInfo.className = " ace_error"; + else if (type == "warning" && rowInfo.className != " ace_error") + rowInfo.className = " ace_warning"; + else if (type == "info" && (!rowInfo.className)) + rowInfo.className = " ace_info"; + } + }; + + this.$updateAnnotations = function (e) { + if (!this.$annotations.length) + return; + var delta = e.data; + var range = delta.range; + var firstRow = range.start.row; + var len = range.end.row - firstRow; + if (len === 0) { + } else if (delta.action == "removeText" || delta.action == "removeLines") { + this.$annotations.splice(firstRow, len + 1, null); + } else { + var args = new Array(len + 1); + args.unshift(firstRow, 1); + this.$annotations.splice.apply(this.$annotations, args); + } + }; + + this.update = function(config) { + var session = this.session; + var firstRow = config.firstRow; + var lastRow = Math.min(config.lastRow + config.gutterOffset, // needed to compensate for hor scollbar + session.getLength() - 1); + var fold = session.getNextFoldLine(firstRow); + var foldStart = fold ? fold.start.row : Infinity; + var foldWidgets = this.$showFoldWidgets && session.foldWidgets; + var breakpoints = session.$breakpoints; + var decorations = session.$decorations; + var firstLineNumber = session.$firstLineNumber; + var lastLineNumber = 0; + + var gutterRenderer = session.gutterRenderer || this.$renderer; + + var cell = null; + var index = -1; + var row = firstRow; + while (true) { + if (row > foldStart) { + row = fold.end.row + 1; + fold = session.getNextFoldLine(row, fold); + foldStart = fold ? fold.start.row : Infinity; + } + if (row > lastRow) { + while (this.$cells.length > index + 1) { + cell = this.$cells.pop(); + this.element.removeChild(cell.element); + } + break; + } + + cell = this.$cells[++index]; + if (!cell) { + cell = {element: null, textNode: null, foldWidget: null}; + cell.element = dom.createElement("div"); + cell.textNode = document.createTextNode(''); + cell.element.appendChild(cell.textNode); + this.element.appendChild(cell.element); + this.$cells[index] = cell; + } + + var className = "ace_gutter-cell "; + if (breakpoints[row]) + className += breakpoints[row]; + if (decorations[row]) + className += decorations[row]; + if (this.$annotations[row]) + className += this.$annotations[row].className; + if (cell.element.className != className) + cell.element.className = className; + + var height = session.getRowLength(row) * config.lineHeight + "px"; + if (height != cell.element.style.height) + cell.element.style.height = height; + + if (foldWidgets) { + var c = foldWidgets[row]; + if (c == null) + c = foldWidgets[row] = session.getFoldWidget(row); + } + + if (c) { + if (!cell.foldWidget) { + cell.foldWidget = dom.createElement("span"); + cell.element.appendChild(cell.foldWidget); + } + var className = "ace_fold-widget ace_" + c; + if (c == "start" && row == foldStart && row < fold.end.row) + className += " ace_closed"; + else + className += " ace_open"; + if (cell.foldWidget.className != className) + cell.foldWidget.className = className; + + var height = config.lineHeight + "px"; + if (cell.foldWidget.style.height != height) + cell.foldWidget.style.height = height; + } else { + if (cell.foldWidget) { + cell.element.removeChild(cell.foldWidget); + cell.foldWidget = null; + } + } + + var text = lastLineNumber = gutterRenderer + ? gutterRenderer.getText(session, row) + : row + firstLineNumber; + if (text != cell.textNode.data) + cell.textNode.data = text; + + row++; + } + + this.element.style.height = config.minHeight + "px"; + + if (this.$fixedWidth || session.$useWrapMode) + lastLineNumber = session.getLength() + firstLineNumber; + + var gutterWidth = gutterRenderer + ? gutterRenderer.getWidth(session, lastLineNumber, config) + : lastLineNumber.toString().length * config.characterWidth; + + var padding = this.$padding || this.$computePadding(); + gutterWidth += padding.left + padding.right; + if (gutterWidth !== this.gutterWidth && !isNaN(gutterWidth)) { + this.gutterWidth = gutterWidth; + this.element.style.width = Math.ceil(this.gutterWidth) + "px"; + this._emit("changeGutterWidth", gutterWidth); + } + }; + + this.$fixedWidth = false; + + this.$showLineNumbers = true; + this.$renderer = ""; + this.setShowLineNumbers = function(show) { + this.$renderer = !show && { + getWidth: function() {return ""}, + getText: function() {return ""} + }; + }; + + this.getShowLineNumbers = function() { + return this.$showLineNumbers; + }; + + this.$showFoldWidgets = true; + this.setShowFoldWidgets = function(show) { + if (show) + dom.addCssClass(this.element, "ace_folding-enabled"); + else + dom.removeCssClass(this.element, "ace_folding-enabled"); + + this.$showFoldWidgets = show; + this.$padding = null; + }; + + this.getShowFoldWidgets = function() { + return this.$showFoldWidgets; + }; + + this.$computePadding = function() { + if (!this.element.firstChild) + return {left: 0, right: 0}; + var style = dom.computedStyle(this.element.firstChild); + this.$padding = {}; + this.$padding.left = parseInt(style.paddingLeft) + 1 || 0; + this.$padding.right = parseInt(style.paddingRight) || 0; + return this.$padding; + }; + + this.getRegion = function(point) { + var padding = this.$padding || this.$computePadding(); + var rect = this.element.getBoundingClientRect(); + if (point.x < padding.left + rect.left) + return "markers"; + if (this.$showFoldWidgets && point.x > rect.right - padding.right) + return "foldWidgets"; + }; + +}).call(Gutter.prototype); + +exports.Gutter = Gutter; + +}); + +define("ace/layer/marker",["require","exports","module","ace/range","ace/lib/dom"], function(require, exports, module) { +"use strict"; + +var Range = require("../range").Range; +var dom = require("../lib/dom"); + +var Marker = function(parentEl) { + this.element = dom.createElement("div"); + this.element.className = "ace_layer ace_marker-layer"; + parentEl.appendChild(this.element); +}; + +(function() { + + this.$padding = 0; + + this.setPadding = function(padding) { + this.$padding = padding; + }; + this.setSession = function(session) { + this.session = session; + }; + + this.setMarkers = function(markers) { + this.markers = markers; + }; + + this.update = function(config) { + var config = config || this.config; + if (!config) + return; + + this.config = config; + + + var html = []; + for (var key in this.markers) { + var marker = this.markers[key]; + + if (!marker.range) { + marker.update(html, this, this.session, config); + continue; + } + + var range = marker.range.clipRows(config.firstRow, config.lastRow); + if (range.isEmpty()) continue; + + range = range.toScreenRange(this.session); + if (marker.renderer) { + var top = this.$getTop(range.start.row, config); + var left = this.$padding + range.start.column * config.characterWidth; + marker.renderer(html, range, left, top, config); + } else if (marker.type == "fullLine") { + this.drawFullLineMarker(html, range, marker.clazz, config); + } else if (marker.type == "screenLine") { + this.drawScreenLineMarker(html, range, marker.clazz, config); + } else if (range.isMultiLine()) { + if (marker.type == "text") + this.drawTextMarker(html, range, marker.clazz, config); + else + this.drawMultiLineMarker(html, range, marker.clazz, config); + } else { + this.drawSingleLineMarker(html, range, marker.clazz + " ace_start", config); + } + } + this.element.innerHTML = html.join(""); + }; + + this.$getTop = function(row, layerConfig) { + return (row - layerConfig.firstRowScreen) * layerConfig.lineHeight; + }; + this.drawTextMarker = function(stringBuilder, range, clazz, layerConfig, extraStyle) { + var row = range.start.row; + + var lineRange = new Range( + row, range.start.column, + row, this.session.getScreenLastRowColumn(row) + ); + this.drawSingleLineMarker(stringBuilder, lineRange, clazz + " ace_start", layerConfig, 1, extraStyle); + row = range.end.row; + lineRange = new Range(row, 0, row, range.end.column); + this.drawSingleLineMarker(stringBuilder, lineRange, clazz, layerConfig, 0, extraStyle); + + for (row = range.start.row + 1; row < range.end.row; row++) { + lineRange.start.row = row; + lineRange.end.row = row; + lineRange.end.column = this.session.getScreenLastRowColumn(row); + this.drawSingleLineMarker(stringBuilder, lineRange, clazz, layerConfig, 1, extraStyle); + } + }; + this.drawMultiLineMarker = function(stringBuilder, range, clazz, config, extraStyle) { + var padding = this.$padding; + var height = config.lineHeight; + var top = this.$getTop(range.start.row, config); + var left = padding + range.start.column * config.characterWidth; + extraStyle = extraStyle || ""; + + stringBuilder.push( + "
" + ); + top = this.$getTop(range.end.row, config); + var width = range.end.column * config.characterWidth; + + stringBuilder.push( + "
" + ); + height = (range.end.row - range.start.row - 1) * config.lineHeight; + if (height < 0) + return; + top = this.$getTop(range.start.row + 1, config); + + stringBuilder.push( + "
" + ); + }; + this.drawSingleLineMarker = function(stringBuilder, range, clazz, config, extraLength, extraStyle) { + var height = config.lineHeight; + var width = (range.end.column + (extraLength || 0) - range.start.column) * config.characterWidth; + + var top = this.$getTop(range.start.row, config); + var left = this.$padding + range.start.column * config.characterWidth; + + stringBuilder.push( + "
" + ); + }; + + this.drawFullLineMarker = function(stringBuilder, range, clazz, config, extraStyle) { + var top = this.$getTop(range.start.row, config); + var height = config.lineHeight; + if (range.start.row != range.end.row) + height += this.$getTop(range.end.row, config) - top; + + stringBuilder.push( + "
" + ); + }; + + this.drawScreenLineMarker = function(stringBuilder, range, clazz, config, extraStyle) { + var top = this.$getTop(range.start.row, config); + var height = config.lineHeight; + + stringBuilder.push( + "
" + ); + }; + +}).call(Marker.prototype); + +exports.Marker = Marker; + +}); + +define("ace/layer/text",["require","exports","module","ace/lib/oop","ace/lib/dom","ace/lib/lang","ace/lib/useragent","ace/lib/event_emitter"], function(require, exports, module) { +"use strict"; + +var oop = require("../lib/oop"); +var dom = require("../lib/dom"); +var lang = require("../lib/lang"); +var useragent = require("../lib/useragent"); +var EventEmitter = require("../lib/event_emitter").EventEmitter; + +var Text = function(parentEl) { + this.element = dom.createElement("div"); + this.element.className = "ace_layer ace_text-layer"; + parentEl.appendChild(this.element); + this.$updateEolChar = this.$updateEolChar.bind(this); +}; + +(function() { + + oop.implement(this, EventEmitter); + + this.EOF_CHAR = "\xB6"; + this.EOL_CHAR_LF = "\xAC"; + this.EOL_CHAR_CRLF = "\xa4"; + this.EOL_CHAR = this.EOL_CHAR_LF; + this.TAB_CHAR = "\u2192"; //"\u21E5"; + this.SPACE_CHAR = "\xB7"; + this.$padding = 0; + + this.$updateEolChar = function() { + var EOL_CHAR = this.session.doc.getNewLineCharacter() == "\n" + ? this.EOL_CHAR_LF + : this.EOL_CHAR_CRLF; + if (this.EOL_CHAR != EOL_CHAR) { + this.EOL_CHAR = EOL_CHAR; + return true; + } + } + + this.setPadding = function(padding) { + this.$padding = padding; + this.element.style.padding = "0 " + padding + "px"; + }; + + this.getLineHeight = function() { + return this.$fontMetrics.$characterSize.height || 0; + }; + + this.getCharacterWidth = function() { + return this.$fontMetrics.$characterSize.width || 0; + }; + + this.$setFontMetrics = function(measure) { + this.$fontMetrics = measure; + this.$fontMetrics.on("changeCharacterSize", function(e) { + this._signal("changeCharacterSize", e); + }.bind(this)); + this.$pollSizeChanges(); + } + + this.checkForSizeChanges = function() { + this.$fontMetrics.checkForSizeChanges(); + }; + this.$pollSizeChanges = function() { + return this.$pollSizeChangesTimer = this.$fontMetrics.$pollSizeChanges(); + }; + this.setSession = function(session) { + this.session = session; + if (session) + this.$computeTabString(); + }; + + this.showInvisibles = false; + this.setShowInvisibles = function(showInvisibles) { + if (this.showInvisibles == showInvisibles) + return false; + + this.showInvisibles = showInvisibles; + this.$computeTabString(); + return true; + }; + + this.displayIndentGuides = true; + this.setDisplayIndentGuides = function(display) { + if (this.displayIndentGuides == display) + return false; + + this.displayIndentGuides = display; + this.$computeTabString(); + return true; + }; + + this.$tabStrings = []; + this.onChangeTabSize = + this.$computeTabString = function() { + var tabSize = this.session.getTabSize(); + this.tabSize = tabSize; + var tabStr = this.$tabStrings = [0]; + for (var i = 1; i < tabSize + 1; i++) { + if (this.showInvisibles) { + tabStr.push("" + + this.TAB_CHAR + + lang.stringRepeat("\xa0", i - 1) + + ""); + } else { + tabStr.push(lang.stringRepeat("\xa0", i)); + } + } + if (this.displayIndentGuides) { + this.$indentGuideRe = /\s\S| \t|\t |\s$/; + var className = "ace_indent-guide"; + var spaceClass = ""; + var tabClass = ""; + if (this.showInvisibles) { + className += " ace_invisible"; + spaceClass = " ace_invisible_space"; + tabClass = " ace_invisible_tab"; + var spaceContent = lang.stringRepeat(this.SPACE_CHAR, this.tabSize); + var tabContent = this.TAB_CHAR + lang.stringRepeat("\xa0", this.tabSize - 1); + } else{ + var spaceContent = lang.stringRepeat("\xa0", this.tabSize); + var tabContent = spaceContent; + } + + this.$tabStrings[" "] = "" + spaceContent + ""; + this.$tabStrings["\t"] = "" + tabContent + ""; + } + }; + + this.updateLines = function(config, firstRow, lastRow) { + if (this.config.lastRow != config.lastRow || + this.config.firstRow != config.firstRow) { + this.scrollLines(config); + } + this.config = config; + + var first = Math.max(firstRow, config.firstRow); + var last = Math.min(lastRow, config.lastRow); + + var lineElements = this.element.childNodes; + var lineElementsIdx = 0; + + for (var row = config.firstRow; row < first; row++) { + var foldLine = this.session.getFoldLine(row); + if (foldLine) { + if (foldLine.containsRow(first)) { + first = foldLine.start.row; + break; + } else { + row = foldLine.end.row; + } + } + lineElementsIdx ++; + } + + var row = first; + var foldLine = this.session.getNextFoldLine(row); + var foldStart = foldLine ? foldLine.start.row : Infinity; + + while (true) { + if (row > foldStart) { + row = foldLine.end.row+1; + foldLine = this.session.getNextFoldLine(row, foldLine); + foldStart = foldLine ? foldLine.start.row :Infinity; + } + if (row > last) + break; + + var lineElement = lineElements[lineElementsIdx++]; + if (lineElement) { + var html = []; + this.$renderLine( + html, row, !this.$useLineGroups(), row == foldStart ? foldLine : false + ); + lineElement.style.height = config.lineHeight * this.session.getRowLength(row) + "px"; + lineElement.innerHTML = html.join(""); + } + row++; + } + }; + + this.scrollLines = function(config) { + var oldConfig = this.config; + this.config = config; + + if (!oldConfig || oldConfig.lastRow < config.firstRow) + return this.update(config); + + if (config.lastRow < oldConfig.firstRow) + return this.update(config); + + var el = this.element; + if (oldConfig.firstRow < config.firstRow) + for (var row=this.session.getFoldedRowCount(oldConfig.firstRow, config.firstRow - 1); row>0; row--) + el.removeChild(el.firstChild); + + if (oldConfig.lastRow > config.lastRow) + for (var row=this.session.getFoldedRowCount(config.lastRow + 1, oldConfig.lastRow); row>0; row--) + el.removeChild(el.lastChild); + + if (config.firstRow < oldConfig.firstRow) { + var fragment = this.$renderLinesFragment(config, config.firstRow, oldConfig.firstRow - 1); + if (el.firstChild) + el.insertBefore(fragment, el.firstChild); + else + el.appendChild(fragment); + } + + if (config.lastRow > oldConfig.lastRow) { + var fragment = this.$renderLinesFragment(config, oldConfig.lastRow + 1, config.lastRow); + el.appendChild(fragment); + } + }; + + this.$renderLinesFragment = function(config, firstRow, lastRow) { + var fragment = this.element.ownerDocument.createDocumentFragment(); + var row = firstRow; + var foldLine = this.session.getNextFoldLine(row); + var foldStart = foldLine ? foldLine.start.row : Infinity; + + while (true) { + if (row > foldStart) { + row = foldLine.end.row+1; + foldLine = this.session.getNextFoldLine(row, foldLine); + foldStart = foldLine ? foldLine.start.row : Infinity; + } + if (row > lastRow) + break; + + var container = dom.createElement("div"); + + var html = []; + this.$renderLine(html, row, false, row == foldStart ? foldLine : false); + container.innerHTML = html.join(""); + if (this.$useLineGroups()) { + container.className = 'ace_line_group'; + fragment.appendChild(container); + container.style.height = config.lineHeight * this.session.getRowLength(row) + "px"; + + } else { + while(container.firstChild) + fragment.appendChild(container.firstChild); + } + + row++; + } + return fragment; + }; + + this.update = function(config) { + this.config = config; + + var html = []; + var firstRow = config.firstRow, lastRow = config.lastRow; + + var row = firstRow; + var foldLine = this.session.getNextFoldLine(row); + var foldStart = foldLine ? foldLine.start.row : Infinity; + + while (true) { + if (row > foldStart) { + row = foldLine.end.row+1; + foldLine = this.session.getNextFoldLine(row, foldLine); + foldStart = foldLine ? foldLine.start.row :Infinity; + } + if (row > lastRow) + break; + + if (this.$useLineGroups()) + html.push("
") + + this.$renderLine(html, row, false, row == foldStart ? foldLine : false); + + if (this.$useLineGroups()) + html.push("
"); // end the line group + + row++; + } + this.element.innerHTML = html.join(""); + }; + + this.$textToken = { + "text": true, + "rparen": true, + "lparen": true + }; + + this.$renderToken = function(stringBuilder, screenColumn, token, value) { + var self = this; + var replaceReg = /\t|&|<|( +)|([\x00-\x1f\x80-\xa0\xad\u1680\u180E\u2000-\u200f\u2028\u2029\u202F\u205F\u3000\uFEFF])|[\u1100-\u115F\u11A3-\u11A7\u11FA-\u11FF\u2329-\u232A\u2E80-\u2E99\u2E9B-\u2EF3\u2F00-\u2FD5\u2FF0-\u2FFB\u3000-\u303E\u3041-\u3096\u3099-\u30FF\u3105-\u312D\u3131-\u318E\u3190-\u31BA\u31C0-\u31E3\u31F0-\u321E\u3220-\u3247\u3250-\u32FE\u3300-\u4DBF\u4E00-\uA48C\uA490-\uA4C6\uA960-\uA97C\uAC00-\uD7A3\uD7B0-\uD7C6\uD7CB-\uD7FB\uF900-\uFAFF\uFE10-\uFE19\uFE30-\uFE52\uFE54-\uFE66\uFE68-\uFE6B\uFF01-\uFF60\uFFE0-\uFFE6]/g; + var replaceFunc = function(c, a, b, tabIdx, idx4) { + if (a) { + return self.showInvisibles ? + "" + lang.stringRepeat(self.SPACE_CHAR, c.length) + "" : + lang.stringRepeat("\xa0", c.length); + } else if (c == "&") { + return "&"; + } else if (c == "<") { + return "<"; + } else if (c == "\t") { + var tabSize = self.session.getScreenTabSize(screenColumn + tabIdx); + screenColumn += tabSize - 1; + return self.$tabStrings[tabSize]; + } else if (c == "\u3000") { + var classToUse = self.showInvisibles ? "ace_cjk ace_invisible ace_invisible_space" : "ace_cjk"; + var space = self.showInvisibles ? self.SPACE_CHAR : ""; + screenColumn += 1; + return "" + space + ""; + } else if (b) { + return "" + self.SPACE_CHAR + ""; + } else { + screenColumn += 1; + return "" + c + ""; + } + }; + + var output = value.replace(replaceReg, replaceFunc); + + if (!this.$textToken[token.type]) { + var classes = "ace_" + token.type.replace(/\./g, " ace_"); + var style = ""; + if (token.type == "fold") + style = " style='width:" + (token.value.length * this.config.characterWidth) + "px;' "; + stringBuilder.push("", output, ""); + } + else { + stringBuilder.push(output); + } + return screenColumn + value.length; + }; + + this.renderIndentGuide = function(stringBuilder, value, max) { + var cols = value.search(this.$indentGuideRe); + if (cols <= 0 || cols >= max) + return value; + if (value[0] == " ") { + cols -= cols % this.tabSize; + stringBuilder.push(lang.stringRepeat(this.$tabStrings[" "], cols/this.tabSize)); + return value.substr(cols); + } else if (value[0] == "\t") { + stringBuilder.push(lang.stringRepeat(this.$tabStrings["\t"], cols)); + return value.substr(cols); + } + return value; + }; + + this.$renderWrappedLine = function(stringBuilder, tokens, splits, onlyContents) { + var chars = 0; + var split = 0; + var splitChars = splits[0]; + var screenColumn = 0; + + for (var i = 0; i < tokens.length; i++) { + var token = tokens[i]; + var value = token.value; + if (i == 0 && this.displayIndentGuides) { + chars = value.length; + value = this.renderIndentGuide(stringBuilder, value, splitChars); + if (!value) + continue; + chars -= value.length; + } + + if (chars + value.length < splitChars) { + screenColumn = this.$renderToken(stringBuilder, screenColumn, token, value); + chars += value.length; + } else { + while (chars + value.length >= splitChars) { + screenColumn = this.$renderToken( + stringBuilder, screenColumn, + token, value.substring(0, splitChars - chars) + ); + value = value.substring(splitChars - chars); + chars = splitChars; + + if (!onlyContents) { + stringBuilder.push("", + "
" + ); + } + + split ++; + screenColumn = 0; + splitChars = splits[split] || Number.MAX_VALUE; + } + if (value.length != 0) { + chars += value.length; + screenColumn = this.$renderToken( + stringBuilder, screenColumn, token, value + ); + } + } + } + }; + + this.$renderSimpleLine = function(stringBuilder, tokens) { + var screenColumn = 0; + var token = tokens[0]; + var value = token.value; + if (this.displayIndentGuides) + value = this.renderIndentGuide(stringBuilder, value); + if (value) + screenColumn = this.$renderToken(stringBuilder, screenColumn, token, value); + for (var i = 1; i < tokens.length; i++) { + token = tokens[i]; + value = token.value; + screenColumn = this.$renderToken(stringBuilder, screenColumn, token, value); + } + }; + this.$renderLine = function(stringBuilder, row, onlyContents, foldLine) { + if (!foldLine && foldLine != false) + foldLine = this.session.getFoldLine(row); + + if (foldLine) + var tokens = this.$getFoldLineTokens(row, foldLine); + else + var tokens = this.session.getTokens(row); + + + if (!onlyContents) { + stringBuilder.push( + "
" + ); + } + + if (tokens.length) { + var splits = this.session.getRowSplitData(row); + if (splits && splits.length) + this.$renderWrappedLine(stringBuilder, tokens, splits, onlyContents); + else + this.$renderSimpleLine(stringBuilder, tokens); + } + + if (this.showInvisibles) { + if (foldLine) + row = foldLine.end.row + + stringBuilder.push( + "", + row == this.session.getLength() - 1 ? this.EOF_CHAR : this.EOL_CHAR, + "" + ); + } + if (!onlyContents) + stringBuilder.push("
"); + }; + + this.$getFoldLineTokens = function(row, foldLine) { + var session = this.session; + var renderTokens = []; + + function addTokens(tokens, from, to) { + var idx = 0, col = 0; + while ((col + tokens[idx].value.length) < from) { + col += tokens[idx].value.length; + idx++; + + if (idx == tokens.length) + return; + } + if (col != from) { + var value = tokens[idx].value.substring(from - col); + if (value.length > (to - from)) + value = value.substring(0, to - from); + + renderTokens.push({ + type: tokens[idx].type, + value: value + }); + + col = from + value.length; + idx += 1; + } + + while (col < to && idx < tokens.length) { + var value = tokens[idx].value; + if (value.length + col > to) { + renderTokens.push({ + type: tokens[idx].type, + value: value.substring(0, to - col) + }); + } else + renderTokens.push(tokens[idx]); + col += value.length; + idx += 1; + } + } + + var tokens = session.getTokens(row); + foldLine.walk(function(placeholder, row, column, lastColumn, isNewRow) { + if (placeholder != null) { + renderTokens.push({ + type: "fold", + value: placeholder + }); + } else { + if (isNewRow) + tokens = session.getTokens(row); + + if (tokens.length) + addTokens(tokens, lastColumn, column); + } + }, foldLine.end.row, this.session.getLine(foldLine.end.row).length); + + return renderTokens; + }; + + this.$useLineGroups = function() { + return this.session.getUseWrapMode(); + }; + + this.destroy = function() { + clearInterval(this.$pollSizeChangesTimer); + if (this.$measureNode) + this.$measureNode.parentNode.removeChild(this.$measureNode); + delete this.$measureNode; + }; + +}).call(Text.prototype); + +exports.Text = Text; + +}); + +define("ace/layer/cursor",["require","exports","module","ace/lib/dom"], function(require, exports, module) { +"use strict"; + +var dom = require("../lib/dom"); +var IE8; + +var Cursor = function(parentEl) { + this.element = dom.createElement("div"); + this.element.className = "ace_layer ace_cursor-layer"; + parentEl.appendChild(this.element); + + if (IE8 === undefined) + IE8 = "opacity" in this.element; + + this.isVisible = false; + this.isBlinking = true; + this.blinkInterval = 1000; + this.smoothBlinking = false; + + this.cursors = []; + this.cursor = this.addCursor(); + dom.addCssClass(this.element, "ace_hidden-cursors"); + this.$updateCursors = this.$updateVisibility.bind(this); +}; + +(function() { + + this.$updateVisibility = function(val) { + var cursors = this.cursors; + for (var i = cursors.length; i--; ) + cursors[i].style.visibility = val ? "" : "hidden"; + }; + this.$updateOpacity = function(val) { + var cursors = this.cursors; + for (var i = cursors.length; i--; ) + cursors[i].style.opacity = val ? "" : "0"; + }; + + + this.$padding = 0; + this.setPadding = function(padding) { + this.$padding = padding; + }; + + this.setSession = function(session) { + this.session = session; + }; + + this.setBlinking = function(blinking) { + if (blinking != this.isBlinking){ + this.isBlinking = blinking; + this.restartTimer(); + } + }; + + this.setBlinkInterval = function(blinkInterval) { + if (blinkInterval != this.blinkInterval){ + this.blinkInterval = blinkInterval; + this.restartTimer(); + } + }; + + this.setSmoothBlinking = function(smoothBlinking) { + if (smoothBlinking != this.smoothBlinking && !IE8) { + this.smoothBlinking = smoothBlinking; + dom.setCssClass(this.element, "ace_smooth-blinking", smoothBlinking); + this.$updateCursors(true); + this.$updateCursors = (smoothBlinking + ? this.$updateOpacity + : this.$updateVisibility).bind(this); + this.restartTimer(); + } + }; + + this.addCursor = function() { + var el = dom.createElement("div"); + el.className = "ace_cursor"; + this.element.appendChild(el); + this.cursors.push(el); + return el; + }; + + this.removeCursor = function() { + if (this.cursors.length > 1) { + var el = this.cursors.pop(); + el.parentNode.removeChild(el); + return el; + } + }; + + this.hideCursor = function() { + this.isVisible = false; + dom.addCssClass(this.element, "ace_hidden-cursors"); + this.restartTimer(); + }; + + this.showCursor = function() { + this.isVisible = true; + dom.removeCssClass(this.element, "ace_hidden-cursors"); + this.restartTimer(); + }; + + this.restartTimer = function() { + var update = this.$updateCursors; + clearInterval(this.intervalId); + clearTimeout(this.timeoutId); + if (this.smoothBlinking) { + dom.removeCssClass(this.element, "ace_smooth-blinking"); + } + + update(true); + + if (!this.isBlinking || !this.blinkInterval || !this.isVisible) + return; + + if (this.smoothBlinking) { + setTimeout(function(){ + dom.addCssClass(this.element, "ace_smooth-blinking"); + }.bind(this)); + } + + var blink = function(){ + this.timeoutId = setTimeout(function() { + update(false); + }, 0.6 * this.blinkInterval); + }.bind(this); + + this.intervalId = setInterval(function() { + update(true); + blink(); + }, this.blinkInterval); + + blink(); + }; + + this.getPixelPosition = function(position, onScreen) { + if (!this.config || !this.session) + return {left : 0, top : 0}; + + if (!position) + position = this.session.selection.getCursor(); + var pos = this.session.documentToScreenPosition(position); + var cursorLeft = this.$padding + pos.column * this.config.characterWidth; + var cursorTop = (pos.row - (onScreen ? this.config.firstRowScreen : 0)) * + this.config.lineHeight; + + return {left : cursorLeft, top : cursorTop}; + }; + + this.update = function(config) { + this.config = config; + + var selections = this.session.$selectionMarkers; + var i = 0, cursorIndex = 0; + + if (selections === undefined || selections.length === 0){ + selections = [{cursor: null}]; + } + + for (var i = 0, n = selections.length; i < n; i++) { + var pixelPos = this.getPixelPosition(selections[i].cursor, true); + if ((pixelPos.top > config.height + config.offset || + pixelPos.top < 0) && i > 1) { + continue; + } + + var style = (this.cursors[cursorIndex++] || this.addCursor()).style; + + if (!this.drawCursor) { + style.left = pixelPos.left + "px"; + style.top = pixelPos.top + "px"; + style.width = config.characterWidth + "px"; + style.height = config.lineHeight + "px"; + } else { + this.drawCursor(style, pixelPos, config, selections[i], this.session); + } + } + while (this.cursors.length > cursorIndex) + this.removeCursor(); + + var overwrite = this.session.getOverwrite(); + this.$setOverwrite(overwrite); + this.$pixelPos = pixelPos; + this.restartTimer(); + }; + + this.drawCursor = null; + + this.$setOverwrite = function(overwrite) { + if (overwrite != this.overwrite) { + this.overwrite = overwrite; + if (overwrite) + dom.addCssClass(this.element, "ace_overwrite-cursors"); + else + dom.removeCssClass(this.element, "ace_overwrite-cursors"); + } + }; + + this.destroy = function() { + clearInterval(this.intervalId); + clearTimeout(this.timeoutId); + }; + +}).call(Cursor.prototype); + +exports.Cursor = Cursor; + +}); + +define("ace/scrollbar",["require","exports","module","ace/lib/oop","ace/lib/dom","ace/lib/event","ace/lib/event_emitter"], function(require, exports, module) { +"use strict"; + +var oop = require("./lib/oop"); +var dom = require("./lib/dom"); +var event = require("./lib/event"); +var EventEmitter = require("./lib/event_emitter").EventEmitter; +var ScrollBar = function(parent) { + this.element = dom.createElement("div"); + this.element.className = "ace_scrollbar ace_scrollbar" + this.classSuffix; + + this.inner = dom.createElement("div"); + this.inner.className = "ace_scrollbar-inner"; + this.element.appendChild(this.inner); + + parent.appendChild(this.element); + + this.setVisible(false); + this.skipEvent = false; + + event.addListener(this.element, "scroll", this.onScroll.bind(this)); + event.addListener(this.element, "mousedown", event.preventDefault); +}; + +(function() { + oop.implement(this, EventEmitter); + + this.setVisible = function(isVisible) { + this.element.style.display = isVisible ? "" : "none"; + this.isVisible = isVisible; + }; +}).call(ScrollBar.prototype); +var VScrollBar = function(parent, renderer) { + ScrollBar.call(this, parent); + this.scrollTop = 0; + renderer.$scrollbarWidth = + this.width = dom.scrollbarWidth(parent.ownerDocument); + this.inner.style.width = + this.element.style.width = (this.width || 15) + 5 + "px"; +}; + +oop.inherits(VScrollBar, ScrollBar); + +(function() { + + this.classSuffix = '-v'; + this.onScroll = function() { + if (!this.skipEvent) { + this.scrollTop = this.element.scrollTop; + this._emit("scroll", {data: this.scrollTop}); + } + this.skipEvent = false; + }; + this.getWidth = function() { + return this.isVisible ? this.width : 0; + }; + this.setHeight = function(height) { + this.element.style.height = height + "px"; + }; + this.setInnerHeight = function(height) { + this.inner.style.height = height + "px"; + }; + this.setScrollHeight = function(height) { + this.inner.style.height = height + "px"; + }; + this.setScrollTop = function(scrollTop) { + if (this.scrollTop != scrollTop) { + this.skipEvent = true; + this.scrollTop = this.element.scrollTop = scrollTop; + } + }; + +}).call(VScrollBar.prototype); +var HScrollBar = function(parent, renderer) { + ScrollBar.call(this, parent); + this.scrollLeft = 0; + this.height = renderer.$scrollbarWidth; + this.inner.style.height = + this.element.style.height = (this.height || 15) + 5 + "px"; +}; + +oop.inherits(HScrollBar, ScrollBar); + +(function() { + + this.classSuffix = '-h'; + this.onScroll = function() { + if (!this.skipEvent) { + this.scrollLeft = this.element.scrollLeft; + this._emit("scroll", {data: this.scrollLeft}); + } + this.skipEvent = false; + }; + this.getHeight = function() { + return this.isVisible ? this.height : 0; + }; + this.setWidth = function(width) { + this.element.style.width = width + "px"; + }; + this.setInnerWidth = function(width) { + this.inner.style.width = width + "px"; + }; + this.setScrollWidth = function(width) { + this.inner.style.width = width + "px"; + }; + this.setScrollLeft = function(scrollLeft) { + if (this.scrollLeft != scrollLeft) { + this.skipEvent = true; + this.scrollLeft = this.element.scrollLeft = scrollLeft; + } + }; + +}).call(HScrollBar.prototype); + + +exports.ScrollBar = VScrollBar; // backward compatibility +exports.ScrollBarV = VScrollBar; // backward compatibility +exports.ScrollBarH = HScrollBar; // backward compatibility + +exports.VScrollBar = VScrollBar; +exports.HScrollBar = HScrollBar; +}); + +define("ace/renderloop",["require","exports","module","ace/lib/event"], function(require, exports, module) { +"use strict"; + +var event = require("./lib/event"); + + +var RenderLoop = function(onRender, win) { + this.onRender = onRender; + this.pending = false; + this.changes = 0; + this.window = win || window; +}; + +(function() { + + + this.schedule = function(change) { + this.changes = this.changes | change; + if (!this.pending && this.changes) { + this.pending = true; + var _self = this; + event.nextFrame(function() { + _self.pending = false; + var changes; + while (changes = _self.changes) { + _self.changes = 0; + _self.onRender(changes); + } + }, this.window); + } + }; + +}).call(RenderLoop.prototype); + +exports.RenderLoop = RenderLoop; +}); + +define("ace/layer/font_metrics",["require","exports","module","ace/lib/oop","ace/lib/dom","ace/lib/lang","ace/lib/useragent","ace/lib/event_emitter"], function(require, exports, module) { + +var oop = require("../lib/oop"); +var dom = require("../lib/dom"); +var lang = require("../lib/lang"); +var useragent = require("../lib/useragent"); +var EventEmitter = require("../lib/event_emitter").EventEmitter; + +var CHAR_COUNT = 0; + +var FontMetrics = exports.FontMetrics = function(parentEl, interval) { + this.el = dom.createElement("div"); + this.$setMeasureNodeStyles(this.el.style, true); + + this.$main = dom.createElement("div"); + this.$setMeasureNodeStyles(this.$main.style); + + this.$measureNode = dom.createElement("div"); + this.$setMeasureNodeStyles(this.$measureNode.style); + + + this.el.appendChild(this.$main); + this.el.appendChild(this.$measureNode); + parentEl.appendChild(this.el); + + if (!CHAR_COUNT) + this.$testFractionalRect(); + this.$measureNode.innerHTML = lang.stringRepeat("X", CHAR_COUNT); + + this.$characterSize = {width: 0, height: 0}; + this.checkForSizeChanges(); +}; + +(function() { + + oop.implement(this, EventEmitter); + + this.$characterSize = {width: 0, height: 0}; + + this.$testFractionalRect = function() { + var el = dom.createElement("div"); + this.$setMeasureNodeStyles(el.style); + el.style.width = "0.2px"; + document.documentElement.appendChild(el); + var w = el.getBoundingClientRect().width; + if (w > 0 && w < 1) + CHAR_COUNT = 50; + else + CHAR_COUNT = 100; + el.parentNode.removeChild(el); + }; + + this.$setMeasureNodeStyles = function(style, isRoot) { + style.width = style.height = "auto"; + style.left = style.top = "0px"; + style.visibility = "hidden"; + style.position = "absolute"; + style.whiteSpace = "pre"; + + if (useragent.isIE < 8) { + style["font-family"] = "inherit"; + } else { + style.font = "inherit"; + } + style.overflow = isRoot ? "hidden" : "visible"; + }; + + this.checkForSizeChanges = function() { + var size = this.$measureSizes(); + if (size && (this.$characterSize.width !== size.width || this.$characterSize.height !== size.height)) { + this.$measureNode.style.fontWeight = "bold"; + var boldSize = this.$measureSizes(); + this.$measureNode.style.fontWeight = ""; + this.$characterSize = size; + this.charSizes = Object.create(null); + this.allowBoldFonts = boldSize && boldSize.width === size.width && boldSize.height === size.height; + this._emit("changeCharacterSize", {data: size}); + } + }; + + this.$pollSizeChanges = function() { + if (this.$pollSizeChangesTimer) + return this.$pollSizeChangesTimer; + var self = this; + return this.$pollSizeChangesTimer = setInterval(function() { + self.checkForSizeChanges(); + }, 500); + }; + + this.setPolling = function(val) { + if (val) { + this.$pollSizeChanges(); + } else { + if (this.$pollSizeChangesTimer) + this.$pollSizeChangesTimer; + } + }; + + this.$measureSizes = function() { + if (CHAR_COUNT === 50) { + var rect = null; + try { + rect = this.$measureNode.getBoundingClientRect(); + } catch(e) { + rect = {width: 0, height:0 }; + }; + var size = { + height: rect.height, + width: rect.width / CHAR_COUNT + }; + } else { + var size = { + height: this.$measureNode.clientHeight, + width: this.$measureNode.clientWidth / CHAR_COUNT + }; + } + if (size.width === 0 || size.height === 0) + return null; + return size; + }; + + this.$measureCharWidth = function(ch) { + this.$main.innerHTML = lang.stringRepeat(ch, CHAR_COUNT); + var rect = this.$main.getBoundingClientRect(); + return rect.width / CHAR_COUNT; + }; + + this.getCharacterWidth = function(ch) { + var w = this.charSizes[ch]; + if (w === undefined) { + this.charSizes[ch] = this.$measureCharWidth(ch) / this.$characterSize.width; + } + return w; + }; + + this.destroy = function() { + clearInterval(this.$pollSizeChangesTimer); + if (this.el && this.el.parentNode) + this.el.parentNode.removeChild(this.el); + }; + +}).call(FontMetrics.prototype); + +}); + +define("ace/virtual_renderer",["require","exports","module","ace/lib/oop","ace/lib/dom","ace/config","ace/lib/useragent","ace/layer/gutter","ace/layer/marker","ace/layer/text","ace/layer/cursor","ace/scrollbar","ace/scrollbar","ace/renderloop","ace/layer/font_metrics","ace/lib/event_emitter"], function(require, exports, module) { +"use strict"; + +var oop = require("./lib/oop"); +var dom = require("./lib/dom"); +var config = require("./config"); +var useragent = require("./lib/useragent"); +var GutterLayer = require("./layer/gutter").Gutter; +var MarkerLayer = require("./layer/marker").Marker; +var TextLayer = require("./layer/text").Text; +var CursorLayer = require("./layer/cursor").Cursor; +var HScrollBar = require("./scrollbar").HScrollBar; +var VScrollBar = require("./scrollbar").VScrollBar; +var RenderLoop = require("./renderloop").RenderLoop; +var FontMetrics = require("./layer/font_metrics").FontMetrics; +var EventEmitter = require("./lib/event_emitter").EventEmitter; +var editorCss = ".ace_editor {\ +position: relative;\ +overflow: hidden;\ +font: 12px/normal 'Monaco', 'Menlo', 'Ubuntu Mono', 'Consolas', 'source-code-pro', monospace;\ +direction: ltr;\ +}\ +.ace_scroller {\ +position: absolute;\ +overflow: hidden;\ +top: 0;\ +bottom: 0;\ +background-color: inherit;\ +-ms-user-select: none;\ +-moz-user-select: none;\ +-webkit-user-select: none;\ +user-select: none;\ +cursor: text;\ +}\ +.ace_content {\ +position: absolute;\ +-moz-box-sizing: border-box;\ +-webkit-box-sizing: border-box;\ +box-sizing: border-box;\ +min-width: 100%;\ +}\ +.ace_dragging .ace_scroller:before{\ +position: absolute;\ +top: 0;\ +left: 0;\ +right: 0;\ +bottom: 0;\ +content: '';\ +background: rgba(250, 250, 250, 0.01);\ +z-index: 1000;\ +}\ +.ace_dragging.ace_dark .ace_scroller:before{\ +background: rgba(0, 0, 0, 0.01);\ +}\ +.ace_selecting, .ace_selecting * {\ +cursor: text !important;\ +}\ +.ace_gutter {\ +position: absolute;\ +overflow : hidden;\ +width: auto;\ +top: 0;\ +bottom: 0;\ +left: 0;\ +cursor: default;\ +z-index: 4;\ +-ms-user-select: none;\ +-moz-user-select: none;\ +-webkit-user-select: none;\ +user-select: none;\ +}\ +.ace_gutter-active-line {\ +position: absolute;\ +left: 0;\ +right: 0;\ +}\ +.ace_scroller.ace_scroll-left {\ +box-shadow: 17px 0 16px -16px rgba(0, 0, 0, 0.4) inset;\ +}\ +.ace_gutter-cell {\ +padding-left: 19px;\ +padding-right: 6px;\ +background-repeat: no-repeat;\ +}\ +.ace_gutter-cell.ace_error {\ +background-image: url(\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAMAAAAoLQ9TAAABOFBMVEX/////////QRswFAb/Ui4wFAYwFAYwFAaWGAfDRymzOSH/PxswFAb/SiUwFAYwFAbUPRvjQiDllog5HhHdRybsTi3/Tyv9Tir+Syj/UC3////XurebMBIwFAb/RSHbPx/gUzfdwL3kzMivKBAwFAbbvbnhPx66NhowFAYwFAaZJg8wFAaxKBDZurf/RB6mMxb/SCMwFAYwFAbxQB3+RB4wFAb/Qhy4Oh+4QifbNRcwFAYwFAYwFAb/QRzdNhgwFAYwFAbav7v/Uy7oaE68MBK5LxLewr/r2NXewLswFAaxJw4wFAbkPRy2PyYwFAaxKhLm1tMwFAazPiQwFAaUGAb/QBrfOx3bvrv/VC/maE4wFAbRPBq6MRO8Qynew8Dp2tjfwb0wFAbx6eju5+by6uns4uH9/f36+vr/GkHjAAAAYnRSTlMAGt+64rnWu/bo8eAA4InH3+DwoN7j4eLi4xP99Nfg4+b+/u9B/eDs1MD1mO7+4PHg2MXa347g7vDizMLN4eG+Pv7i5evs/v79yu7S3/DV7/498Yv24eH+4ufQ3Ozu/v7+y13sRqwAAADLSURBVHjaZc/XDsFgGIBhtDrshlitmk2IrbHFqL2pvXf/+78DPokj7+Fz9qpU/9UXJIlhmPaTaQ6QPaz0mm+5gwkgovcV6GZzd5JtCQwgsxoHOvJO15kleRLAnMgHFIESUEPmawB9ngmelTtipwwfASilxOLyiV5UVUyVAfbG0cCPHig+GBkzAENHS0AstVF6bacZIOzgLmxsHbt2OecNgJC83JERmePUYq8ARGkJx6XtFsdddBQgZE2nPR6CICZhawjA4Fb/chv+399kfR+MMMDGOQAAAABJRU5ErkJggg==\");\ +background-repeat: no-repeat;\ +background-position: 2px center;\ +}\ +.ace_gutter-cell.ace_warning {\ +background-image: url(\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAMAAAAoLQ9TAAAAmVBMVEX///8AAAD///8AAAAAAABPSzb/5sAAAAB/blH/73z/ulkAAAAAAAD85pkAAAAAAAACAgP/vGz/rkDerGbGrV7/pkQICAf////e0IsAAAD/oED/qTvhrnUAAAD/yHD/njcAAADuv2r/nz//oTj/p064oGf/zHAAAAA9Nir/tFIAAAD/tlTiuWf/tkIAAACynXEAAAAAAAAtIRW7zBpBAAAAM3RSTlMAABR1m7RXO8Ln31Z36zT+neXe5OzooRDfn+TZ4p3h2hTf4t3k3ucyrN1K5+Xaks52Sfs9CXgrAAAAjklEQVR42o3PbQ+CIBQFYEwboPhSYgoYunIqqLn6/z8uYdH8Vmdnu9vz4WwXgN/xTPRD2+sgOcZjsge/whXZgUaYYvT8QnuJaUrjrHUQreGczuEafQCO/SJTufTbroWsPgsllVhq3wJEk2jUSzX3CUEDJC84707djRc5MTAQxoLgupWRwW6UB5fS++NV8AbOZgnsC7BpEAAAAABJRU5ErkJggg==\");\ +background-position: 2px center;\ +}\ +.ace_gutter-cell.ace_info {\ +background-image: url(\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAAAAAA6mKC9AAAAGXRFWHRTb2Z0d2FyZQBBZG9iZSBJbWFnZVJlYWR5ccllPAAAAAJ0Uk5TAAB2k804AAAAPklEQVQY02NgIB68QuO3tiLznjAwpKTgNyDbMegwisCHZUETUZV0ZqOquBpXj2rtnpSJT1AEnnRmL2OgGgAAIKkRQap2htgAAAAASUVORK5CYII=\");\ +background-position: 2px center;\ +}\ +.ace_dark .ace_gutter-cell.ace_info {\ +background-image: url(\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQBAMAAADt3eJSAAAAJFBMVEUAAAChoaGAgIAqKiq+vr6tra1ZWVmUlJSbm5s8PDxubm56enrdgzg3AAAAAXRSTlMAQObYZgAAAClJREFUeNpjYMAPdsMYHegyJZFQBlsUlMFVCWUYKkAZMxZAGdxlDMQBAG+TBP4B6RyJAAAAAElFTkSuQmCC\");\ +}\ +.ace_scrollbar {\ +position: absolute;\ +right: 0;\ +bottom: 0;\ +z-index: 6;\ +}\ +.ace_scrollbar-inner {\ +position: absolute;\ +cursor: text;\ +left: 0;\ +top: 0;\ +}\ +.ace_scrollbar-v{\ +overflow-x: hidden;\ +overflow-y: scroll;\ +top: 0;\ +}\ +.ace_scrollbar-h {\ +overflow-x: scroll;\ +overflow-y: hidden;\ +left: 0;\ +}\ +.ace_print-margin {\ +position: absolute;\ +height: 100%;\ +}\ +.ace_text-input {\ +position: absolute;\ +z-index: 0;\ +width: 0.5em;\ +height: 1em;\ +opacity: 0;\ +background: transparent;\ +-moz-appearance: none;\ +appearance: none;\ +border: none;\ +resize: none;\ +outline: none;\ +overflow: hidden;\ +font: inherit;\ +padding: 0 1px;\ +margin: 0 -1px;\ +text-indent: -1em;\ +-ms-user-select: text;\ +-moz-user-select: text;\ +-webkit-user-select: text;\ +user-select: text;\ +}\ +.ace_text-input.ace_composition {\ +background: inherit;\ +color: inherit;\ +z-index: 1000;\ +opacity: 1;\ +text-indent: 0;\ +}\ +.ace_layer {\ +z-index: 1;\ +position: absolute;\ +overflow: hidden;\ +white-space: pre;\ +height: 100%;\ +width: 100%;\ +-moz-box-sizing: border-box;\ +-webkit-box-sizing: border-box;\ +box-sizing: border-box;\ +pointer-events: none;\ +}\ +.ace_gutter-layer {\ +position: relative;\ +width: auto;\ +text-align: right;\ +pointer-events: auto;\ +}\ +.ace_text-layer {\ +font: inherit !important;\ +}\ +.ace_cjk {\ +display: inline-block;\ +text-align: center;\ +}\ +.ace_cursor-layer {\ +z-index: 4;\ +}\ +.ace_cursor {\ +z-index: 4;\ +position: absolute;\ +-moz-box-sizing: border-box;\ +-webkit-box-sizing: border-box;\ +box-sizing: border-box;\ +border-left: 2px solid\ +}\ +.ace_slim-cursors .ace_cursor {\ +border-left-width: 1px;\ +}\ +.ace_overwrite-cursors .ace_cursor {\ +border-left-width: 0;\ +border-bottom: 1px solid;\ +}\ +.ace_hidden-cursors .ace_cursor {\ +opacity: 0.2;\ +}\ +.ace_smooth-blinking .ace_cursor {\ +-webkit-transition: opacity 0.18s;\ +transition: opacity 0.18s;\ +}\ +.ace_editor.ace_multiselect .ace_cursor {\ +border-left-width: 1px;\ +}\ +.ace_marker-layer .ace_step, .ace_marker-layer .ace_stack {\ +position: absolute;\ +z-index: 3;\ +}\ +.ace_marker-layer .ace_selection {\ +position: absolute;\ +z-index: 5;\ +}\ +.ace_marker-layer .ace_bracket {\ +position: absolute;\ +z-index: 6;\ +}\ +.ace_marker-layer .ace_active-line {\ +position: absolute;\ +z-index: 2;\ +}\ +.ace_marker-layer .ace_selected-word {\ +position: absolute;\ +z-index: 4;\ +-moz-box-sizing: border-box;\ +-webkit-box-sizing: border-box;\ +box-sizing: border-box;\ +}\ +.ace_line .ace_fold {\ +-moz-box-sizing: border-box;\ +-webkit-box-sizing: border-box;\ +box-sizing: border-box;\ +display: inline-block;\ +height: 11px;\ +margin-top: -2px;\ +vertical-align: middle;\ +background-image:\ +url(\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABEAAAAJCAYAAADU6McMAAAAGXRFWHRTb2Z0d2FyZQBBZG9iZSBJbWFnZVJlYWR5ccllPAAAAJpJREFUeNpi/P//PwOlgAXGYGRklAVSokD8GmjwY1wasKljQpYACtpCFeADcHVQfQyMQAwzwAZI3wJKvCLkfKBaMSClBlR7BOQikCFGQEErIH0VqkabiGCAqwUadAzZJRxQr/0gwiXIal8zQQPnNVTgJ1TdawL0T5gBIP1MUJNhBv2HKoQHHjqNrA4WO4zY0glyNKLT2KIfIMAAQsdgGiXvgnYAAAAASUVORK5CYII=\"),\ +url(\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAA3CAYAAADNNiA5AAAAGXRFWHRTb2Z0d2FyZQBBZG9iZSBJbWFnZVJlYWR5ccllPAAAACJJREFUeNpi+P//fxgTAwPDBxDxD078RSX+YeEyDFMCIMAAI3INmXiwf2YAAAAASUVORK5CYII=\");\ +background-repeat: no-repeat, repeat-x;\ +background-position: center center, top left;\ +color: transparent;\ +border: 1px solid black;\ +border-radius: 2px;\ +cursor: pointer;\ +pointer-events: auto;\ +}\ +.ace_dark .ace_fold {\ +}\ +.ace_fold:hover{\ +background-image:\ +url(\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABEAAAAJCAYAAADU6McMAAAAGXRFWHRTb2Z0d2FyZQBBZG9iZSBJbWFnZVJlYWR5ccllPAAAAJpJREFUeNpi/P//PwOlgAXGYGRklAVSokD8GmjwY1wasKljQpYACtpCFeADcHVQfQyMQAwzwAZI3wJKvCLkfKBaMSClBlR7BOQikCFGQEErIH0VqkabiGCAqwUadAzZJRxQr/0gwiXIal8zQQPnNVTgJ1TdawL0T5gBIP1MUJNhBv2HKoQHHjqNrA4WO4zY0glyNKLT2KIfIMAAQsdgGiXvgnYAAAAASUVORK5CYII=\"),\ +url(\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAA3CAYAAADNNiA5AAAAGXRFWHRTb2Z0d2FyZQBBZG9iZSBJbWFnZVJlYWR5ccllPAAAACBJREFUeNpi+P//fz4TAwPDZxDxD5X4i5fLMEwJgAADAEPVDbjNw87ZAAAAAElFTkSuQmCC\");\ +}\ +.ace_tooltip {\ +background-color: #FFF;\ +background-image: -webkit-linear-gradient(top, transparent, rgba(0, 0, 0, 0.1));\ +background-image: linear-gradient(to bottom, transparent, rgba(0, 0, 0, 0.1));\ +border: 1px solid gray;\ +border-radius: 1px;\ +box-shadow: 0 1px 2px rgba(0, 0, 0, 0.3);\ +color: black;\ +max-width: 100%;\ +padding: 3px 4px;\ +position: fixed;\ +z-index: 999999;\ +-moz-box-sizing: border-box;\ +-webkit-box-sizing: border-box;\ +box-sizing: border-box;\ +cursor: default;\ +white-space: pre;\ +word-wrap: break-word;\ +line-height: normal;\ +font-style: normal;\ +font-weight: normal;\ +letter-spacing: normal;\ +pointer-events: none;\ +}\ +.ace_folding-enabled > .ace_gutter-cell {\ +padding-right: 13px;\ +}\ +.ace_fold-widget {\ +-moz-box-sizing: border-box;\ +-webkit-box-sizing: border-box;\ +box-sizing: border-box;\ +margin: 0 -12px 0 1px;\ +display: none;\ +width: 11px;\ +vertical-align: top;\ +background-image: url(\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAUAAAAFCAYAAACNbyblAAAANElEQVR42mWKsQ0AMAzC8ixLlrzQjzmBiEjp0A6WwBCSPgKAXoLkqSot7nN3yMwR7pZ32NzpKkVoDBUxKAAAAABJRU5ErkJggg==\");\ +background-repeat: no-repeat;\ +background-position: center;\ +border-radius: 3px;\ +border: 1px solid transparent;\ +cursor: pointer;\ +}\ +.ace_folding-enabled .ace_fold-widget {\ +display: inline-block; \ +}\ +.ace_fold-widget.ace_end {\ +background-image: url(\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAUAAAAFCAYAAACNbyblAAAANElEQVR42m3HwQkAMAhD0YzsRchFKI7sAikeWkrxwScEB0nh5e7KTPWimZki4tYfVbX+MNl4pyZXejUO1QAAAABJRU5ErkJggg==\");\ +}\ +.ace_fold-widget.ace_closed {\ +background-image: url(\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAMAAAAGCAYAAAAG5SQMAAAAOUlEQVR42jXKwQkAMAgDwKwqKD4EwQ26sSOkVWjgIIHAzPiCgaqiqnJHZnKICBERHN194O5b9vbLuAVRL+l0YWnZAAAAAElFTkSuQmCCXA==\");\ +}\ +.ace_fold-widget:hover {\ +border: 1px solid rgba(0, 0, 0, 0.3);\ +background-color: rgba(255, 255, 255, 0.2);\ +box-shadow: 0 1px 1px rgba(255, 255, 255, 0.7);\ +}\ +.ace_fold-widget:active {\ +border: 1px solid rgba(0, 0, 0, 0.4);\ +background-color: rgba(0, 0, 0, 0.05);\ +box-shadow: 0 1px 1px rgba(255, 255, 255, 0.8);\ +}\ +.ace_dark .ace_fold-widget {\ +background-image: url(\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAUAAAAFCAYAAACNbyblAAAAHklEQVQIW2P4//8/AzoGEQ7oGCaLLAhWiSwB146BAQCSTPYocqT0AAAAAElFTkSuQmCC\");\ +}\ +.ace_dark .ace_fold-widget.ace_end {\ +background-image: url(\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAUAAAAFCAYAAACNbyblAAAAH0lEQVQIW2P4//8/AxQ7wNjIAjDMgC4AxjCVKBirIAAF0kz2rlhxpAAAAABJRU5ErkJggg==\");\ +}\ +.ace_dark .ace_fold-widget.ace_closed {\ +background-image: url(\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAMAAAAFCAYAAACAcVaiAAAAHElEQVQIW2P4//+/AxAzgDADlOOAznHAKgPWAwARji8UIDTfQQAAAABJRU5ErkJggg==\");\ +}\ +.ace_dark .ace_fold-widget:hover {\ +box-shadow: 0 1px 1px rgba(255, 255, 255, 0.2);\ +background-color: rgba(255, 255, 255, 0.1);\ +}\ +.ace_dark .ace_fold-widget:active {\ +box-shadow: 0 1px 1px rgba(255, 255, 255, 0.2);\ +}\ +.ace_fold-widget.ace_invalid {\ +background-color: #FFB4B4;\ +border-color: #DE5555;\ +}\ +.ace_fade-fold-widgets .ace_fold-widget {\ +-webkit-transition: opacity 0.4s ease 0.05s;\ +transition: opacity 0.4s ease 0.05s;\ +opacity: 0;\ +}\ +.ace_fade-fold-widgets:hover .ace_fold-widget {\ +-webkit-transition: opacity 0.05s ease 0.05s;\ +transition: opacity 0.05s ease 0.05s;\ +opacity:1;\ +}\ +.ace_underline {\ +text-decoration: underline;\ +}\ +.ace_bold {\ +font-weight: bold;\ +}\ +.ace_nobold .ace_bold {\ +font-weight: normal;\ +}\ +.ace_italic {\ +font-style: italic;\ +}\ +.ace_error-marker {\ +background-color: rgba(255, 0, 0,0.2);\ +position: absolute;\ +z-index: 9;\ +}\ +.ace_highlight-marker {\ +background-color: rgba(255, 255, 0,0.2);\ +position: absolute;\ +z-index: 8;\ +}\ +"; + +dom.importCssString(editorCss, "ace_editor"); + +var VirtualRenderer = function(container, theme) { + var _self = this; + + this.container = container || dom.createElement("div"); + this.$keepTextAreaAtCursor = !useragent.isOldIE; + + dom.addCssClass(this.container, "ace_editor"); + + this.setTheme(theme); + + this.$gutter = dom.createElement("div"); + this.$gutter.className = "ace_gutter"; + this.container.appendChild(this.$gutter); + + this.scroller = dom.createElement("div"); + this.scroller.className = "ace_scroller"; + this.container.appendChild(this.scroller); + + this.content = dom.createElement("div"); + this.content.className = "ace_content"; + this.scroller.appendChild(this.content); + + this.$gutterLayer = new GutterLayer(this.$gutter); + this.$gutterLayer.on("changeGutterWidth", this.onGutterResize.bind(this)); + + this.$markerBack = new MarkerLayer(this.content); + + var textLayer = this.$textLayer = new TextLayer(this.content); + this.canvas = textLayer.element; + + this.$markerFront = new MarkerLayer(this.content); + + this.$cursorLayer = new CursorLayer(this.content); + this.$horizScroll = false; + this.$vScroll = false; + + this.scrollBar = + this.scrollBarV = new VScrollBar(this.container, this); + this.scrollBarH = new HScrollBar(this.container, this); + this.scrollBarV.addEventListener("scroll", function(e) { + if (!_self.$scrollAnimation) + _self.session.setScrollTop(e.data - _self.scrollMargin.top); + }); + this.scrollBarH.addEventListener("scroll", function(e) { + if (!_self.$scrollAnimation) + _self.session.setScrollLeft(e.data - _self.scrollMargin.left); + }); + + this.scrollTop = 0; + this.scrollLeft = 0; + + this.cursorPos = { + row : 0, + column : 0 + }; + + this.$fontMetrics = new FontMetrics(this.container, 500); + this.$textLayer.$setFontMetrics(this.$fontMetrics); + this.$textLayer.addEventListener("changeCharacterSize", function(e) { + _self.updateCharacterSize(); + _self.onResize(true, _self.gutterWidth, _self.$size.width, _self.$size.height); + _self._signal("changeCharacterSize", e); + }); + + this.$size = { + width: 0, + height: 0, + scrollerHeight: 0, + scrollerWidth: 0, + $dirty: true + }; + + this.layerConfig = { + width : 1, + padding : 0, + firstRow : 0, + firstRowScreen: 0, + lastRow : 0, + lineHeight : 0, + characterWidth : 0, + minHeight : 1, + maxHeight : 1, + offset : 0, + height : 1, + gutterOffset: 1 + }; + + this.scrollMargin = { + left: 0, + right: 0, + top: 0, + bottom: 0, + v: 0, + h: 0 + }; + + this.$loop = new RenderLoop( + this.$renderChanges.bind(this), + this.container.ownerDocument.defaultView + ); + this.$loop.schedule(this.CHANGE_FULL); + + this.updateCharacterSize(); + this.setPadding(4); + config.resetOptions(this); + config._emit("renderer", this); +}; + +(function() { + + this.CHANGE_CURSOR = 1; + this.CHANGE_MARKER = 2; + this.CHANGE_GUTTER = 4; + this.CHANGE_SCROLL = 8; + this.CHANGE_LINES = 16; + this.CHANGE_TEXT = 32; + this.CHANGE_SIZE = 64; + this.CHANGE_MARKER_BACK = 128; + this.CHANGE_MARKER_FRONT = 256; + this.CHANGE_FULL = 512; + this.CHANGE_H_SCROLL = 1024; + + oop.implement(this, EventEmitter); + + this.updateCharacterSize = function() { + if (this.$textLayer.allowBoldFonts != this.$allowBoldFonts) { + this.$allowBoldFonts = this.$textLayer.allowBoldFonts; + this.setStyle("ace_nobold", !this.$allowBoldFonts); + } + + this.layerConfig.characterWidth = + this.characterWidth = this.$textLayer.getCharacterWidth(); + this.layerConfig.lineHeight = + this.lineHeight = this.$textLayer.getLineHeight(); + this.$updatePrintMargin(); + }; + this.setSession = function(session) { + if (this.session) + this.session.doc.off("changeNewLineMode", this.onChangeNewLineMode); + + this.session = session; + if (session && this.scrollMargin.top && session.getScrollTop() <= 0) + session.setScrollTop(-this.scrollMargin.top); + + this.$cursorLayer.setSession(session); + this.$markerBack.setSession(session); + this.$markerFront.setSession(session); + this.$gutterLayer.setSession(session); + this.$textLayer.setSession(session); + if (!session) + return; + + this.$loop.schedule(this.CHANGE_FULL); + this.session.$setFontMetrics(this.$fontMetrics); + + this.onChangeNewLineMode = this.onChangeNewLineMode.bind(this); + this.onChangeNewLineMode() + this.session.doc.on("changeNewLineMode", this.onChangeNewLineMode); + }; + this.updateLines = function(firstRow, lastRow, force) { + if (lastRow === undefined) + lastRow = Infinity; + + if (!this.$changedLines) { + this.$changedLines = { + firstRow: firstRow, + lastRow: lastRow + }; + } + else { + if (this.$changedLines.firstRow > firstRow) + this.$changedLines.firstRow = firstRow; + + if (this.$changedLines.lastRow < lastRow) + this.$changedLines.lastRow = lastRow; + } + if (this.$changedLines.lastRow < this.layerConfig.firstRow) { + if (force) + this.$changedLines.lastRow = this.layerConfig.lastRow; + else + return; + } + if (this.$changedLines.firstRow > this.layerConfig.lastRow) + return; + this.$loop.schedule(this.CHANGE_LINES); + }; + + this.onChangeNewLineMode = function() { + this.$loop.schedule(this.CHANGE_TEXT); + this.$textLayer.$updateEolChar(); + }; + + this.onChangeTabSize = function() { + this.$loop.schedule(this.CHANGE_TEXT | this.CHANGE_MARKER); + this.$textLayer.onChangeTabSize(); + }; + this.updateText = function() { + this.$loop.schedule(this.CHANGE_TEXT); + }; + this.updateFull = function(force) { + if (force) + this.$renderChanges(this.CHANGE_FULL, true); + else + this.$loop.schedule(this.CHANGE_FULL); + }; + this.updateFontSize = function() { + this.$textLayer.checkForSizeChanges(); + }; + + this.$changes = 0; + this.$updateSizeAsync = function() { + if (this.$loop.pending) + this.$size.$dirty = true; + else + this.onResize(); + }; + this.onResize = function(force, gutterWidth, width, height) { + if (this.resizing > 2) + return; + else if (this.resizing > 0) + this.resizing++; + else + this.resizing = force ? 1 : 0; + var el = this.container; + if (!height) + height = el.clientHeight || el.scrollHeight; + if (!width) + width = el.clientWidth || el.scrollWidth; + var changes = this.$updateCachedSize(force, gutterWidth, width, height); + + + if (!this.$size.scrollerHeight || (!width && !height)) + return this.resizing = 0; + + if (force) + this.$gutterLayer.$padding = null; + + if (force) + this.$renderChanges(changes | this.$changes, true); + else + this.$loop.schedule(changes | this.$changes); + + if (this.resizing) + this.resizing = 0; + this.scrollBarV.scrollLeft = this.scrollBarV.scrollTop = null; + }; + + this.$updateCachedSize = function(force, gutterWidth, width, height) { + height -= (this.$extraHeight || 0); + var changes = 0; + var size = this.$size; + var oldSize = { + width: size.width, + height: size.height, + scrollerHeight: size.scrollerHeight, + scrollerWidth: size.scrollerWidth + }; + if (height && (force || size.height != height)) { + size.height = height; + changes |= this.CHANGE_SIZE; + + size.scrollerHeight = size.height; + if (this.$horizScroll) + size.scrollerHeight -= this.scrollBarH.getHeight(); + this.scrollBarV.element.style.bottom = this.scrollBarH.getHeight() + "px"; + + changes = changes | this.CHANGE_SCROLL; + } + + if (width && (force || size.width != width)) { + changes |= this.CHANGE_SIZE; + size.width = width; + + if (gutterWidth == null) + gutterWidth = this.$showGutter ? this.$gutter.offsetWidth : 0; + + this.gutterWidth = gutterWidth; + + this.scrollBarH.element.style.left = + this.scroller.style.left = gutterWidth + "px"; + size.scrollerWidth = Math.max(0, width - gutterWidth - this.scrollBarV.getWidth()); + + this.scrollBarH.element.style.right = + this.scroller.style.right = this.scrollBarV.getWidth() + "px"; + this.scroller.style.bottom = this.scrollBarH.getHeight() + "px"; + + if (this.session && this.session.getUseWrapMode() && this.adjustWrapLimit() || force) + changes |= this.CHANGE_FULL; + } + + size.$dirty = !width || !height; + + if (changes) + this._signal("resize", oldSize); + + return changes; + }; + + this.onGutterResize = function() { + var gutterWidth = this.$showGutter ? this.$gutter.offsetWidth : 0; + if (gutterWidth != this.gutterWidth) + this.$changes |= this.$updateCachedSize(true, gutterWidth, this.$size.width, this.$size.height); + + if (this.session.getUseWrapMode() && this.adjustWrapLimit()) { + this.$loop.schedule(this.CHANGE_FULL); + } else if (this.$size.$dirty) { + this.$loop.schedule(this.CHANGE_FULL); + } else { + this.$computeLayerConfig(); + this.$loop.schedule(this.CHANGE_MARKER); + } + }; + this.adjustWrapLimit = function() { + var availableWidth = this.$size.scrollerWidth - this.$padding * 2; + var limit = Math.floor(availableWidth / this.characterWidth); + return this.session.adjustWrapLimit(limit, this.$showPrintMargin && this.$printMarginColumn); + }; + this.setAnimatedScroll = function(shouldAnimate){ + this.setOption("animatedScroll", shouldAnimate); + }; + this.getAnimatedScroll = function() { + return this.$animatedScroll; + }; + this.setShowInvisibles = function(showInvisibles) { + this.setOption("showInvisibles", showInvisibles); + }; + this.getShowInvisibles = function() { + return this.getOption("showInvisibles"); + }; + this.getDisplayIndentGuides = function() { + return this.getOption("displayIndentGuides"); + }; + + this.setDisplayIndentGuides = function(display) { + this.setOption("displayIndentGuides", display); + }; + this.setShowPrintMargin = function(showPrintMargin) { + this.setOption("showPrintMargin", showPrintMargin); + }; + this.getShowPrintMargin = function() { + return this.getOption("showPrintMargin"); + }; + this.setPrintMarginColumn = function(showPrintMargin) { + this.setOption("printMarginColumn", showPrintMargin); + }; + this.getPrintMarginColumn = function() { + return this.getOption("printMarginColumn"); + }; + this.getShowGutter = function(){ + return this.getOption("showGutter"); + }; + this.setShowGutter = function(show){ + return this.setOption("showGutter", show); + }; + + this.getFadeFoldWidgets = function(){ + return this.getOption("fadeFoldWidgets") + }; + + this.setFadeFoldWidgets = function(show) { + this.setOption("fadeFoldWidgets", show); + }; + + this.setHighlightGutterLine = function(shouldHighlight) { + this.setOption("highlightGutterLine", shouldHighlight); + }; + + this.getHighlightGutterLine = function() { + return this.getOption("highlightGutterLine"); + }; + + this.$updateGutterLineHighlight = function() { + var pos = this.$cursorLayer.$pixelPos; + var height = this.layerConfig.lineHeight; + if (this.session.getUseWrapMode()) { + var cursor = this.session.selection.getCursor(); + cursor.column = 0; + pos = this.$cursorLayer.getPixelPosition(cursor, true); + height *= this.session.getRowLength(cursor.row); + } + this.$gutterLineHighlight.style.top = pos.top - this.layerConfig.offset + "px"; + this.$gutterLineHighlight.style.height = height + "px"; + }; + + this.$updatePrintMargin = function() { + if (!this.$showPrintMargin && !this.$printMarginEl) + return; + + if (!this.$printMarginEl) { + var containerEl = dom.createElement("div"); + containerEl.className = "ace_layer ace_print-margin-layer"; + this.$printMarginEl = dom.createElement("div"); + this.$printMarginEl.className = "ace_print-margin"; + containerEl.appendChild(this.$printMarginEl); + this.content.insertBefore(containerEl, this.content.firstChild); + } + + var style = this.$printMarginEl.style; + style.left = ((this.characterWidth * this.$printMarginColumn) + this.$padding) + "px"; + style.visibility = this.$showPrintMargin ? "visible" : "hidden"; + + if (this.session && this.session.$wrap == -1) + this.adjustWrapLimit(); + }; + this.getContainerElement = function() { + return this.container; + }; + this.getMouseEventTarget = function() { + return this.content; + }; + this.getTextAreaContainer = function() { + return this.container; + }; + this.$moveTextAreaToCursor = function() { + if (!this.$keepTextAreaAtCursor) + return; + var config = this.layerConfig; + var posTop = this.$cursorLayer.$pixelPos.top; + var posLeft = this.$cursorLayer.$pixelPos.left; + posTop -= config.offset; + + var style = this.textarea.style; + var h = this.lineHeight; + if (posTop < 0 || posTop > config.height - h) { + style.top = style.left = "0"; + return; + } + + var w = this.characterWidth; + if (this.$composition) { + var val = this.textarea.value.replace(/^\x01+/, ""); + w *= (this.session.$getStringScreenWidth(val)[0]+2); + h += 2; + } + posLeft -= this.scrollLeft; + if (posLeft > this.$size.scrollerWidth - w) + posLeft = this.$size.scrollerWidth - w; + + posLeft += this.gutterWidth; + style.height = h + "px"; + style.width = w + "px"; + style.left = Math.min(posLeft, this.$size.scrollerWidth - w) + "px"; + style.top = Math.min(posTop, this.$size.height - h) + "px"; + }; + this.getFirstVisibleRow = function() { + return this.layerConfig.firstRow; + }; + this.getFirstFullyVisibleRow = function() { + return this.layerConfig.firstRow + (this.layerConfig.offset === 0 ? 0 : 1); + }; + this.getLastFullyVisibleRow = function() { + var flint = Math.floor((this.layerConfig.height + this.layerConfig.offset) / this.layerConfig.lineHeight); + return this.layerConfig.firstRow - 1 + flint; + }; + this.getLastVisibleRow = function() { + return this.layerConfig.lastRow; + }; + + this.$padding = null; + this.setPadding = function(padding) { + this.$padding = padding; + this.$textLayer.setPadding(padding); + this.$cursorLayer.setPadding(padding); + this.$markerFront.setPadding(padding); + this.$markerBack.setPadding(padding); + this.$loop.schedule(this.CHANGE_FULL); + this.$updatePrintMargin(); + }; + + this.setScrollMargin = function(top, bottom, left, right) { + var sm = this.scrollMargin; + sm.top = top|0; + sm.bottom = bottom|0; + sm.right = right|0; + sm.left = left|0; + sm.v = sm.top + sm.bottom; + sm.h = sm.left + sm.right; + if (sm.top && this.scrollTop <= 0 && this.session) + this.session.setScrollTop(-sm.top); + this.updateFull(); + }; + this.getHScrollBarAlwaysVisible = function() { + return this.$hScrollBarAlwaysVisible; + }; + this.setHScrollBarAlwaysVisible = function(alwaysVisible) { + this.setOption("hScrollBarAlwaysVisible", alwaysVisible); + }; + this.getVScrollBarAlwaysVisible = function() { + return this.$hScrollBarAlwaysVisible; + }; + this.setVScrollBarAlwaysVisible = function(alwaysVisible) { + this.setOption("vScrollBarAlwaysVisible", alwaysVisible); + }; + + this.$updateScrollBarV = function() { + var scrollHeight = this.layerConfig.maxHeight; + var scrollerHeight = this.$size.scrollerHeight; + if (!this.$maxLines && this.$scrollPastEnd) { + scrollHeight -= (scrollerHeight - this.lineHeight) * this.$scrollPastEnd; + if (this.scrollTop > scrollHeight - scrollerHeight) { + scrollHeight = this.scrollTop + scrollerHeight; + this.scrollBarV.scrollTop = null; + } + } + this.scrollBarV.setScrollHeight(scrollHeight + this.scrollMargin.v); + this.scrollBarV.setScrollTop(this.scrollTop + this.scrollMargin.top); + }; + this.$updateScrollBarH = function() { + this.scrollBarH.setScrollWidth(this.layerConfig.width + 2 * this.$padding + this.scrollMargin.h); + this.scrollBarH.setScrollLeft(this.scrollLeft + this.scrollMargin.left); + }; + + this.$frozen = false; + this.freeze = function() { + this.$frozen = true; + }; + + this.unfreeze = function() { + this.$frozen = false; + }; + + this.$renderChanges = function(changes, force) { + if (this.$changes) { + changes |= this.$changes; + this.$changes = 0; + } + if ((!this.session || !this.container.offsetWidth || this.$frozen) || (!changes && !force)) { + this.$changes |= changes; + return; + } + if (this.$size.$dirty) { + this.$changes |= changes; + return this.onResize(true); + } + if (!this.lineHeight) { + this.$textLayer.checkForSizeChanges(); + } + + this._signal("beforeRender"); + var config = this.layerConfig; + if (changes & this.CHANGE_FULL || + changes & this.CHANGE_SIZE || + changes & this.CHANGE_TEXT || + changes & this.CHANGE_LINES || + changes & this.CHANGE_SCROLL || + changes & this.CHANGE_H_SCROLL + ) { + changes |= this.$computeLayerConfig(); + if (config.firstRow != this.layerConfig.firstRow && config.firstRowScreen == this.layerConfig.firstRowScreen) { + var st = this.scrollTop + (config.firstRow - this.layerConfig.firstRow) * this.lineHeight; + if (st > 0) { + this.scrollTop = st; + changes = changes | this.CHANGE_SCROLL; + changes |= this.$computeLayerConfig(); + } + } + config = this.layerConfig; + this.$updateScrollBarV(); + if (changes & this.CHANGE_H_SCROLL) + this.$updateScrollBarH(); + this.$gutterLayer.element.style.marginTop = (-config.offset) + "px"; + this.content.style.marginTop = (-config.offset) + "px"; + this.content.style.width = config.width + 2 * this.$padding + "px"; + this.content.style.height = config.minHeight + "px"; + } + if (changes & this.CHANGE_H_SCROLL) { + this.content.style.marginLeft = -this.scrollLeft + "px"; + this.scroller.className = this.scrollLeft <= 0 ? "ace_scroller" : "ace_scroller ace_scroll-left"; + } + if (changes & this.CHANGE_FULL) { + this.$textLayer.update(config); + if (this.$showGutter) + this.$gutterLayer.update(config); + this.$markerBack.update(config); + this.$markerFront.update(config); + this.$cursorLayer.update(config); + this.$moveTextAreaToCursor(); + this.$highlightGutterLine && this.$updateGutterLineHighlight(); + this._signal("afterRender"); + return; + } + if (changes & this.CHANGE_SCROLL) { + if (changes & this.CHANGE_TEXT || changes & this.CHANGE_LINES) + this.$textLayer.update(config); + else + this.$textLayer.scrollLines(config); + + if (this.$showGutter) + this.$gutterLayer.update(config); + this.$markerBack.update(config); + this.$markerFront.update(config); + this.$cursorLayer.update(config); + this.$highlightGutterLine && this.$updateGutterLineHighlight(); + this.$moveTextAreaToCursor(); + this._signal("afterRender"); + return; + } + + if (changes & this.CHANGE_TEXT) { + this.$textLayer.update(config); + if (this.$showGutter) + this.$gutterLayer.update(config); + } + else if (changes & this.CHANGE_LINES) { + if (this.$updateLines() || (changes & this.CHANGE_GUTTER) && this.$showGutter) + this.$gutterLayer.update(config); + } + else if (changes & this.CHANGE_TEXT || changes & this.CHANGE_GUTTER) { + if (this.$showGutter) + this.$gutterLayer.update(config); + } + + if (changes & this.CHANGE_CURSOR) { + this.$cursorLayer.update(config); + this.$moveTextAreaToCursor(); + this.$highlightGutterLine && this.$updateGutterLineHighlight(); + } + + if (changes & (this.CHANGE_MARKER | this.CHANGE_MARKER_FRONT)) { + this.$markerFront.update(config); + } + + if (changes & (this.CHANGE_MARKER | this.CHANGE_MARKER_BACK)) { + this.$markerBack.update(config); + } + + this._signal("afterRender"); + }; + + + this.$autosize = function() { + var height = this.session.getScreenLength() * this.lineHeight; + var maxHeight = this.$maxLines * this.lineHeight; + var desiredHeight = Math.max( + (this.$minLines||1) * this.lineHeight, + Math.min(maxHeight, height) + ) + this.scrollMargin.v + (this.$extraHeight || 0); + var vScroll = height > maxHeight; + + if (desiredHeight != this.desiredHeight || + this.$size.height != this.desiredHeight || vScroll != this.$vScroll) { + if (vScroll != this.$vScroll) { + this.$vScroll = vScroll; + this.scrollBarV.setVisible(vScroll); + } + + var w = this.container.clientWidth; + this.container.style.height = desiredHeight + "px"; + this.$updateCachedSize(true, this.$gutterWidth, w, desiredHeight); + this.desiredHeight = desiredHeight; + + this._signal("autosize"); + } + }; + + this.$computeLayerConfig = function() { + if (this.$maxLines && this.lineHeight > 1) + this.$autosize(); + + var session = this.session; + var size = this.$size; + + var hideScrollbars = size.height <= 2 * this.lineHeight; + var screenLines = this.session.getScreenLength(); + var maxHeight = screenLines * this.lineHeight; + + var offset = this.scrollTop % this.lineHeight; + var minHeight = size.scrollerHeight + this.lineHeight; + + var longestLine = this.$getLongestLine(); + + var horizScroll = !hideScrollbars && (this.$hScrollBarAlwaysVisible || + size.scrollerWidth - longestLine - 2 * this.$padding < 0); + + var hScrollChanged = this.$horizScroll !== horizScroll; + if (hScrollChanged) { + this.$horizScroll = horizScroll; + this.scrollBarH.setVisible(horizScroll); + } + + var scrollPastEnd = !this.$maxLines && this.$scrollPastEnd + ? (size.scrollerHeight - this.lineHeight) * this.$scrollPastEnd + : 0; + maxHeight += scrollPastEnd; + + this.session.setScrollTop(Math.max(-this.scrollMargin.top, + Math.min(this.scrollTop, maxHeight - size.scrollerHeight + this.scrollMargin.bottom))); + + this.session.setScrollLeft(Math.max(-this.scrollMargin.left, Math.min(this.scrollLeft, + longestLine + 2 * this.$padding - size.scrollerWidth + this.scrollMargin.right))); + + var vScroll = !hideScrollbars && (this.$vScrollBarAlwaysVisible || + size.scrollerHeight - maxHeight + scrollPastEnd < 0 || this.scrollTop); + var vScrollChanged = this.$vScroll !== vScroll; + if (vScrollChanged) { + this.$vScroll = vScroll; + this.scrollBarV.setVisible(vScroll); + } + + var lineCount = Math.ceil(minHeight / this.lineHeight) - 1; + var firstRow = Math.max(0, Math.round((this.scrollTop - offset) / this.lineHeight)); + var lastRow = firstRow + lineCount; + var firstRowScreen, firstRowHeight; + var lineHeight = this.lineHeight; + firstRow = session.screenToDocumentRow(firstRow, 0); + var foldLine = session.getFoldLine(firstRow); + if (foldLine) { + firstRow = foldLine.start.row; + } + + firstRowScreen = session.documentToScreenRow(firstRow, 0); + firstRowHeight = session.getRowLength(firstRow) * lineHeight; + + lastRow = Math.min(session.screenToDocumentRow(lastRow, 0), session.getLength() - 1); + minHeight = size.scrollerHeight + session.getRowLength(lastRow) * lineHeight + + firstRowHeight; + + offset = this.scrollTop - firstRowScreen * lineHeight; + + var changes = 0; + if (this.layerConfig.width != longestLine) + changes = this.CHANGE_H_SCROLL; + if (hScrollChanged || vScrollChanged) { + changes = this.$updateCachedSize(true, this.gutterWidth, size.width, size.height); + this._signal("scrollbarVisibilityChanged"); + if (vScrollChanged) + longestLine = this.$getLongestLine(); + } + + this.layerConfig = { + width : longestLine, + padding : this.$padding, + firstRow : firstRow, + firstRowScreen: firstRowScreen, + lastRow : lastRow, + lineHeight : lineHeight, + characterWidth : this.characterWidth, + minHeight : minHeight, + maxHeight : maxHeight, + offset : offset, + gutterOffset : Math.max(0, Math.ceil((offset + size.height - size.scrollerHeight) / lineHeight)), + height : this.$size.scrollerHeight + }; + + return changes; + }; + + this.$updateLines = function() { + var firstRow = this.$changedLines.firstRow; + var lastRow = this.$changedLines.lastRow; + this.$changedLines = null; + + var layerConfig = this.layerConfig; + + if (firstRow > layerConfig.lastRow + 1) { return; } + if (lastRow < layerConfig.firstRow) { return; } + if (lastRow === Infinity) { + if (this.$showGutter) + this.$gutterLayer.update(layerConfig); + this.$textLayer.update(layerConfig); + return; + } + this.$textLayer.updateLines(layerConfig, firstRow, lastRow); + return true; + }; + + this.$getLongestLine = function() { + var charCount = this.session.getScreenWidth(); + if (this.showInvisibles && !this.session.$useWrapMode) + charCount += 1; + + return Math.max(this.$size.scrollerWidth - 2 * this.$padding, Math.round(charCount * this.characterWidth)); + }; + this.updateFrontMarkers = function() { + this.$markerFront.setMarkers(this.session.getMarkers(true)); + this.$loop.schedule(this.CHANGE_MARKER_FRONT); + }; + this.updateBackMarkers = function() { + this.$markerBack.setMarkers(this.session.getMarkers()); + this.$loop.schedule(this.CHANGE_MARKER_BACK); + }; + this.addGutterDecoration = function(row, className){ + this.$gutterLayer.addGutterDecoration(row, className); + }; + this.removeGutterDecoration = function(row, className){ + this.$gutterLayer.removeGutterDecoration(row, className); + }; + this.updateBreakpoints = function(rows) { + this.$loop.schedule(this.CHANGE_GUTTER); + }; + this.setAnnotations = function(annotations) { + this.$gutterLayer.setAnnotations(annotations); + this.$loop.schedule(this.CHANGE_GUTTER); + }; + this.updateCursor = function() { + this.$loop.schedule(this.CHANGE_CURSOR); + }; + this.hideCursor = function() { + this.$cursorLayer.hideCursor(); + }; + this.showCursor = function() { + this.$cursorLayer.showCursor(); + }; + + this.scrollSelectionIntoView = function(anchor, lead, offset) { + this.scrollCursorIntoView(anchor, offset); + this.scrollCursorIntoView(lead, offset); + }; + this.scrollCursorIntoView = function(cursor, offset, $viewMargin) { + if (this.$size.scrollerHeight === 0) + return; + + var pos = this.$cursorLayer.getPixelPosition(cursor); + + var left = pos.left; + var top = pos.top; + + var topMargin = $viewMargin && $viewMargin.top || 0; + var bottomMargin = $viewMargin && $viewMargin.bottom || 0; + + var scrollTop = this.$scrollAnimation ? this.session.getScrollTop() : this.scrollTop; + + if (scrollTop + topMargin > top) { + if (offset) + top -= offset * this.$size.scrollerHeight; + if (top === 0) + top = -this.scrollMargin.top; + this.session.setScrollTop(top); + } else if (scrollTop + this.$size.scrollerHeight - bottomMargin < top + this.lineHeight) { + if (offset) + top += offset * this.$size.scrollerHeight; + this.session.setScrollTop(top + this.lineHeight - this.$size.scrollerHeight); + } + + var scrollLeft = this.scrollLeft; + + if (scrollLeft > left) { + if (left < this.$padding + 2 * this.layerConfig.characterWidth) + left = -this.scrollMargin.left; + this.session.setScrollLeft(left); + } else if (scrollLeft + this.$size.scrollerWidth < left + this.characterWidth) { + this.session.setScrollLeft(Math.round(left + this.characterWidth - this.$size.scrollerWidth)); + } else if (scrollLeft <= this.$padding && left - scrollLeft < this.characterWidth) { + this.session.setScrollLeft(0); + } + }; + this.getScrollTop = function() { + return this.session.getScrollTop(); + }; + this.getScrollLeft = function() { + return this.session.getScrollLeft(); + }; + this.getScrollTopRow = function() { + return this.scrollTop / this.lineHeight; + }; + this.getScrollBottomRow = function() { + return Math.max(0, Math.floor((this.scrollTop + this.$size.scrollerHeight) / this.lineHeight) - 1); + }; + this.scrollToRow = function(row) { + this.session.setScrollTop(row * this.lineHeight); + }; + + this.alignCursor = function(cursor, alignment) { + if (typeof cursor == "number") + cursor = {row: cursor, column: 0}; + + var pos = this.$cursorLayer.getPixelPosition(cursor); + var h = this.$size.scrollerHeight - this.lineHeight; + var offset = pos.top - h * (alignment || 0); + + this.session.setScrollTop(offset); + return offset; + }; + + this.STEPS = 8; + this.$calcSteps = function(fromValue, toValue){ + var i = 0; + var l = this.STEPS; + var steps = []; + + var func = function(t, x_min, dx) { + return dx * (Math.pow(t - 1, 3) + 1) + x_min; + }; + + for (i = 0; i < l; ++i) + steps.push(func(i / this.STEPS, fromValue, toValue - fromValue)); + + return steps; + }; + this.scrollToLine = function(line, center, animate, callback) { + var pos = this.$cursorLayer.getPixelPosition({row: line, column: 0}); + var offset = pos.top; + if (center) + offset -= this.$size.scrollerHeight / 2; + + var initialScroll = this.scrollTop; + this.session.setScrollTop(offset); + if (animate !== false) + this.animateScrolling(initialScroll, callback); + }; + + this.animateScrolling = function(fromValue, callback) { + var toValue = this.scrollTop; + if (!this.$animatedScroll) + return; + var _self = this; + + if (fromValue == toValue) + return; + + if (this.$scrollAnimation) { + var oldSteps = this.$scrollAnimation.steps; + if (oldSteps.length) { + fromValue = oldSteps[0]; + if (fromValue == toValue) + return; + } + } + + var steps = _self.$calcSteps(fromValue, toValue); + this.$scrollAnimation = {from: fromValue, to: toValue, steps: steps}; + + clearInterval(this.$timer); + + _self.session.setScrollTop(steps.shift()); + _self.session.$scrollTop = toValue; + this.$timer = setInterval(function() { + if (steps.length) { + _self.session.setScrollTop(steps.shift()); + _self.session.$scrollTop = toValue; + } else if (toValue != null) { + _self.session.$scrollTop = -1; + _self.session.setScrollTop(toValue); + toValue = null; + } else { + _self.$timer = clearInterval(_self.$timer); + _self.$scrollAnimation = null; + callback && callback(); + } + }, 10); + }; + this.scrollToY = function(scrollTop) { + if (this.scrollTop !== scrollTop) { + this.$loop.schedule(this.CHANGE_SCROLL); + this.scrollTop = scrollTop; + } + }; + this.scrollToX = function(scrollLeft) { + if (this.scrollLeft !== scrollLeft) + this.scrollLeft = scrollLeft; + this.$loop.schedule(this.CHANGE_H_SCROLL); + }; + this.scrollTo = function(x, y) { + this.session.setScrollTop(y); + this.session.setScrollLeft(y); + }; + this.scrollBy = function(deltaX, deltaY) { + deltaY && this.session.setScrollTop(this.session.getScrollTop() + deltaY); + deltaX && this.session.setScrollLeft(this.session.getScrollLeft() + deltaX); + }; + this.isScrollableBy = function(deltaX, deltaY) { + if (deltaY < 0 && this.session.getScrollTop() >= 1 - this.scrollMargin.top) + return true; + if (deltaY > 0 && this.session.getScrollTop() + this.$size.scrollerHeight + - this.layerConfig.maxHeight < -1 + this.scrollMargin.bottom) + return true; + if (deltaX < 0 && this.session.getScrollLeft() >= 1 - this.scrollMargin.left) + return true; + if (deltaX > 0 && this.session.getScrollLeft() + this.$size.scrollerWidth + - this.layerConfig.width < -1 + this.scrollMargin.right) + return true; + }; + + this.pixelToScreenCoordinates = function(x, y) { + var canvasPos = this.scroller.getBoundingClientRect(); + + var offset = (x + this.scrollLeft - canvasPos.left - this.$padding) / this.characterWidth; + var row = Math.floor((y + this.scrollTop - canvasPos.top) / this.lineHeight); + var col = Math.round(offset); + + return {row: row, column: col, side: offset - col > 0 ? 1 : -1}; + }; + + this.screenToTextCoordinates = function(x, y) { + var canvasPos = this.scroller.getBoundingClientRect(); + + var col = Math.round( + (x + this.scrollLeft - canvasPos.left - this.$padding) / this.characterWidth + ); + + var row = (y + this.scrollTop - canvasPos.top) / this.lineHeight; + + return this.session.screenToDocumentPosition(row, Math.max(col, 0)); + }; + this.textToScreenCoordinates = function(row, column) { + var canvasPos = this.scroller.getBoundingClientRect(); + var pos = this.session.documentToScreenPosition(row, column); + + var x = this.$padding + Math.round(pos.column * this.characterWidth); + var y = pos.row * this.lineHeight; + + return { + pageX: canvasPos.left + x - this.scrollLeft, + pageY: canvasPos.top + y - this.scrollTop + }; + }; + this.visualizeFocus = function() { + dom.addCssClass(this.container, "ace_focus"); + }; + this.visualizeBlur = function() { + dom.removeCssClass(this.container, "ace_focus"); + }; + this.showComposition = function(position) { + if (!this.$composition) + this.$composition = { + keepTextAreaAtCursor: this.$keepTextAreaAtCursor, + cssText: this.textarea.style.cssText + }; + + this.$keepTextAreaAtCursor = true; + dom.addCssClass(this.textarea, "ace_composition"); + this.textarea.style.cssText = ""; + this.$moveTextAreaToCursor(); + }; + this.setCompositionText = function(text) { + this.$moveTextAreaToCursor(); + }; + this.hideComposition = function() { + if (!this.$composition) + return; + + dom.removeCssClass(this.textarea, "ace_composition"); + this.$keepTextAreaAtCursor = this.$composition.keepTextAreaAtCursor; + this.textarea.style.cssText = this.$composition.cssText; + this.$composition = null; + }; + this.setTheme = function(theme, cb) { + var _self = this; + this.$themeId = theme; + _self._dispatchEvent('themeChange',{theme:theme}); + + if (!theme || typeof theme == "string") { + var moduleName = theme || this.$options.theme.initialValue; + config.loadModule(["theme", moduleName], afterLoad); + } else { + afterLoad(theme); + } + + function afterLoad(module) { + if (_self.$themeId != theme) + return cb && cb(); + if (!module.cssClass) + return; + dom.importCssString( + module.cssText, + module.cssClass, + _self.container.ownerDocument + ); + + if (_self.theme) + dom.removeCssClass(_self.container, _self.theme.cssClass); + + var padding = "padding" in module ? module.padding + : "padding" in (_self.theme || {}) ? 4 : _self.$padding; + if (_self.$padding && padding != _self.$padding) + _self.setPadding(padding); + _self.$theme = module.cssClass; + + _self.theme = module; + dom.addCssClass(_self.container, module.cssClass); + dom.setCssClass(_self.container, "ace_dark", module.isDark); + if (_self.$size) { + _self.$size.width = 0; + _self.$updateSizeAsync(); + } + + _self._dispatchEvent('themeLoaded', {theme:module}); + cb && cb(); + } + }; + this.getTheme = function() { + return this.$themeId; + }; + this.setStyle = function(style, include) { + dom.setCssClass(this.container, style, include !== false); + }; + this.unsetStyle = function(style) { + dom.removeCssClass(this.container, style); + }; + + this.setCursorStyle = function(style) { + if (this.scroller.style.cursor != style) + this.scroller.style.cursor = style; + }; + this.setMouseCursor = function(cursorStyle) { + this.scroller.style.cursor = cursorStyle; + }; + this.destroy = function() { + this.$textLayer.destroy(); + this.$cursorLayer.destroy(); + }; + +}).call(VirtualRenderer.prototype); + + +config.defineOptions(VirtualRenderer.prototype, "renderer", { + animatedScroll: {initialValue: false}, + showInvisibles: { + set: function(value) { + if (this.$textLayer.setShowInvisibles(value)) + this.$loop.schedule(this.CHANGE_TEXT); + }, + initialValue: false + }, + showPrintMargin: { + set: function() { this.$updatePrintMargin(); }, + initialValue: true + }, + printMarginColumn: { + set: function() { this.$updatePrintMargin(); }, + initialValue: 80 + }, + printMargin: { + set: function(val) { + if (typeof val == "number") + this.$printMarginColumn = val; + this.$showPrintMargin = !!val; + this.$updatePrintMargin(); + }, + get: function() { + return this.$showPrintMargin && this.$printMarginColumn; + } + }, + showGutter: { + set: function(show){ + this.$gutter.style.display = show ? "block" : "none"; + this.$loop.schedule(this.CHANGE_FULL); + this.onGutterResize(); + }, + initialValue: true + }, + fadeFoldWidgets: { + set: function(show) { + dom.setCssClass(this.$gutter, "ace_fade-fold-widgets", show); + }, + initialValue: false + }, + showFoldWidgets: { + set: function(show) {this.$gutterLayer.setShowFoldWidgets(show)}, + initialValue: true + }, + showLineNumbers: { + set: function(show) { + this.$gutterLayer.setShowLineNumbers(show); + this.$loop.schedule(this.CHANGE_GUTTER); + }, + initialValue: true + }, + displayIndentGuides: { + set: function(show) { + if (this.$textLayer.setDisplayIndentGuides(show)) + this.$loop.schedule(this.CHANGE_TEXT); + }, + initialValue: true + }, + highlightGutterLine: { + set: function(shouldHighlight) { + if (!this.$gutterLineHighlight) { + this.$gutterLineHighlight = dom.createElement("div"); + this.$gutterLineHighlight.className = "ace_gutter-active-line"; + this.$gutter.appendChild(this.$gutterLineHighlight); + return; + } + + this.$gutterLineHighlight.style.display = shouldHighlight ? "" : "none"; + if (this.$cursorLayer.$pixelPos) + this.$updateGutterLineHighlight(); + }, + initialValue: false, + value: true + }, + hScrollBarAlwaysVisible: { + set: function(val) { + if (!this.$hScrollBarAlwaysVisible || !this.$horizScroll) + this.$loop.schedule(this.CHANGE_SCROLL); + }, + initialValue: false + }, + vScrollBarAlwaysVisible: { + set: function(val) { + if (!this.$vScrollBarAlwaysVisible || !this.$vScroll) + this.$loop.schedule(this.CHANGE_SCROLL); + }, + initialValue: false + }, + fontSize: { + set: function(size) { + if (typeof size == "number") + size = size + "px"; + this.container.style.fontSize = size; + this.updateFontSize(); + }, + initialValue: 12 + }, + fontFamily: { + set: function(name) { + this.container.style.fontFamily = name; + this.updateFontSize(); + } + }, + maxLines: { + set: function(val) { + this.updateFull(); + } + }, + minLines: { + set: function(val) { + this.updateFull(); + } + }, + scrollPastEnd: { + set: function(val) { + val = +val || 0; + if (this.$scrollPastEnd == val) + return; + this.$scrollPastEnd = val; + this.$loop.schedule(this.CHANGE_SCROLL); + }, + initialValue: 0, + handlesSet: true + }, + fixedWidthGutter: { + set: function(val) { + this.$gutterLayer.$fixedWidth = !!val; + this.$loop.schedule(this.CHANGE_GUTTER); + } + }, + theme: { + set: function(val) { this.setTheme(val) }, + get: function() { return this.$themeId || this.theme; }, + initialValue: "./theme/textmate", + handlesSet: true + } +}); + +exports.VirtualRenderer = VirtualRenderer; +}); + +define("ace/worker/worker_client",["require","exports","module","ace/lib/oop","ace/lib/net","ace/lib/event_emitter","ace/config"], function(require, exports, module) { +"use strict"; + +var oop = require("../lib/oop"); +var net = require("../lib/net"); +var EventEmitter = require("../lib/event_emitter").EventEmitter; +var config = require("../config"); + +var WorkerClient = function(topLevelNamespaces, mod, classname, workerUrl) { + this.$sendDeltaQueue = this.$sendDeltaQueue.bind(this); + this.changeListener = this.changeListener.bind(this); + this.onMessage = this.onMessage.bind(this); + if (require.nameToUrl && !require.toUrl) + require.toUrl = require.nameToUrl; + + if (config.get("packaged") || !require.toUrl) { + workerUrl = workerUrl || config.moduleUrl(mod, "worker"); + } else { + var normalizePath = this.$normalizePath; + workerUrl = workerUrl || normalizePath(require.toUrl("ace/worker/worker.js", null, "_")); + + var tlns = {}; + topLevelNamespaces.forEach(function(ns) { + tlns[ns] = normalizePath(require.toUrl(ns, null, "_").replace(/(\.js)?(\?.*)?$/, "")); + }); + } + + try { + this.$worker = new Worker(workerUrl); + } catch(e) { + if (e instanceof window.DOMException) { + var blob = this.$workerBlob(workerUrl); + var URL = window.URL || window.webkitURL; + var blobURL = URL.createObjectURL(blob); + + this.$worker = new Worker(blobURL); + URL.revokeObjectURL(blobURL); + } else { + throw e; + } + } + this.$worker.postMessage({ + init : true, + tlns : tlns, + module : mod, + classname : classname + }); + + this.callbackId = 1; + this.callbacks = {}; + + this.$worker.onmessage = this.onMessage; +}; + +(function(){ + + oop.implement(this, EventEmitter); + + this.onMessage = function(e) { + var msg = e.data; + switch(msg.type) { + case "event": + this._signal(msg.name, {data: msg.data}); + break; + case "call": + var callback = this.callbacks[msg.id]; + if (callback) { + callback(msg.data); + delete this.callbacks[msg.id]; + } + break; + case "error": + this.reportError(msg.data); + break; + case "log": + window.console && console.log && console.log.apply(console, msg.data); + break; + } + }; + + this.reportError = function(err) { + window.console && console.error && console.error(err); + }; + + this.$normalizePath = function(path) { + return net.qualifyURL(path); + }; + + this.terminate = function() { + this._signal("terminate", {}); + this.deltaQueue = null; + this.$worker.terminate(); + this.$worker = null; + if (this.$doc) + this.$doc.off("change", this.changeListener); + this.$doc = null; + }; + + this.send = function(cmd, args) { + this.$worker.postMessage({command: cmd, args: args}); + }; + + this.call = function(cmd, args, callback) { + if (callback) { + var id = this.callbackId++; + this.callbacks[id] = callback; + args.push(id); + } + this.send(cmd, args); + }; + + this.emit = function(event, data) { + try { + this.$worker.postMessage({event: event, data: {data: data.data}}); + } + catch(ex) { + console.error(ex.stack); + } + }; + + this.attachToDocument = function(doc) { + if(this.$doc) + this.terminate(); + + this.$doc = doc; + this.call("setValue", [doc.getValue()]); + doc.on("change", this.changeListener); + }; + + this.changeListener = function(e) { + if (!this.deltaQueue) { + this.deltaQueue = [e.data]; + setTimeout(this.$sendDeltaQueue, 0); + } else + this.deltaQueue.push(e.data); + }; + + this.$sendDeltaQueue = function() { + var q = this.deltaQueue; + if (!q) return; + this.deltaQueue = null; + if (q.length > 20 && q.length > this.$doc.getLength() >> 1) { + this.call("setValue", [this.$doc.getValue()]); + } else + this.emit("change", {data: q}); + }; + + this.$workerBlob = function(workerUrl) { + var script = "importScripts('" + net.qualifyURL(workerUrl) + "');"; + try { + return new Blob([script], {"type": "application/javascript"}); + } catch (e) { // Backwards-compatibility + var BlobBuilder = window.BlobBuilder || window.WebKitBlobBuilder || window.MozBlobBuilder; + var blobBuilder = new BlobBuilder(); + blobBuilder.append(script); + return blobBuilder.getBlob("application/javascript"); + } + }; + +}).call(WorkerClient.prototype); + + +var UIWorkerClient = function(topLevelNamespaces, mod, classname) { + this.$sendDeltaQueue = this.$sendDeltaQueue.bind(this); + this.changeListener = this.changeListener.bind(this); + this.callbackId = 1; + this.callbacks = {}; + this.messageBuffer = []; + + var main = null; + var emitSync = false; + var sender = Object.create(EventEmitter); + var _self = this; + + this.$worker = {}; + this.$worker.terminate = function() {}; + this.$worker.postMessage = function(e) { + _self.messageBuffer.push(e); + if (main) { + if (emitSync) + setTimeout(processNext); + else + processNext(); + } + }; + this.setEmitSync = function(val) { emitSync = val }; + + var processNext = function() { + var msg = _self.messageBuffer.shift(); + if (msg.command) + main[msg.command].apply(main, msg.args); + else if (msg.event) + sender._signal(msg.event, msg.data); + }; + + sender.postMessage = function(msg) { + _self.onMessage({data: msg}); + }; + sender.callback = function(data, callbackId) { + this.postMessage({type: "call", id: callbackId, data: data}); + }; + sender.emit = function(name, data) { + this.postMessage({type: "event", name: name, data: data}); + }; + + config.loadModule(["worker", mod], function(Main) { + main = new Main[classname](sender); + while (_self.messageBuffer.length) + processNext(); + }); +}; + +UIWorkerClient.prototype = WorkerClient.prototype; + +exports.UIWorkerClient = UIWorkerClient; +exports.WorkerClient = WorkerClient; + +}); + +define("ace/placeholder",["require","exports","module","ace/range","ace/lib/event_emitter","ace/lib/oop"], function(require, exports, module) { +"use strict"; + +var Range = require("./range").Range; +var EventEmitter = require("./lib/event_emitter").EventEmitter; +var oop = require("./lib/oop"); + +var PlaceHolder = function(session, length, pos, others, mainClass, othersClass) { + var _self = this; + this.length = length; + this.session = session; + this.doc = session.getDocument(); + this.mainClass = mainClass; + this.othersClass = othersClass; + this.$onUpdate = this.onUpdate.bind(this); + this.doc.on("change", this.$onUpdate); + this.$others = others; + + this.$onCursorChange = function() { + setTimeout(function() { + _self.onCursorChange(); + }); + }; + + this.$pos = pos; + var undoStack = session.getUndoManager().$undoStack || session.getUndoManager().$undostack || {length: -1}; + this.$undoStackDepth = undoStack.length; + this.setup(); + + session.selection.on("changeCursor", this.$onCursorChange); +}; + +(function() { + + oop.implement(this, EventEmitter); + this.setup = function() { + var _self = this; + var doc = this.doc; + var session = this.session; + var pos = this.$pos; + + this.selectionBefore = session.selection.toJSON(); + if (session.selection.inMultiSelectMode) + session.selection.toSingleRange(); + + this.pos = doc.createAnchor(pos.row, pos.column); + this.markerId = session.addMarker(new Range(pos.row, pos.column, pos.row, pos.column + this.length), this.mainClass, null, false); + this.pos.on("change", function(event) { + session.removeMarker(_self.markerId); + _self.markerId = session.addMarker(new Range(event.value.row, event.value.column, event.value.row, event.value.column+_self.length), _self.mainClass, null, false); + }); + this.others = []; + this.$others.forEach(function(other) { + var anchor = doc.createAnchor(other.row, other.column); + _self.others.push(anchor); + }); + session.setUndoSelect(false); + }; + this.showOtherMarkers = function() { + if(this.othersActive) return; + var session = this.session; + var _self = this; + this.othersActive = true; + this.others.forEach(function(anchor) { + anchor.markerId = session.addMarker(new Range(anchor.row, anchor.column, anchor.row, anchor.column+_self.length), _self.othersClass, null, false); + anchor.on("change", function(event) { + session.removeMarker(anchor.markerId); + anchor.markerId = session.addMarker(new Range(event.value.row, event.value.column, event.value.row, event.value.column+_self.length), _self.othersClass, null, false); + }); + }); + }; + this.hideOtherMarkers = function() { + if(!this.othersActive) return; + this.othersActive = false; + for (var i = 0; i < this.others.length; i++) { + this.session.removeMarker(this.others[i].markerId); + } + }; + this.onUpdate = function(event) { + var delta = event.data; + var range = delta.range; + if(range.start.row !== range.end.row) return; + if(range.start.row !== this.pos.row) return; + if (this.$updating) return; + this.$updating = true; + var lengthDiff = delta.action === "insertText" ? range.end.column - range.start.column : range.start.column - range.end.column; + + if(range.start.column >= this.pos.column && range.start.column <= this.pos.column + this.length + 1) { + var distanceFromStart = range.start.column - this.pos.column; + this.length += lengthDiff; + if(!this.session.$fromUndo) { + if(delta.action === "insertText") { + for (var i = this.others.length - 1; i >= 0; i--) { + var otherPos = this.others[i]; + var newPos = {row: otherPos.row, column: otherPos.column + distanceFromStart}; + if(otherPos.row === range.start.row && range.start.column < otherPos.column) + newPos.column += lengthDiff; + this.doc.insert(newPos, delta.text); + } + } else if(delta.action === "removeText") { + for (var i = this.others.length - 1; i >= 0; i--) { + var otherPos = this.others[i]; + var newPos = {row: otherPos.row, column: otherPos.column + distanceFromStart}; + if(otherPos.row === range.start.row && range.start.column < otherPos.column) + newPos.column += lengthDiff; + this.doc.remove(new Range(newPos.row, newPos.column, newPos.row, newPos.column - lengthDiff)); + } + } + if(range.start.column === this.pos.column && delta.action === "insertText") { + setTimeout(function() { + this.pos.setPosition(this.pos.row, this.pos.column - lengthDiff); + for (var i = 0; i < this.others.length; i++) { + var other = this.others[i]; + var newPos = {row: other.row, column: other.column - lengthDiff}; + if(other.row === range.start.row && range.start.column < other.column) + newPos.column += lengthDiff; + other.setPosition(newPos.row, newPos.column); + } + }.bind(this), 0); + } + else if(range.start.column === this.pos.column && delta.action === "removeText") { + setTimeout(function() { + for (var i = 0; i < this.others.length; i++) { + var other = this.others[i]; + if(other.row === range.start.row && range.start.column < other.column) { + other.setPosition(other.row, other.column - lengthDiff); + } + } + }.bind(this), 0); + } + } + this.pos._emit("change", {value: this.pos}); + for (var i = 0; i < this.others.length; i++) { + this.others[i]._emit("change", {value: this.others[i]}); + } + } + this.$updating = false; + }; + + this.onCursorChange = function(event) { + if (this.$updating || !this.session) return; + var pos = this.session.selection.getCursor(); + if (pos.row === this.pos.row && pos.column >= this.pos.column && pos.column <= this.pos.column + this.length) { + this.showOtherMarkers(); + this._emit("cursorEnter", event); + } else { + this.hideOtherMarkers(); + this._emit("cursorLeave", event); + } + }; + this.detach = function() { + this.session.removeMarker(this.markerId); + this.hideOtherMarkers(); + this.doc.removeEventListener("change", this.$onUpdate); + this.session.selection.removeEventListener("changeCursor", this.$onCursorChange); + this.pos.detach(); + for (var i = 0; i < this.others.length; i++) { + this.others[i].detach(); + } + this.session.setUndoSelect(true); + this.session = null; + }; + this.cancel = function() { + if(this.$undoStackDepth === -1) + throw Error("Canceling placeholders only supported with undo manager attached to session."); + var undoManager = this.session.getUndoManager(); + var undosRequired = (undoManager.$undoStack || undoManager.$undostack).length - this.$undoStackDepth; + for (var i = 0; i < undosRequired; i++) { + undoManager.undo(true); + } + if (this.selectionBefore) + this.session.selection.fromJSON(this.selectionBefore); + }; +}).call(PlaceHolder.prototype); + + +exports.PlaceHolder = PlaceHolder; +}); + +define("ace/mouse/multi_select_handler",["require","exports","module","ace/lib/event","ace/lib/useragent"], function(require, exports, module) { + +var event = require("../lib/event"); +var useragent = require("../lib/useragent"); +function isSamePoint(p1, p2) { + return p1.row == p2.row && p1.column == p2.column; +} + +function onMouseDown(e) { + var ev = e.domEvent; + var alt = ev.altKey; + var shift = ev.shiftKey; + var ctrl = ev.ctrlKey; + var accel = e.getAccelKey(); + var button = e.getButton(); + + if (ctrl && useragent.isMac) + button = ev.button; + + if (e.editor.inMultiSelectMode && button == 2) { + e.editor.textInput.onContextMenu(e.domEvent); + return; + } + + if (!ctrl && !alt && !accel) { + if (button === 0 && e.editor.inMultiSelectMode) + e.editor.exitMultiSelectMode(); + return; + } + + if (button !== 0) + return; + + var editor = e.editor; + var selection = editor.selection; + var isMultiSelect = editor.inMultiSelectMode; + var pos = e.getDocumentPosition(); + var cursor = selection.getCursor(); + var inSelection = e.inSelection() || (selection.isEmpty() && isSamePoint(pos, cursor)); + + var mouseX = e.x, mouseY = e.y; + var onMouseSelection = function(e) { + mouseX = e.clientX; + mouseY = e.clientY; + }; + + var session = editor.session; + var screenAnchor = editor.renderer.pixelToScreenCoordinates(mouseX, mouseY); + var screenCursor = screenAnchor; + + var selectionMode; + if (editor.$mouseHandler.$enableJumpToDef) { + if (ctrl && alt || accel && alt) + selectionMode = "add"; + else if (alt) + selectionMode = "block"; + } else { + if (accel && !alt) { + selectionMode = "add"; + if (!isMultiSelect && shift) + return; + } else if (alt) { + selectionMode = "block"; + } + } + + if (selectionMode && useragent.isMac && ev.ctrlKey) { + editor.$mouseHandler.cancelContextMenu(); + } + + if (selectionMode == "add") { + if (!isMultiSelect && inSelection) + return; // dragging + + if (!isMultiSelect) { + var range = selection.toOrientedRange(); + editor.addSelectionMarker(range); + } + + var oldRange = selection.rangeList.rangeAtPoint(pos); + + + editor.$blockScrolling++; + editor.inVirtualSelectionMode = true; + + if (shift) { + oldRange = null; + range = selection.ranges[0]; + editor.removeSelectionMarker(range); + } + editor.once("mouseup", function() { + var tmpSel = selection.toOrientedRange(); + + if (oldRange && tmpSel.isEmpty() && isSamePoint(oldRange.cursor, tmpSel.cursor)) + selection.substractPoint(tmpSel.cursor); + else { + if (shift) { + selection.substractPoint(range.cursor); + } else if (range) { + editor.removeSelectionMarker(range); + selection.addRange(range); + } + selection.addRange(tmpSel); + } + editor.$blockScrolling--; + editor.inVirtualSelectionMode = false; + }); + + } else if (selectionMode == "block") { + e.stop(); + editor.inVirtualSelectionMode = true; + var initialRange; + var rectSel = []; + var blockSelect = function() { + var newCursor = editor.renderer.pixelToScreenCoordinates(mouseX, mouseY); + var cursor = session.screenToDocumentPosition(newCursor.row, newCursor.column); + + if (isSamePoint(screenCursor, newCursor) && isSamePoint(cursor, selection.lead)) + return; + screenCursor = newCursor; + + editor.$blockScrolling++; + editor.selection.moveToPosition(cursor); + editor.renderer.scrollCursorIntoView(); + + editor.removeSelectionMarkers(rectSel); + rectSel = selection.rectangularRangeBlock(screenCursor, screenAnchor); + if (editor.$mouseHandler.$clickSelection && rectSel.length == 1 && rectSel[0].isEmpty()) + rectSel[0] = editor.$mouseHandler.$clickSelection.clone(); + rectSel.forEach(editor.addSelectionMarker, editor); + editor.updateSelectionMarkers(); + editor.$blockScrolling--; + }; + editor.$blockScrolling++; + if (isMultiSelect && !accel) { + selection.toSingleRange(); + } else if (!isMultiSelect && accel) { + initialRange = selection.toOrientedRange(); + editor.addSelectionMarker(initialRange); + } + + if (shift) + screenAnchor = session.documentToScreenPosition(selection.lead); + else + selection.moveToPosition(pos); + editor.$blockScrolling--; + + screenCursor = {row: -1, column: -1}; + + var onMouseSelectionEnd = function(e) { + clearInterval(timerId); + editor.removeSelectionMarkers(rectSel); + if (!rectSel.length) + rectSel = [selection.toOrientedRange()]; + editor.$blockScrolling++; + if (initialRange) { + editor.removeSelectionMarker(initialRange); + selection.toSingleRange(initialRange); + } + for (var i = 0; i < rectSel.length; i++) + selection.addRange(rectSel[i]); + editor.inVirtualSelectionMode = false; + editor.$mouseHandler.$clickSelection = null; + editor.$blockScrolling--; + }; + + var onSelectionInterval = blockSelect; + + event.capture(editor.container, onMouseSelection, onMouseSelectionEnd); + var timerId = setInterval(function() {onSelectionInterval();}, 20); + + return e.preventDefault(); + } +} + + +exports.onMouseDown = onMouseDown; + +}); + +define("ace/commands/multi_select_commands",["require","exports","module","ace/keyboard/hash_handler"], function(require, exports, module) { +exports.defaultCommands = [{ + name: "addCursorAbove", + exec: function(editor) { editor.selectMoreLines(-1); }, + bindKey: {win: "Ctrl-Alt-Up", mac: "Ctrl-Alt-Up"}, + scrollIntoView: "cursor", + readonly: true +}, { + name: "addCursorBelow", + exec: function(editor) { editor.selectMoreLines(1); }, + bindKey: {win: "Ctrl-Alt-Down", mac: "Ctrl-Alt-Down"}, + scrollIntoView: "cursor", + readonly: true +}, { + name: "addCursorAboveSkipCurrent", + exec: function(editor) { editor.selectMoreLines(-1, true); }, + bindKey: {win: "Ctrl-Alt-Shift-Up", mac: "Ctrl-Alt-Shift-Up"}, + scrollIntoView: "cursor", + readonly: true +}, { + name: "addCursorBelowSkipCurrent", + exec: function(editor) { editor.selectMoreLines(1, true); }, + bindKey: {win: "Ctrl-Alt-Shift-Down", mac: "Ctrl-Alt-Shift-Down"}, + scrollIntoView: "cursor", + readonly: true +}, { + name: "selectMoreBefore", + exec: function(editor) { editor.selectMore(-1); }, + bindKey: {win: "Ctrl-Alt-Left", mac: "Ctrl-Alt-Left"}, + scrollIntoView: "cursor", + readonly: true +}, { + name: "selectMoreAfter", + exec: function(editor) { editor.selectMore(1); }, + bindKey: {win: "Ctrl-Alt-Right", mac: "Ctrl-Alt-Right"}, + scrollIntoView: "cursor", + readonly: true +}, { + name: "selectNextBefore", + exec: function(editor) { editor.selectMore(-1, true); }, + bindKey: {win: "Ctrl-Alt-Shift-Left", mac: "Ctrl-Alt-Shift-Left"}, + scrollIntoView: "cursor", + readonly: true +}, { + name: "selectNextAfter", + exec: function(editor) { editor.selectMore(1, true); }, + bindKey: {win: "Ctrl-Alt-Shift-Right", mac: "Ctrl-Alt-Shift-Right"}, + scrollIntoView: "cursor", + readonly: true +}, { + name: "splitIntoLines", + exec: function(editor) { editor.multiSelect.splitIntoLines(); }, + bindKey: {win: "Ctrl-Alt-L", mac: "Ctrl-Alt-L"}, + readonly: true +}, { + name: "alignCursors", + exec: function(editor) { editor.alignCursors(); }, + bindKey: {win: "Ctrl-Alt-A", mac: "Ctrl-Alt-A"}, + scrollIntoView: "cursor" +}, { + name: "findAll", + exec: function(editor) { editor.findAll(); }, + bindKey: {win: "Ctrl-Alt-K", mac: "Ctrl-Alt-G"}, + scrollIntoView: "cursor", + readonly: true +}]; +exports.multiSelectCommands = [{ + name: "singleSelection", + bindKey: "esc", + exec: function(editor) { editor.exitMultiSelectMode(); }, + scrollIntoView: "cursor", + readonly: true, + isAvailable: function(editor) {return editor && editor.inMultiSelectMode} +}]; + +var HashHandler = require("../keyboard/hash_handler").HashHandler; +exports.keyboardHandler = new HashHandler(exports.multiSelectCommands); + +}); + +define("ace/multi_select",["require","exports","module","ace/range_list","ace/range","ace/selection","ace/mouse/multi_select_handler","ace/lib/event","ace/lib/lang","ace/commands/multi_select_commands","ace/search","ace/edit_session","ace/editor","ace/config"], function(require, exports, module) { + +var RangeList = require("./range_list").RangeList; +var Range = require("./range").Range; +var Selection = require("./selection").Selection; +var onMouseDown = require("./mouse/multi_select_handler").onMouseDown; +var event = require("./lib/event"); +var lang = require("./lib/lang"); +var commands = require("./commands/multi_select_commands"); +exports.commands = commands.defaultCommands.concat(commands.multiSelectCommands); +var Search = require("./search").Search; +var search = new Search(); + +function find(session, needle, dir) { + search.$options.wrap = true; + search.$options.needle = needle; + search.$options.backwards = dir == -1; + return search.find(session); +} +var EditSession = require("./edit_session").EditSession; +(function() { + this.getSelectionMarkers = function() { + return this.$selectionMarkers; + }; +}).call(EditSession.prototype); +(function() { + this.ranges = null; + this.rangeList = null; + this.addRange = function(range, $blockChangeEvents) { + if (!range) + return; + + if (!this.inMultiSelectMode && this.rangeCount === 0) { + var oldRange = this.toOrientedRange(); + this.rangeList.add(oldRange); + this.rangeList.add(range); + if (this.rangeList.ranges.length != 2) { + this.rangeList.removeAll(); + return $blockChangeEvents || this.fromOrientedRange(range); + } + this.rangeList.removeAll(); + this.rangeList.add(oldRange); + this.$onAddRange(oldRange); + } + + if (!range.cursor) + range.cursor = range.end; + + var removed = this.rangeList.add(range); + + this.$onAddRange(range); + + if (removed.length) + this.$onRemoveRange(removed); + + if (this.rangeCount > 1 && !this.inMultiSelectMode) { + this._signal("multiSelect"); + this.inMultiSelectMode = true; + this.session.$undoSelect = false; + this.rangeList.attach(this.session); + } + + return $blockChangeEvents || this.fromOrientedRange(range); + }; + + this.toSingleRange = function(range) { + range = range || this.ranges[0]; + var removed = this.rangeList.removeAll(); + if (removed.length) + this.$onRemoveRange(removed); + + range && this.fromOrientedRange(range); + }; + this.substractPoint = function(pos) { + var removed = this.rangeList.substractPoint(pos); + if (removed) { + this.$onRemoveRange(removed); + return removed[0]; + } + }; + this.mergeOverlappingRanges = function() { + var removed = this.rangeList.merge(); + if (removed.length) + this.$onRemoveRange(removed); + else if(this.ranges[0]) + this.fromOrientedRange(this.ranges[0]); + }; + + this.$onAddRange = function(range) { + this.rangeCount = this.rangeList.ranges.length; + this.ranges.unshift(range); + this._signal("addRange", {range: range}); + }; + + this.$onRemoveRange = function(removed) { + this.rangeCount = this.rangeList.ranges.length; + if (this.rangeCount == 1 && this.inMultiSelectMode) { + var lastRange = this.rangeList.ranges.pop(); + removed.push(lastRange); + this.rangeCount = 0; + } + + for (var i = removed.length; i--; ) { + var index = this.ranges.indexOf(removed[i]); + this.ranges.splice(index, 1); + } + + this._signal("removeRange", {ranges: removed}); + + if (this.rangeCount === 0 && this.inMultiSelectMode) { + this.inMultiSelectMode = false; + this._signal("singleSelect"); + this.session.$undoSelect = true; + this.rangeList.detach(this.session); + } + + lastRange = lastRange || this.ranges[0]; + if (lastRange && !lastRange.isEqual(this.getRange())) + this.fromOrientedRange(lastRange); + }; + this.$initRangeList = function() { + if (this.rangeList) + return; + + this.rangeList = new RangeList(); + this.ranges = []; + this.rangeCount = 0; + }; + this.getAllRanges = function() { + return this.rangeCount ? this.rangeList.ranges.concat() : [this.getRange()]; + }; + + this.splitIntoLines = function () { + if (this.rangeCount > 1) { + var ranges = this.rangeList.ranges; + var lastRange = ranges[ranges.length - 1]; + var range = Range.fromPoints(ranges[0].start, lastRange.end); + + this.toSingleRange(); + this.setSelectionRange(range, lastRange.cursor == lastRange.start); + } else { + var range = this.getRange(); + var isBackwards = this.isBackwards(); + var startRow = range.start.row; + var endRow = range.end.row; + if (startRow == endRow) { + if (isBackwards) + var start = range.end, end = range.start; + else + var start = range.start, end = range.end; + + this.addRange(Range.fromPoints(end, end)); + this.addRange(Range.fromPoints(start, start)); + return; + } + + var rectSel = []; + var r = this.getLineRange(startRow, true); + r.start.column = range.start.column; + rectSel.push(r); + + for (var i = startRow + 1; i < endRow; i++) + rectSel.push(this.getLineRange(i, true)); + + r = this.getLineRange(endRow, true); + r.end.column = range.end.column; + rectSel.push(r); + + rectSel.forEach(this.addRange, this); + } + }; + this.toggleBlockSelection = function () { + if (this.rangeCount > 1) { + var ranges = this.rangeList.ranges; + var lastRange = ranges[ranges.length - 1]; + var range = Range.fromPoints(ranges[0].start, lastRange.end); + + this.toSingleRange(); + this.setSelectionRange(range, lastRange.cursor == lastRange.start); + } else { + var cursor = this.session.documentToScreenPosition(this.selectionLead); + var anchor = this.session.documentToScreenPosition(this.selectionAnchor); + + var rectSel = this.rectangularRangeBlock(cursor, anchor); + rectSel.forEach(this.addRange, this); + } + }; + this.rectangularRangeBlock = function(screenCursor, screenAnchor, includeEmptyLines) { + var rectSel = []; + + var xBackwards = screenCursor.column < screenAnchor.column; + if (xBackwards) { + var startColumn = screenCursor.column; + var endColumn = screenAnchor.column; + } else { + var startColumn = screenAnchor.column; + var endColumn = screenCursor.column; + } + + var yBackwards = screenCursor.row < screenAnchor.row; + if (yBackwards) { + var startRow = screenCursor.row; + var endRow = screenAnchor.row; + } else { + var startRow = screenAnchor.row; + var endRow = screenCursor.row; + } + + if (startColumn < 0) + startColumn = 0; + if (startRow < 0) + startRow = 0; + + if (startRow == endRow) + includeEmptyLines = true; + + for (var row = startRow; row <= endRow; row++) { + var range = Range.fromPoints( + this.session.screenToDocumentPosition(row, startColumn), + this.session.screenToDocumentPosition(row, endColumn) + ); + if (range.isEmpty()) { + if (docEnd && isSamePoint(range.end, docEnd)) + break; + var docEnd = range.end; + } + range.cursor = xBackwards ? range.start : range.end; + rectSel.push(range); + } + + if (yBackwards) + rectSel.reverse(); + + if (!includeEmptyLines) { + var end = rectSel.length - 1; + while (rectSel[end].isEmpty() && end > 0) + end--; + if (end > 0) { + var start = 0; + while (rectSel[start].isEmpty()) + start++; + } + for (var i = end; i >= start; i--) { + if (rectSel[i].isEmpty()) + rectSel.splice(i, 1); + } + } + + return rectSel; + }; +}).call(Selection.prototype); +var Editor = require("./editor").Editor; +(function() { + this.updateSelectionMarkers = function() { + this.renderer.updateCursor(); + this.renderer.updateBackMarkers(); + }; + this.addSelectionMarker = function(orientedRange) { + if (!orientedRange.cursor) + orientedRange.cursor = orientedRange.end; + + var style = this.getSelectionStyle(); + orientedRange.marker = this.session.addMarker(orientedRange, "ace_selection", style); + + this.session.$selectionMarkers.push(orientedRange); + this.session.selectionMarkerCount = this.session.$selectionMarkers.length; + return orientedRange; + }; + this.removeSelectionMarker = function(range) { + if (!range.marker) + return; + this.session.removeMarker(range.marker); + var index = this.session.$selectionMarkers.indexOf(range); + if (index != -1) + this.session.$selectionMarkers.splice(index, 1); + this.session.selectionMarkerCount = this.session.$selectionMarkers.length; + }; + + this.removeSelectionMarkers = function(ranges) { + var markerList = this.session.$selectionMarkers; + for (var i = ranges.length; i--; ) { + var range = ranges[i]; + if (!range.marker) + continue; + this.session.removeMarker(range.marker); + var index = markerList.indexOf(range); + if (index != -1) + markerList.splice(index, 1); + } + this.session.selectionMarkerCount = markerList.length; + }; + + this.$onAddRange = function(e) { + this.addSelectionMarker(e.range); + this.renderer.updateCursor(); + this.renderer.updateBackMarkers(); + }; + + this.$onRemoveRange = function(e) { + this.removeSelectionMarkers(e.ranges); + this.renderer.updateCursor(); + this.renderer.updateBackMarkers(); + }; + + this.$onMultiSelect = function(e) { + if (this.inMultiSelectMode) + return; + this.inMultiSelectMode = true; + + this.setStyle("ace_multiselect"); + this.keyBinding.addKeyboardHandler(commands.keyboardHandler); + this.commands.setDefaultHandler("exec", this.$onMultiSelectExec); + + this.renderer.updateCursor(); + this.renderer.updateBackMarkers(); + }; + + this.$onSingleSelect = function(e) { + if (this.session.multiSelect.inVirtualMode) + return; + this.inMultiSelectMode = false; + + this.unsetStyle("ace_multiselect"); + this.keyBinding.removeKeyboardHandler(commands.keyboardHandler); + + this.commands.removeDefaultHandler("exec", this.$onMultiSelectExec); + this.renderer.updateCursor(); + this.renderer.updateBackMarkers(); + this._emit("changeSelection"); + }; + + this.$onMultiSelectExec = function(e) { + var command = e.command; + var editor = e.editor; + if (!editor.multiSelect) + return; + if (!command.multiSelectAction) { + var result = command.exec(editor, e.args || {}); + editor.multiSelect.addRange(editor.multiSelect.toOrientedRange()); + editor.multiSelect.mergeOverlappingRanges(); + } else if (command.multiSelectAction == "forEach") { + result = editor.forEachSelection(command, e.args); + } else if (command.multiSelectAction == "forEachLine") { + result = editor.forEachSelection(command, e.args, true); + } else if (command.multiSelectAction == "single") { + editor.exitMultiSelectMode(); + result = command.exec(editor, e.args || {}); + } else { + result = command.multiSelectAction(editor, e.args || {}); + } + return result; + }; + this.forEachSelection = function(cmd, args, options) { + if (this.inVirtualSelectionMode) + return; + var keepOrder = options && options.keepOrder; + var $byLines = options == true || options && options.$byLines + var session = this.session; + var selection = this.selection; + var rangeList = selection.rangeList; + var ranges = (keepOrder ? selection : rangeList).ranges; + var result; + + if (!ranges.length) + return cmd.exec ? cmd.exec(this, args || {}) : cmd(this, args || {}); + + var reg = selection._eventRegistry; + selection._eventRegistry = {}; + + var tmpSel = new Selection(session); + this.inVirtualSelectionMode = true; + for (var i = ranges.length; i--;) { + if ($byLines) { + while (i > 0 && ranges[i].start.row == ranges[i - 1].end.row) + i--; + } + tmpSel.fromOrientedRange(ranges[i]); + tmpSel.index = i; + this.selection = session.selection = tmpSel; + var cmdResult = cmd.exec ? cmd.exec(this, args || {}) : cmd(this, args || {}); + if (!result && cmdResult !== undefined) + result = cmdResult; + tmpSel.toOrientedRange(ranges[i]); + } + tmpSel.detach(); + + this.selection = session.selection = selection; + this.inVirtualSelectionMode = false; + selection._eventRegistry = reg; + selection.mergeOverlappingRanges(); + + var anim = this.renderer.$scrollAnimation; + this.onCursorChange(); + this.onSelectionChange(); + if (anim && anim.from == anim.to) + this.renderer.animateScrolling(anim.from); + + return result; + }; + this.exitMultiSelectMode = function() { + if (!this.inMultiSelectMode || this.inVirtualSelectionMode) + return; + this.multiSelect.toSingleRange(); + }; + + this.getSelectedText = function() { + var text = ""; + if (this.inMultiSelectMode && !this.inVirtualSelectionMode) { + var ranges = this.multiSelect.rangeList.ranges; + var buf = []; + for (var i = 0; i < ranges.length; i++) { + buf.push(this.session.getTextRange(ranges[i])); + } + var nl = this.session.getDocument().getNewLineCharacter(); + text = buf.join(nl); + if (text.length == (buf.length - 1) * nl.length) + text = ""; + } else if (!this.selection.isEmpty()) { + text = this.session.getTextRange(this.getSelectionRange()); + } + return text; + }; + + this.$checkMultiselectChange = function(e, anchor) { + if (this.inMultiSelectMode && !this.inVirtualSelectionMode) { + var range = this.multiSelect.ranges[0]; + if (this.multiSelect.isEmpty() && anchor == this.multiSelect.anchor) + return; + var pos = anchor == this.multiSelect.anchor + ? range.cursor == range.start ? range.end : range.start + : range.cursor; + if (pos.row != anchor.row + || this.session.$clipPositionToDocument(pos.row, pos.column).column != anchor.column) + this.multiSelect.toSingleRange(this.multiSelect.toOrientedRange()); + } + }; + this.findAll = function(needle, options, additive) { + options = options || {}; + options.needle = needle || options.needle; + if (options.needle == undefined) { + var range = this.selection.isEmpty() + ? this.selection.getWordRange() + : this.selection.getRange(); + options.needle = this.session.getTextRange(range); + } + this.$search.set(options); + + var ranges = this.$search.findAll(this.session); + if (!ranges.length) + return 0; + + this.$blockScrolling += 1; + var selection = this.multiSelect; + + if (!additive) + selection.toSingleRange(ranges[0]); + + for (var i = ranges.length; i--; ) + selection.addRange(ranges[i], true); + if (range && selection.rangeList.rangeAtPoint(range.start)) + selection.addRange(range, true); + + this.$blockScrolling -= 1; + + return ranges.length; + }; + this.selectMoreLines = function(dir, skip) { + var range = this.selection.toOrientedRange(); + var isBackwards = range.cursor == range.end; + + var screenLead = this.session.documentToScreenPosition(range.cursor); + if (this.selection.$desiredColumn) + screenLead.column = this.selection.$desiredColumn; + + var lead = this.session.screenToDocumentPosition(screenLead.row + dir, screenLead.column); + + if (!range.isEmpty()) { + var screenAnchor = this.session.documentToScreenPosition(isBackwards ? range.end : range.start); + var anchor = this.session.screenToDocumentPosition(screenAnchor.row + dir, screenAnchor.column); + } else { + var anchor = lead; + } + + if (isBackwards) { + var newRange = Range.fromPoints(lead, anchor); + newRange.cursor = newRange.start; + } else { + var newRange = Range.fromPoints(anchor, lead); + newRange.cursor = newRange.end; + } + + newRange.desiredColumn = screenLead.column; + if (!this.selection.inMultiSelectMode) { + this.selection.addRange(range); + } else { + if (skip) + var toRemove = range.cursor; + } + + this.selection.addRange(newRange); + if (toRemove) + this.selection.substractPoint(toRemove); + }; + this.transposeSelections = function(dir) { + var session = this.session; + var sel = session.multiSelect; + var all = sel.ranges; + + for (var i = all.length; i--; ) { + var range = all[i]; + if (range.isEmpty()) { + var tmp = session.getWordRange(range.start.row, range.start.column); + range.start.row = tmp.start.row; + range.start.column = tmp.start.column; + range.end.row = tmp.end.row; + range.end.column = tmp.end.column; + } + } + sel.mergeOverlappingRanges(); + + var words = []; + for (var i = all.length; i--; ) { + var range = all[i]; + words.unshift(session.getTextRange(range)); + } + + if (dir < 0) + words.unshift(words.pop()); + else + words.push(words.shift()); + + for (var i = all.length; i--; ) { + var range = all[i]; + var tmp = range.clone(); + session.replace(range, words[i]); + range.start.row = tmp.start.row; + range.start.column = tmp.start.column; + } + }; + this.selectMore = function(dir, skip, stopAtFirst) { + var session = this.session; + var sel = session.multiSelect; + + var range = sel.toOrientedRange(); + if (range.isEmpty()) { + range = session.getWordRange(range.start.row, range.start.column); + range.cursor = dir == -1 ? range.start : range.end; + this.multiSelect.addRange(range); + if (stopAtFirst) + return; + } + var needle = session.getTextRange(range); + + var newRange = find(session, needle, dir); + if (newRange) { + newRange.cursor = dir == -1 ? newRange.start : newRange.end; + this.$blockScrolling += 1; + this.session.unfold(newRange); + this.multiSelect.addRange(newRange); + this.$blockScrolling -= 1; + this.renderer.scrollCursorIntoView(null, 0.5); + } + if (skip) + this.multiSelect.substractPoint(range.cursor); + }; + this.alignCursors = function() { + var session = this.session; + var sel = session.multiSelect; + var ranges = sel.ranges; + var row = -1; + var sameRowRanges = ranges.filter(function(r) { + if (r.cursor.row == row) + return true; + row = r.cursor.row; + }); + + if (!ranges.length || sameRowRanges.length == ranges.length - 1) { + var range = this.selection.getRange(); + var fr = range.start.row, lr = range.end.row; + var guessRange = fr == lr; + if (guessRange) { + var max = this.session.getLength(); + var line; + do { + line = this.session.getLine(lr); + } while (/[=:]/.test(line) && ++lr < max); + do { + line = this.session.getLine(fr); + } while (/[=:]/.test(line) && --fr > 0); + + if (fr < 0) fr = 0; + if (lr >= max) lr = max - 1; + } + var lines = this.session.doc.removeLines(fr, lr); + lines = this.$reAlignText(lines, guessRange); + this.session.doc.insert({row: fr, column: 0}, lines.join("\n") + "\n"); + if (!guessRange) { + range.start.column = 0; + range.end.column = lines[lines.length - 1].length; + } + this.selection.setRange(range); + } else { + sameRowRanges.forEach(function(r) { + sel.substractPoint(r.cursor); + }); + + var maxCol = 0; + var minSpace = Infinity; + var spaceOffsets = ranges.map(function(r) { + var p = r.cursor; + var line = session.getLine(p.row); + var spaceOffset = line.substr(p.column).search(/\S/g); + if (spaceOffset == -1) + spaceOffset = 0; + + if (p.column > maxCol) + maxCol = p.column; + if (spaceOffset < minSpace) + minSpace = spaceOffset; + return spaceOffset; + }); + ranges.forEach(function(r, i) { + var p = r.cursor; + var l = maxCol - p.column; + var d = spaceOffsets[i] - minSpace; + if (l > d) + session.insert(p, lang.stringRepeat(" ", l - d)); + else + session.remove(new Range(p.row, p.column, p.row, p.column - l + d)); + + r.start.column = r.end.column = maxCol; + r.start.row = r.end.row = p.row; + r.cursor = r.end; + }); + sel.fromOrientedRange(ranges[0]); + this.renderer.updateCursor(); + this.renderer.updateBackMarkers(); + } + }; + + this.$reAlignText = function(lines, forceLeft) { + var isLeftAligned = true, isRightAligned = true; + var startW, textW, endW; + + return lines.map(function(line) { + var m = line.match(/(\s*)(.*?)(\s*)([=:].*)/); + if (!m) + return [line]; + + if (startW == null) { + startW = m[1].length; + textW = m[2].length; + endW = m[3].length; + return m; + } + + if (startW + textW + endW != m[1].length + m[2].length + m[3].length) + isRightAligned = false; + if (startW != m[1].length) + isLeftAligned = false; + + if (startW > m[1].length) + startW = m[1].length; + if (textW < m[2].length) + textW = m[2].length; + if (endW > m[3].length) + endW = m[3].length; + + return m; + }).map(forceLeft ? alignLeft : + isLeftAligned ? isRightAligned ? alignRight : alignLeft : unAlign); + + function spaces(n) { + return lang.stringRepeat(" ", n); + } + + function alignLeft(m) { + return !m[2] ? m[0] : spaces(startW) + m[2] + + spaces(textW - m[2].length + endW) + + m[4].replace(/^([=:])\s+/, "$1 "); + } + function alignRight(m) { + return !m[2] ? m[0] : spaces(startW + textW - m[2].length) + m[2] + + spaces(endW, " ") + + m[4].replace(/^([=:])\s+/, "$1 "); + } + function unAlign(m) { + return !m[2] ? m[0] : spaces(startW) + m[2] + + spaces(endW) + + m[4].replace(/^([=:])\s+/, "$1 "); + } + }; +}).call(Editor.prototype); + + +function isSamePoint(p1, p2) { + return p1.row == p2.row && p1.column == p2.column; +} +exports.onSessionChange = function(e) { + var session = e.session; + if (session && !session.multiSelect) { + session.$selectionMarkers = []; + session.selection.$initRangeList(); + session.multiSelect = session.selection; + } + this.multiSelect = session && session.multiSelect; + + var oldSession = e.oldSession; + if (oldSession) { + oldSession.multiSelect.off("addRange", this.$onAddRange); + oldSession.multiSelect.off("removeRange", this.$onRemoveRange); + oldSession.multiSelect.off("multiSelect", this.$onMultiSelect); + oldSession.multiSelect.off("singleSelect", this.$onSingleSelect); + oldSession.multiSelect.lead.off("change", this.$checkMultiselectChange); + oldSession.multiSelect.anchor.off("change", this.$checkMultiselectChange); + } + + if (session) { + session.multiSelect.on("addRange", this.$onAddRange); + session.multiSelect.on("removeRange", this.$onRemoveRange); + session.multiSelect.on("multiSelect", this.$onMultiSelect); + session.multiSelect.on("singleSelect", this.$onSingleSelect); + session.multiSelect.lead.on("change", this.$checkMultiselectChange); + session.multiSelect.anchor.on("change", this.$checkMultiselectChange); + } + + if (session && this.inMultiSelectMode != session.selection.inMultiSelectMode) { + if (session.selection.inMultiSelectMode) + this.$onMultiSelect(); + else + this.$onSingleSelect(); + } +}; +function MultiSelect(editor) { + if (editor.$multiselectOnSessionChange) + return; + editor.$onAddRange = editor.$onAddRange.bind(editor); + editor.$onRemoveRange = editor.$onRemoveRange.bind(editor); + editor.$onMultiSelect = editor.$onMultiSelect.bind(editor); + editor.$onSingleSelect = editor.$onSingleSelect.bind(editor); + editor.$multiselectOnSessionChange = exports.onSessionChange.bind(editor); + editor.$checkMultiselectChange = editor.$checkMultiselectChange.bind(editor); + + editor.$multiselectOnSessionChange(editor); + editor.on("changeSession", editor.$multiselectOnSessionChange); + + editor.on("mousedown", onMouseDown); + editor.commands.addCommands(commands.defaultCommands); + + addAltCursorListeners(editor); +} + +function addAltCursorListeners(editor){ + var el = editor.textInput.getElement(); + var altCursor = false; + event.addListener(el, "keydown", function(e) { + if (e.keyCode == 18 && !(e.ctrlKey || e.shiftKey || e.metaKey)) { + if (!altCursor) { + editor.renderer.setMouseCursor("crosshair"); + altCursor = true; + } + } else if (altCursor) { + reset(); + } + }); + + event.addListener(el, "keyup", reset); + event.addListener(el, "blur", reset); + function reset(e) { + if (altCursor) { + editor.renderer.setMouseCursor(""); + altCursor = false; + } + } +} + +exports.MultiSelect = MultiSelect; + + +require("./config").defineOptions(Editor.prototype, "editor", { + enableMultiselect: { + set: function(val) { + MultiSelect(this); + if (val) { + this.on("changeSession", this.$multiselectOnSessionChange); + this.on("mousedown", onMouseDown); + } else { + this.off("changeSession", this.$multiselectOnSessionChange); + this.off("mousedown", onMouseDown); + } + }, + value: true + } +}); + + + +}); + +define("ace/mode/folding/fold_mode",["require","exports","module","ace/range"], function(require, exports, module) { +"use strict"; + +var Range = require("../../range").Range; + +var FoldMode = exports.FoldMode = function() {}; + +(function() { + + this.foldingStartMarker = null; + this.foldingStopMarker = null; + this.getFoldWidget = function(session, foldStyle, row) { + var line = session.getLine(row); + if (this.foldingStartMarker.test(line)) + return "start"; + if (foldStyle == "markbeginend" + && this.foldingStopMarker + && this.foldingStopMarker.test(line)) + return "end"; + return ""; + }; + + this.getFoldWidgetRange = function(session, foldStyle, row) { + return null; + }; + + this.indentationBlock = function(session, row, column) { + var re = /\S/; + var line = session.getLine(row); + var startLevel = line.search(re); + if (startLevel == -1) + return; + + var startColumn = column || line.length; + var maxRow = session.getLength(); + var startRow = row; + var endRow = row; + + while (++row < maxRow) { + var level = session.getLine(row).search(re); + + if (level == -1) + continue; + + if (level <= startLevel) + break; + + endRow = row; + } + + if (endRow > startRow) { + var endColumn = session.getLine(endRow).length; + return new Range(startRow, startColumn, endRow, endColumn); + } + }; + + this.openingBracketBlock = function(session, bracket, row, column, typeRe) { + var start = {row: row, column: column + 1}; + var end = session.$findClosingBracket(bracket, start, typeRe); + if (!end) + return; + + var fw = session.foldWidgets[end.row]; + if (fw == null) + fw = session.getFoldWidget(end.row); + + if (fw == "start" && end.row > start.row) { + end.row --; + end.column = session.getLine(end.row).length; + } + return Range.fromPoints(start, end); + }; + + this.closingBracketBlock = function(session, bracket, row, column, typeRe) { + var end = {row: row, column: column}; + var start = session.$findOpeningBracket(bracket, end); + + if (!start) + return; + + start.column++; + end.column--; + + return Range.fromPoints(start, end); + }; +}).call(FoldMode.prototype); + +}); + +define("ace/theme/textmate",["require","exports","module","ace/lib/dom"], function(require, exports, module) { +"use strict"; + +exports.isDark = false; +exports.cssClass = "ace-tm"; +exports.cssText = ".ace-tm .ace_gutter {\ +background: #f0f0f0;\ +color: #333;\ +}\ +.ace-tm .ace_print-margin {\ +width: 1px;\ +background: #e8e8e8;\ +}\ +.ace-tm .ace_fold {\ +background-color: #6B72E6;\ +}\ +.ace-tm {\ +background-color: #FFFFFF;\ +color: black;\ +}\ +.ace-tm .ace_cursor {\ +color: black;\ +}\ +.ace-tm .ace_invisible {\ +color: rgb(191, 191, 191);\ +}\ +.ace-tm .ace_storage,\ +.ace-tm .ace_keyword {\ +color: blue;\ +}\ +.ace-tm .ace_constant {\ +color: rgb(197, 6, 11);\ +}\ +.ace-tm .ace_constant.ace_buildin {\ +color: rgb(88, 72, 246);\ +}\ +.ace-tm .ace_constant.ace_language {\ +color: rgb(88, 92, 246);\ +}\ +.ace-tm .ace_constant.ace_library {\ +color: rgb(6, 150, 14);\ +}\ +.ace-tm .ace_invalid {\ +background-color: rgba(255, 0, 0, 0.1);\ +color: red;\ +}\ +.ace-tm .ace_support.ace_function {\ +color: rgb(60, 76, 114);\ +}\ +.ace-tm .ace_support.ace_constant {\ +color: rgb(6, 150, 14);\ +}\ +.ace-tm .ace_support.ace_type,\ +.ace-tm .ace_support.ace_class {\ +color: rgb(109, 121, 222);\ +}\ +.ace-tm .ace_keyword.ace_operator {\ +color: rgb(104, 118, 135);\ +}\ +.ace-tm .ace_string {\ +color: rgb(3, 106, 7);\ +}\ +.ace-tm .ace_comment {\ +color: rgb(76, 136, 107);\ +}\ +.ace-tm .ace_comment.ace_doc {\ +color: rgb(0, 102, 255);\ +}\ +.ace-tm .ace_comment.ace_doc.ace_tag {\ +color: rgb(128, 159, 191);\ +}\ +.ace-tm .ace_constant.ace_numeric {\ +color: rgb(0, 0, 205);\ +}\ +.ace-tm .ace_variable {\ +color: rgb(49, 132, 149);\ +}\ +.ace-tm .ace_xml-pe {\ +color: rgb(104, 104, 91);\ +}\ +.ace-tm .ace_entity.ace_name.ace_function {\ +color: #0000A2;\ +}\ +.ace-tm .ace_heading {\ +color: rgb(12, 7, 255);\ +}\ +.ace-tm .ace_list {\ +color:rgb(185, 6, 144);\ +}\ +.ace-tm .ace_meta.ace_tag {\ +color:rgb(0, 22, 142);\ +}\ +.ace-tm .ace_string.ace_regex {\ +color: rgb(255, 0, 0)\ +}\ +.ace-tm .ace_marker-layer .ace_selection {\ +background: rgb(181, 213, 255);\ +}\ +.ace-tm.ace_multiselect .ace_selection.ace_start {\ +box-shadow: 0 0 3px 0px white;\ +border-radius: 2px;\ +}\ +.ace-tm .ace_marker-layer .ace_step {\ +background: rgb(252, 255, 0);\ +}\ +.ace-tm .ace_marker-layer .ace_stack {\ +background: rgb(164, 229, 101);\ +}\ +.ace-tm .ace_marker-layer .ace_bracket {\ +margin: -1px 0 0 -1px;\ +border: 1px solid rgb(192, 192, 192);\ +}\ +.ace-tm .ace_marker-layer .ace_active-line {\ +background: rgba(0, 0, 0, 0.07);\ +}\ +.ace-tm .ace_gutter-active-line {\ +background-color : #dcdcdc;\ +}\ +.ace-tm .ace_marker-layer .ace_selected-word {\ +background: rgb(250, 250, 255);\ +border: 1px solid rgb(200, 200, 250);\ +}\ +.ace-tm .ace_indent-guide {\ +background: url(\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAACCAYAAACZgbYnAAAAE0lEQVQImWP4////f4bLly//BwAmVgd1/w11/gAAAABJRU5ErkJggg==\") right repeat-y;\ +}\ +"; + +var dom = require("../lib/dom"); +dom.importCssString(exports.cssText, exports.cssClass); +}); + +define("ace/line_widgets",["require","exports","module","ace/lib/oop","ace/lib/dom","ace/range"], function(require, exports, module) { +"use strict"; + +var oop = require("./lib/oop"); +var dom = require("./lib/dom"); +var Range = require("./range").Range; + + +function LineWidgets(session) { + this.session = session; + this.session.widgetManager = this; + this.session.getRowLength = this.getRowLength; + this.session.$getWidgetScreenLength = this.$getWidgetScreenLength; + this.updateOnChange = this.updateOnChange.bind(this); + this.renderWidgets = this.renderWidgets.bind(this); + this.measureWidgets = this.measureWidgets.bind(this); + this.session._changedWidgets = []; + this.$onChangeEditor = this.$onChangeEditor.bind(this); + + this.session.on("change", this.updateOnChange); + this.session.on("changeEditor", this.$onChangeEditor); +} + +(function() { + this.getRowLength = function(row) { + var h; + if (this.lineWidgets) + h = this.lineWidgets[row] && this.lineWidgets[row].rowCount || 0; + else + h = 0; + if (!this.$useWrapMode || !this.$wrapData[row]) { + return 1 + h; + } else { + return this.$wrapData[row].length + 1 + h; + } + }; + + this.$getWidgetScreenLength = function() { + var screenRows = 0; + this.lineWidgets.forEach(function(w){ + if (w && w.rowCount) + screenRows +=w.rowCount; + }); + return screenRows; + }; + + this.$onChangeEditor = function(e) { + this.attach(e.editor); + }; + + this.attach = function(editor) { + if (editor && editor.widgetManager && editor.widgetManager != this) + editor.widgetManager.detach(); + + if (this.editor == editor) + return; + + this.detach(); + this.editor = editor; + + if (editor) { + editor.widgetManager = this; + editor.renderer.on("beforeRender", this.measureWidgets); + editor.renderer.on("afterRender", this.renderWidgets); + } + }; + this.detach = function(e) { + var editor = this.editor; + if (!editor) + return; + + this.editor = null; + editor.widgetManager = null; + + editor.renderer.off("beforeRender", this.measureWidgets); + editor.renderer.off("afterRender", this.renderWidgets); + var lineWidgets = this.session.lineWidgets; + lineWidgets && lineWidgets.forEach(function(w) { + if (w && w.el && w.el.parentNode) { + w._inDocument = false; + w.el.parentNode.removeChild(w.el); + } + }); + }; + + this.updateOnChange = function(e) { + var lineWidgets = this.session.lineWidgets; + if (!lineWidgets) return; + + var delta = e.data; + var range = delta.range; + var startRow = range.start.row; + var len = range.end.row - startRow; + + if (len === 0) { + } else if (delta.action == "removeText" || delta.action == "removeLines") { + var removed = lineWidgets.splice(startRow + 1, len); + removed.forEach(function(w) { + w && this.removeLineWidget(w); + }, this); + this.$updateRows(); + } else { + var args = new Array(len); + args.unshift(startRow, 0); + lineWidgets.splice.apply(lineWidgets, args); + this.$updateRows(); + } + }; + + this.$updateRows = function() { + var lineWidgets = this.session.lineWidgets; + if (!lineWidgets) return; + var noWidgets = true; + lineWidgets.forEach(function(w, i) { + if (w) { + noWidgets = false; + w.row = i; + } + }); + if (noWidgets) + this.session.lineWidgets = null; + }; + + this.addLineWidget = function(w) { + if (!this.session.lineWidgets) + this.session.lineWidgets = new Array(this.session.getLength()); + + this.session.lineWidgets[w.row] = w; + + var renderer = this.editor.renderer; + if (w.html && !w.el) { + w.el = dom.createElement("div"); + w.el.innerHTML = w.html; + } + if (w.el) { + dom.addCssClass(w.el, "ace_lineWidgetContainer"); + w.el.style.position = "absolute"; + w.el.style.zIndex = 5; + renderer.container.appendChild(w.el); + w._inDocument = true; + } + + if (!w.coverGutter) { + w.el.style.zIndex = 3; + } + if (!w.pixelHeight) { + w.pixelHeight = w.el.offsetHeight; + } + if (w.rowCount == null) + w.rowCount = w.pixelHeight / renderer.layerConfig.lineHeight; + + this.session._emit("changeFold", {data:{start:{row: w.row}}}); + + this.$updateRows(); + this.renderWidgets(null, renderer); + return w; + }; + + this.removeLineWidget = function(w) { + w._inDocument = false; + if (w.el && w.el.parentNode) + w.el.parentNode.removeChild(w.el); + if (w.editor && w.editor.destroy) try { + w.editor.destroy(); + } catch(e){} + if (this.session.lineWidgets) + this.session.lineWidgets[w.row] = undefined; + this.session._emit("changeFold", {data:{start:{row: w.row}}}); + this.$updateRows(); + }; + + this.onWidgetChanged = function(w) { + this.session._changedWidgets.push(w); + this.editor && this.editor.renderer.updateFull(); + }; + + this.measureWidgets = function(e, renderer) { + var changedWidgets = this.session._changedWidgets; + var config = renderer.layerConfig; + + if (!changedWidgets || !changedWidgets.length) return; + var min = Infinity; + for (var i = 0; i < changedWidgets.length; i++) { + var w = changedWidgets[i]; + if (!w._inDocument) { + w._inDocument = true; + renderer.container.appendChild(w.el); + } + + w.h = w.el.offsetHeight; + + if (!w.fixedWidth) { + w.w = w.el.offsetWidth; + w.screenWidth = Math.ceil(w.w / config.characterWidth); + } + + var rowCount = w.h / config.lineHeight; + if (w.coverLine) { + rowCount -= this.session.getRowLineCount(w.row); + if (rowCount < 0) + rowCount = 0; + } + if (w.rowCount != rowCount) { + w.rowCount = rowCount; + if (w.row < min) + min = w.row; + } + } + if (min != Infinity) { + this.session._emit("changeFold", {data:{start:{row: min}}}); + this.session.lineWidgetWidth = null; + } + this.session._changedWidgets = []; + }; + + this.renderWidgets = function(e, renderer) { + var config = renderer.layerConfig; + var lineWidgets = this.session.lineWidgets; + if (!lineWidgets) + return; + var first = Math.min(this.firstRow, config.firstRow); + var last = Math.max(this.lastRow, config.lastRow, lineWidgets.length); + + while (first > 0 && !lineWidgets[first]) + first--; + + this.firstRow = config.firstRow; + this.lastRow = config.lastRow; + + renderer.$cursorLayer.config = config; + for (var i = first; i <= last; i++) { + var w = lineWidgets[i]; + if (!w || !w.el) continue; + + if (!w._inDocument) { + w._inDocument = true; + renderer.container.appendChild(w.el); + } + var top = renderer.$cursorLayer.getPixelPosition({row: i, column:0}, true).top; + if (!w.coverLine) + top += config.lineHeight * this.session.getRowLineCount(w.row); + w.el.style.top = top - config.offset + "px"; + + var left = w.coverGutter ? 0 : renderer.gutterWidth; + if (!w.fixedWidth) + left -= renderer.scrollLeft; + w.el.style.left = left + "px"; + + if (w.fixedWidth) { + w.el.style.right = renderer.scrollBar.getWidth() + "px"; + } else { + w.el.style.right = ""; + } + } + }; + +}).call(LineWidgets.prototype); + + +exports.LineWidgets = LineWidgets; + +}); + +define("ace/ext/error_marker",["require","exports","module","ace/line_widgets","ace/lib/dom","ace/range"], function(require, exports, module) { +"use strict"; +var LineWidgets = require("../line_widgets").LineWidgets; +var dom = require("../lib/dom"); +var Range = require("../range").Range; + +function binarySearch(array, needle, comparator) { + var first = 0; + var last = array.length - 1; + + while (first <= last) { + var mid = (first + last) >> 1; + var c = comparator(needle, array[mid]); + if (c > 0) + first = mid + 1; + else if (c < 0) + last = mid - 1; + else + return mid; + } + return -(first + 1); +} + +function findAnnotations(session, row, dir) { + var annotations = session.getAnnotations().sort(Range.comparePoints); + if (!annotations.length) + return; + + var i = binarySearch(annotations, {row: row, column: -1}, Range.comparePoints); + if (i < 0) + i = -i - 1; + + if (i >= annotations.length - 1) + i = dir > 0 ? 0 : annotations.length - 1; + else if (i === 0 && dir < 0) + i = annotations.length - 1; + + var annotation = annotations[i]; + if (!annotation || !dir) + return; + + if (annotation.row === row) { + do { + annotation = annotations[i += dir]; + } while (annotation && annotation.row === row); + if (!annotation) + return annotations.slice(); + } + + + var matched = []; + row = annotation.row; + do { + matched[dir < 0 ? "unshift" : "push"](annotation); + annotation = annotations[i += dir]; + } while (annotation && annotation.row == row); + return matched.length && matched; +} + +exports.showErrorMarker = function(editor, dir) { + var session = editor.session; + if (!session.widgetManager) { + session.widgetManager = new LineWidgets(session); + session.widgetManager.attach(editor); + } + + var pos = editor.getCursorPosition(); + var row = pos.row; + var oldWidget = session.lineWidgets && session.lineWidgets[row]; + if (oldWidget) { + oldWidget.destroy(); + } else { + row -= dir; + } + var annotations = findAnnotations(session, row, dir); + var gutterAnno; + if (annotations) { + var annotation = annotations[0]; + pos.column = (annotation.pos && typeof annotation.column != "number" + ? annotation.pos.sc + : annotation.column) || 0; + pos.row = annotation.row; + gutterAnno = editor.renderer.$gutterLayer.$annotations[pos.row]; + } else if (oldWidget) { + return; + } else { + gutterAnno = { + text: ["Looks good!"], + className: "ace_ok" + }; + } + editor.session.unfold(pos.row); + editor.selection.moveToPosition(pos); + + var w = { + row: pos.row, + fixedWidth: true, + coverGutter: true, + el: dom.createElement("div") + }; + var el = w.el.appendChild(dom.createElement("div")); + var arrow = w.el.appendChild(dom.createElement("div")); + arrow.className = "error_widget_arrow " + gutterAnno.className; + + var left = editor.renderer.$cursorLayer + .getPixelPosition(pos).left; + arrow.style.left = left + editor.renderer.gutterWidth - 5 + "px"; + + w.el.className = "error_widget_wrapper"; + el.className = "error_widget " + gutterAnno.className; + el.innerHTML = gutterAnno.text.join("
"); + + el.appendChild(dom.createElement("div")); + + var kb = function(_, hashId, keyString) { + if (hashId === 0 && (keyString === "esc" || keyString === "return")) { + w.destroy(); + return {command: "null"}; + } + }; + + w.destroy = function() { + if (editor.$mouseHandler.isMousePressed) + return; + editor.keyBinding.removeKeyboardHandler(kb); + session.widgetManager.removeLineWidget(w); + editor.off("changeSelection", w.destroy); + editor.off("changeSession", w.destroy); + editor.off("mouseup", w.destroy); + editor.off("change", w.destroy); + }; + + editor.keyBinding.addKeyboardHandler(kb); + editor.on("changeSelection", w.destroy); + editor.on("changeSession", w.destroy); + editor.on("mouseup", w.destroy); + editor.on("change", w.destroy); + + editor.session.widgetManager.addLineWidget(w); + + w.el.onmousedown = editor.focus.bind(editor); + + editor.renderer.scrollCursorIntoView(null, 0.5, {bottom: w.el.offsetHeight}); +}; + + +dom.importCssString("\ + .error_widget_wrapper {\ + background: inherit;\ + color: inherit;\ + border:none\ + }\ + .error_widget {\ + border-top: solid 2px;\ + border-bottom: solid 2px;\ + margin: 5px 0;\ + padding: 10px 40px;\ + white-space: pre-wrap;\ + }\ + .error_widget.ace_error, .error_widget_arrow.ace_error{\ + border-color: #ff5a5a\ + }\ + .error_widget.ace_warning, .error_widget_arrow.ace_warning{\ + border-color: #F1D817\ + }\ + .error_widget.ace_info, .error_widget_arrow.ace_info{\ + border-color: #5a5a5a\ + }\ + .error_widget.ace_ok, .error_widget_arrow.ace_ok{\ + border-color: #5aaa5a\ + }\ + .error_widget_arrow {\ + position: absolute;\ + border: solid 5px;\ + border-top-color: transparent!important;\ + border-right-color: transparent!important;\ + border-left-color: transparent!important;\ + top: -5px;\ + }\ +", ""); + +}); + +define("ace/ace",["require","exports","module","ace/lib/fixoldbrowsers","ace/lib/dom","ace/lib/event","ace/editor","ace/edit_session","ace/undomanager","ace/virtual_renderer","ace/worker/worker_client","ace/keyboard/hash_handler","ace/placeholder","ace/multi_select","ace/mode/folding/fold_mode","ace/theme/textmate","ace/ext/error_marker","ace/config"], function(require, exports, module) { +"use strict"; + +require("./lib/fixoldbrowsers"); + +var dom = require("./lib/dom"); +var event = require("./lib/event"); + +var Editor = require("./editor").Editor; +var EditSession = require("./edit_session").EditSession; +var UndoManager = require("./undomanager").UndoManager; +var Renderer = require("./virtual_renderer").VirtualRenderer; +require("./worker/worker_client"); +require("./keyboard/hash_handler"); +require("./placeholder"); +require("./multi_select"); +require("./mode/folding/fold_mode"); +require("./theme/textmate"); +require("./ext/error_marker"); + +exports.config = require("./config"); +exports.require = require; +exports.edit = function(el) { + if (typeof(el) == "string") { + var _id = el; + el = document.getElementById(_id); + if (!el) + throw new Error("ace.edit can't find div #" + _id); + } + + if (el && el.env && el.env.editor instanceof Editor) + return el.env.editor; + + var value = ""; + if (el && /input|textarea/i.test(el.tagName)) { + var oldNode = el; + value = oldNode.value; + el = dom.createElement("pre"); + oldNode.parentNode.replaceChild(el, oldNode); + } else { + value = dom.getInnerText(el); + el.innerHTML = ''; + } + + var doc = exports.createEditSession(value); + + var editor = new Editor(new Renderer(el)); + editor.setSession(doc); + + var env = { + document: doc, + editor: editor, + onResize: editor.resize.bind(editor, null) + }; + if (oldNode) env.textarea = oldNode; + event.addListener(window, "resize", env.onResize); + editor.on("destroy", function() { + event.removeListener(window, "resize", env.onResize); + env.editor.container.env = null; // prevent memory leak on old ie + }); + editor.container.env = editor.env = env; + return editor; +}; +exports.createEditSession = function(text, mode) { + var doc = new EditSession(text, mode); + doc.setUndoManager(new UndoManager()); + return doc; +} +exports.EditSession = EditSession; +exports.UndoManager = UndoManager; +}); + (function() { + window.require(["ace/ace"], function(a) { + a && a.config.init(true); + if (!window.ace) + window.ace = a; + for (var key in a) if (a.hasOwnProperty(key)) + window.ace[key] = a[key]; + }); + })(); diff --git a/airflow/www/static/airflow.gif b/airflow/www/static/airflow.gif new file mode 100644 index 0000000000000000000000000000000000000000..1889b868f708aaaa9a29a3ef2613c8436b8de384 GIT binary patch literal 622963 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Π)2qR++l1m9b^F`5NSMA10KO%hxY~v$l$zE;V!APWOrkOH^M zuZi~!V9NkO00lo>geV6M5dg9g00lV^vPRHy&;SJ?%fTtOz)AZr9c z>kq>Kwvp@)uTTb1(6b^d#VB{P9?J!ykO{P0%e1_?H3!NcE6OKV${>5n{(#9JtH~(G z$r0emHYvuJiN?%4G=NIMZ%cG9{J~G>yslhx9_+_TXU%~OvkBacjcjy^e7-*y&RGe? z->Y)EyT^&wz8@58p7fFl!8N9M310)HL1FFYOQSfYNS_%ZPk(iR}+=+}D3C2t>^VjZo4k z_Y93d)0EJ%Q~eEot#ih3*()vBE4SI&jMt03urUp?lrYsOciJiU*@11-D>u$}eaD== za%>&eZ#}kot&?^ccn+Yp7$FRn)0L{NHm%h}ja}NHo5Uz3( z4$=OQ;JD4;IVl0n249u=oBO%ZEZ*W$)zNJWU|%HCN=MQ#XAH1Bw@`4p-+<0@%LVa_ z(?#sQATH3-TeFC3j1nEPIpD`QPPa~rawsgal3Nc;PUV&?P_xzRu|%A563zfHaC4k2vN`JyfbPel@aP{K=_*ItDc1ub zd(_`h=uMmE#Vos>F3+T!18}Z$Jx;nno^q2;=P0MkevZ0ETMs@CvVw4OWiYov(6G*X z3%AhaVSBE_AnFm|>MED$I&QcUjRi?|s8JH|ZQKuLqV1gAM!v6cQDS+NPSMLR^a`+yGe z>9VWe;j8ja|FKYD=Qo_RFh9RETys7Dxu+cV3}6Z=m%0+*?J&3R`K_*k>;VMTmDquvXQU6p-c0- z>(l@tiormF1q+Jum(ZUvgAE57QMl0HtB4gNa&g#D!Z&{~G7c=0aM(wKDegrKLNWq_ z3j<9i6htrr$Al?K7R(fJwf;OXiP%EVW9opG8|+tU&bHLf_6L8qT0W# z35K+&cpzY*w0R57K`@BrfiR4jj;Naf?uyEJ$KI;YH*)ILtzXBUUHf+K7B5^#kpjke z7&*+F?~#7^a3by9zlR@R{(SoN?cc|rU;lpo{r&%+Z@nPW>)|}}$b%ug@45pmEZK%4 z!YoR@c?X(C%%X|53nqNWm^=(B&9>bV`lhfPAOlUg2is~W4`|*Q<}R6Nax1S}&N*io z%$R!T98pA}WVC^TFh!K0Xk!41VR{@UID&pdl7S?QsW6F&5-aQiQ7AM;M#tVss2&Xp z%3(=pYGj}h>8`9x#XO#=un{2#68_{gQB0950!n_QDMFbzv=Bpu1S$v$Dxvw1C4vT< z5iEoBq)?QfTv~0+KXJT;vx0)UPO^cBSn``jCS@Q9g~Ti<34c(%4Lf7*tkI1kk7H4* z$P5DY9Z^X&wWdY)vP^=cPPKHPGAl$=%~wr{byg95dNa;B8L0C*4LS4>#Dts+?ANL! zq%cu~@;K+6TMEP=D5Zjiq5N_!)-rBfxQr>5OOU^Hl(<*3WEb+>TI&O0l*|d%sn3qAB zXQsJkn`!hxraV*V719zSbRbR> zikCNT&nZkbNO9FHw6^4qb=sY^Q}zpifZpnU$IVl-La?*e-iP66o0B9-~DmC`G`c% zYc=Xt-`bz9E4IEO!S<>#0$RB?2qA^4rxbjG3Z}g<-7kXu+19JR+W4cS`kkO^`yWW> zwu2^+Xpbs$qf12o;srg2P1|wLP2_DaB zCq!WiRmh;6iHAHqQxAKLHp8G9!G<@)VGeb;Lmu|fhd%^j5QR9z9|mYR=`m0a$iuW1 zLTxCl+E_%+F{hwxZXg!)$2an@MK_@3S$S|+5)9ZTzpSW6%mE-!5(2>pOz$*q*diED zHo`T^gl*q4VcrI!f!!qpd%r2xiWKt4gm{TE3Yl9_&XETWfIn9`rH~s;hg*fkr_Ni9W)~%{9Z)4sE)pN$SaR zjPZ(DSoHpqJl5UObEC86CvWFBg-DVhl-%ScxA;oaSdnfO(IOYUxH9pLu0~i)odL(^ zzG8NhAgGL#d2mbTQT zFNNt1jX0nJDQ!GVRG|}xa*ucPw2!o8CX#X>3S&xbNN!_`%F0+anRxMf^+OXEmPE!3 zCPW@RmB(!SG8WoUOp0-%4Hm-`M>mvaVCt+$J^TW{>o{yfQ5z-j62eoTS|naUsS*FM zIsQ-b0WU1~tcbW|qa1?z!wvPb&OagGCd>&%2oRH|?jCEkCfL#*r+>XY+@ z_L}&73z+J{J_ZnBD4z0~Ul?XL$?X#~(5UAX*Egxf2Cq2#Y^X&__&+}yBxcVrMtaq| z-h?o>yTY_C^|E{1grIl5_YG-&^}Aow=~)os6H@{g*uV!yaDs~npCcmiJWE?p zPUSk4dwgRUnaBeaNM%|mE`|$~kS#eILf(cg6@UpOnL3SIkPwuCwJfV@MdSgE{#P`j z4D;nGVo%GP-kF9rA8tgDg*>;yn#Qv-A%Yc4u_-E54Z_y#Og19C1T%7olNk=aj}k)`vMQ}Gl!H(4xICEA=7 zrOw>ws2nu`C&qJ*VrcRp2wIPEZoF|@LrTNyp>)h-h-}Ena;+`?7TehS_3wCK*aI25 zN5RjAcC@8EZJ9EdrZqj#5+j^Koeo7Fjd)o`I29#>Y{;k%d5uzmcjeOla!S<=fq@&j z{1h$}DyFFIIn$V;G2hLO!x|C+Hz*f?x%J1l!|jlu9CnYtHJ+eXRNFH*iZ&^m zjhB%luKQ63d#uZ|WpHE28u68$xVB2Rz%H>O^5 zaUa|4XIEje%LCvT!iU;#hkM-R9z<&=?ZG$QHVV5vn`x0`YtX1yMqq#&SemY$5W01#+@y`CnWqc=Gnr_J29A$&n!i5Q)fwlSVF&g?D+K)pQUA%k zj&}a{e`ac5wq|ROxtq2mFu(&uzywsl1!TYmbifCEKmrt^x^u8>OOU)fp}qU6a)W|A z$OMVfI3KCEjDR13D}!kvF)R^>mSaAZ^NIBv z3M-0$B!GeHJkG*WI8yG03o69 z6lwyE&x=A>(mWK4ITz$5GlV$QD+nWS8&Qe~AtXX1#6u;7H8PZkCVaw&+YS-5H-cfl zQ*%B-i!ry*x%y+l(^4eH=qwKeL9{Zz(h3QVYckWgg%G%@qPn_vn+m66#E4tMG`gaF zs)wQ*BicwoSj)Nw@s@=8MM8;%P z#${wi2b4g%dyfXgHYLKlrjfAet2lxPfq3G<<`{<0vlWM_lhskW4@x{KgsX0hh;J~3 zLyNxp6Gww1#XW?v2%-&93ll*IGC@cR+Q_pL{?ar-U<^)Sw;I{T7G$Q_ShB6NvMxll zY#cP@NUFfnIMaxYN&5zU46=|@h+#7pkz>Prxk#bNNFfV3%Mc^xVgRqwt$%DY*po41 z5J<-14j2K)h#bY$Ys5m#xxJ9S6?8;AVt|88F^sB2g{UV9cn2?;Nb5+mdVD{N@RU}A z$`Z`UqjV_zb4j^SN{d*XR&vGO=z$_6g6{H?QtJ^MQOmVN5w4U+tjV7`DhLg5$+TQc zw*1As)Jv)eM)Y7d0dqDTYR17N%)&Ix!z{*U1R@HYMs2f3Zlf~exxCY;pI6XGmQ09q z(3G!G4z{uk&O0|VqB1k11bq~Qjv_t&K*YJQ+QK~Q#+9Ls=|aeBV#T4j1yjIHm#m9t z&`3exNQEdNRARTVm`p5$%T}?7hdRW0R5Vipm_JjiiUf^^!c3tQAdN8ys^p#AGbQSb zD}KW=z$zbHs!gG|&D;bcCJ# zvmIDlj#Avq6jjl>=*t{vyTHsL#FWt)rO_H~K*d}z_mD=Ly1>WOyU4^c7!V3@3<^z= zfcErb+4C6Y(gh)z*^OTu*DD%O+ zT7t+25!Bx*)bU%5&%v87Wu3Rwt^di=cxk0r@}KK4QY2N~hi-wbpB$iWYs*0TfId_113%*JZ>}A7V@%O#uva8VytkRq~*RdK=)1u{;68 z`YRtGgi6uGMk!5*m;Mp8cc`aTU8q|j2q@5=bI2|srAI^@p&HQuwt-OSnVY~#jCu+f zPkC3&%Z#5&q%;yBcC83^^&NwX0E`<3#=y@#%@<-F5}J62n;bs)D-`XCm!xu-R0RHEl0aiGb`K z21>AufGQs@-~u%%-P29ohFD#WP}!qUl!iE2S-mofVu{vh+Rs3e4SAL9Qq+X-taLfv z52X%^O@cDe!z=q=*cAl9SN|;CoQX@zq}QH4E{p3)c++*u4eW zl{0^cyhpl)MA<5<1Pq*Ts`S5)J9J~lQ6Om6;- z(Ky!UeQw4%-l00CM$x@SL;lmo__}Q7#Z;|ednM%DC1^%_2L64X;}d1sphCRR3rMCT z$|MOGz|>D(6t=j*|UwgocNNj;!M+3BkXJL}>x9H5(E4wVe!I(wc> z1P%SwjGN1+;59@8sy(j)X_qi2px%pi23+fS2ZSC7Q5$M${Z~?cz8ii=R=wJ22CCBY z;i1r&L8gv+2t0?#0h7BdiT>1cgWGUEH@p}b$V#8OK<9M+2qQpIp`h!jRhN>c=fLhi zd%j#Z=I6skY-jZ690F*^99`w13R7qe>Ai$p|7?vUi}U-;nHq z%SFp3K1%3;p9ld#0F&B+p!;!02erI(yfM^D0_Sx>Ymq+~5FmqcAgGMWFC!Jrm~LY- zhVO2rz8UWfoy*`zd7&K0B(m6?(WD0%=QV*HcutirwMv*(#~qG&a!4^iUSv| z<9?LnzLM(ZJ=zE;{`PMeaMsVTBfa2x1oO8qY7nhEW_!>>cNE1YB&!j>c;%q{_;Ipzi+sOOrV5J zc!g(pLluPvQ=o(>zXhKrJ{<9_=0M#&p_1!~@?lWx>d-VOZ-fs7je2;MEH?!b-VPXn za!N3TF|P~bEfj@7AB25H?a&x9@Kjw{cETc4^G zHHIv=b1t9r3xjh;&sknK1z)%EW8X_0$7dby@n%QtAIG7_u0~|1_G-6w6$I^GT2Ss7 zNsGu6VZZip7k6v7j20jFb4T}dw{~SG0?c*xcaLLeuQnb274p!%V|B;(eBaB>^dNZ{ zoPntC?toA+s)*Gw39}A?gje{5XZR4%02;3uBX0PKS9oaBchf+4il6w0zxe&KCd1{L z3()w9xA=}X`IAR^nPGQ#hxeA(TzR)4X&2~}r}>({nVZamVR(jTkdyennR7Vrh=PV& zs9wZ)hZv;d6@urt*lT@1`hQyvn|B(qD}y5==B0NCxRCm-=X#r8dBb-3u=iG&Hv*Zz z_pV3#wErL(Z;0+<5O>Y%LE-her+c~wOPdMvy4U+GH+Pyp^1avlyH|Vbuvje&9l+oF zz90O>XZ(5o`g|7q$gfedH+v#){L8QTz9k6>aC`ol5$2f1{Lp87a+77zH~rJM@yBO& z$!C4Ur2HVaJ3F>~)Te!Pk6`+4dhLLQGKfJ?sr}!ld5gV(&IkVDH~zd-{Tpii9_WGc!puPOdZ;+^47zSvla_Sd&^lSF&vB@+HieGH24PY4aw|oJmmf?CJ9-(4av{jtu#+ zN5>o*VWhZN@hQ}(Qm0a_YV|7CtXj8n?f&ZZE7-7N$C52;_AJ`8YS*%DTlHebjU7LR zB53$I*DvbSs#ohA?E1CP!iSM|RP6LK?%cX}^X~2YH}K%XhZ8TZ5U%LCAWM%t&H6d? z=%`^+uWnLykfVt;E;jD{JNWS8$CEE_{yh5P$mMb*ZMUcN`10q|v~J%ucI~yf-%juU zKY#%WI3R%q8hGGZ>%lPEa_{j(AA}LkcOQj-<%igQO!bE#haGzOA&4Q0I3kH@Dac@5 zroks6i!G*BA&fs=NE=7CF;*gu{yFNnBac1$_@i$pDwkq&E4p|jk~zh=BuX=Gr{Rr2 zN;xH!Ra$u^mR1VcUV|z=m?W5C#>6Con$g; zW{vzs2`8X|3OXpEg&JCrobGvv5}u8Qsppb>D&^;)m0Ef!rkQFgV4`%EWTc~^vIuF6 zlG?Z_s;R2FDyywJ)+v#n+9@in^pOgqsg%09E3du!`YW)AhQ{8Uu{u{PveMmJVXjaH z`z*B4N;_?qurenrqseLu-Le!mizZk1K=aJF^-yIDy6LLBF1zm>bdE9K#QPAu@_r*# zz4JPh@4Zp+OK-gTQadpI!37&UnY9jPyDh^nc5C0a8)9`%84Or_F&F(V^bSc}+*8my z5?nko$t7DXN8sW$Sp5K^Tjq(gtNsuC&V)YJ|8?Z(M21bRl^*~@pE_T@h zK@oWlm=E;%*r7v2y4a={g!X-iz z`V6>#vH1hVKflHG=e|Gx{inM7v3@7OF$E7cjmrupMy3I12;>_pyI39u0u48C;epbt zoj>I9jSX_}967jH1dMSG4sy_3n!A`F2GWR;N#GortDePDD3BFitc49ZVGLEsLKg}I zhYXORKWJz%8#2U)KolYai0BU^>ad19^dA(XC`CX45L5%aVoM5Wz~Sk~cvP#31tahb zf_y_{Lo}B^hB3D3H6#ci)R;j`F)}C=#0?{(#6aYDF**{&ju*otL-c4dJ`Ti>76YU| z1jztG3jV~93_zqp6v;?`I1-VPtm7T0sL4%ol1f#a6&6Fe6D{J0i^ZGN@vH(3%UO&I z28rIqh9MBhiH(g6aihf|0LOxG0fY=B$T`f&03r;em%04qK!SNOVlu>-42S?S1)@xg zG1DK;gl0jeIn0J!Gn)R;W;MH+O<`8Eljb}pIz9Bs!-*1$q%0*#q;@8WO$B|ED+n#~ zClEE(5`zfA+D@SL;23Z9PlcJ)TKdi$VYiVgM`L_p)D5zP@Ep5 z{)`uM!$AZA!Z8}vVn{WJQgb=RG?J`yO=ZAS3li05GF6yaCCFCAxX-FK(5p#Zsz0v! z)q#X{sx5shT;mFymzpG|qMWHfXCsXXm8apz`T9yHmB`Z}EYY@qX_OheRtXxy8+SO`?t|_srJMn5EQ_={P zUz|!{{h8Z?;83s$F)TtT+qr^}R=AiY+bltA+|wd=xx_`SKPcH7WmA(eCuR&f>!^7e*g)$x@jbSK5=((ZCWiW<7Zk)s%qtG8hmH?4+_{}`@ z*bP4p@{D<_VJhH@MJ!?yYs5-D_K0+tJQ;Wvxr# zUX#1ras4&0nU?KDA5E|RD3Ewm+;7eIPb!S zd?588-!=_WF^gs#>*XBC)=9dMt1t5)VQ*g7%dYj5B82SJH2yn7-JbP=oZWdwzdPOs zhxDW;Yw1lQHhZn|jFI(RAW!c&vy+|6!+P$}1kw1#axl9g<~idqlsrRFS7*pu4D}9u`ptblG9b>p z@G}SEIYeQK0baMQvm`X{|#XOfsg?% z;5Yf-10Iw3RiFj>3i*|vqnw`+*q&7Q&;{f{9;_J!{?1>E)n7m<#8wSb9>5<1lz~=} z3>35=$-tn(aoY+m5)F=w4YHLC?%>7r;DTve4$hzp3ZXv~ArM*&5?-JaK4GS0pa$Xz z2YR4XDALvFPoVWx?+L{39fSp4&F&3U&%bpX$Hf_rF+@E$QV_1; z>FFX9^5Pj9oIvEF$&>*wTAeSxATVyCK@em9$rxioIAaYSV<}dnHBQMXq9T&0Vo|6U ziMircK!Yey0wrXETeXEe+yXgL!Yfc?L8Rk4wj)%)BRR?=L(n5Tx}!tjqdhJJKkDN( z2BbjZNH%Jti*Tc1c;lH3Bttf&LsmvX8sw;)pA|l&MP4LEdPPJ^WP~83JXIPvW~4}t zBuPHSMslRBP^2H;R!O#`OTHvRoFq!hhes+TNWvse<|IzaqEHs4Q$A%i9wkz;MoluM`avaDX5|w~|ri7mAEm zm>FkMLgq>u8*^SKc2bdZikHH`lyv$ebpmBwvSoIbr+KnTaek*tASZdAr+dC9Hg4ya zK_@7wCsnfMTCya3?k9f|q(XM#Q`g!;#VerJP* zWQ1NQhKk38N@s;;sE2;2hW`Giq8N^M4k&@*Cs?*8h^{D$?#6~1XNR_^jLs--yl8L6 zD2?W*j#`F|(k71XD3AuJTlA=E{-}^1DUy-}k%p#`CaIG?sa7oMWi~04R;iUjg_Jrb zm0l^Ac46~_Do!%** z=4hTyWraS2pbjdb7OJ5hDxxN;qAn_ycB-d- zDyW94sE#VBma3_qDypWcs;(-lwyLYXDy&lKpCVXGmgc@rVZZ(>!A@qt zYT&>YtilTB!IodbE-b{}Wy8we!$z#dj^)JiUBzB3$3kVs&O`%*tjLZm$(F3io-E3y ztjd;b3iQBAY;4EI>`;EJOsuTU-Ym}M?8v6TdZOrf8mP<$?OW1pOz5o99xc*_>?noj zQl==-3N6%%CDFn}(pIh2rfdqpY}9ToMo#TZTrJp!ZOHb3&;BLXo-M|TsHc#Z0Ew;J zR&5BBEn=enE!}#o+Jaeqw(Z;Qt^5~bI=1m6}e&id_b2Cm}51uJu@N6J5+|_|FEJB0u@gTr6xYBAY;O%P1P}W#6lbv(Z!s5lu@?)m z6jL!nOfd%Z@E4yk8mF-uuQ3~QaTsGj6)(gUw=o^pu^l(@8^^H?-)(@F=m6nx^)jtm zI&JPw#1>HT9VfCPFY+SK?-nS;A;U2vPckJZvLi$C_ExecZ!#x$@)2BeLPRnadon4P z@(_bEeBLl1U#$*HMJFJ#5a56;&oV97vMt{-F6Xi??=mmKKFbA_R53?)_!Tu+2 zZzmwcD@$@L6SFfvGc-rDG*7cJ&u=mx#4`JEGfy)&ce6KtGdPDcG*>e+D>E#MGdicU zItw#7W56}Prz)><)wXg}96=c$!8+%&KJPO>M2@ev?|J&yoB_p?DCv^xJY1_bm$ z40J&wG(<D9N+i((_Z?!xY!RMz1tW>$CQb z00yBn9Je%0*Yr!rbV}E>PVY2N-?Txm$g}cGgymt#&Pvow>2@R{&gxJwbF>R zQZF^wnsi0T!}hv0U$Zn=tIa#^bzcwmF#okd=rslqwqh?fEgN=VCpKe8_AEQLd&aX} z>nvVV1Y%1zXRq`20yaTxc4v>aEPwW43pQz|HdUMUW2?4evvy_6HHp$S7F{+g&r}X* zK*+)X?{r{w25&^%17gzv4iL9-7dLVrw{j;pb1%1ZH#c-Yw{%B0bx*f-S2uQFw{~YY zcW?J`f41ld_x5^scb7MLpSO9ZH+rwPdNVh86NGqU0DI4Od(*dl*EfFOw|?h$d%yQU z#P@y&IDZQ`fe$!z`!~AEc5f$jZQJ%dzY`3UEXDnHRRA~*TsVgQXSjxMIEQz*hkrPT zhq#E3IEk0IiJv%%dpLsogN3KKi@!LG$GD8oIESxzi_2fCnBxtR~Tp&vR8 z6gr|WI-?)DqBlCEV>t%MLl*{T@A9llXoN}x!JMZolb7tB*WQSktN|FnpKE!fr@E?d z_@l47tM_=Tzq+i?dW^?9t=~F{uQ_)1ZVs$x1Hgbv^g#ZnqwJ@XY^b9jl#{xtQ$>8o z01Y@hv_HGFM?1AoyR}z4wqLupXFIoVySH~cxPQC2hda5CySbNpw0}9epF6v+ySukL zyuZ7=$Gf$wJH5|4zTdmP=R3deyTAAQyxTj#|2x4CyulZ|x9hsQDmdHzP6H4@u)n!U z2>Y;aZk>ZV(~THYqyWc$TK@)#{kt=z13em z)@QxeZ#~y{z1M#|*oVE?k3HF!J=d$d*{8kQul_yTx4qkIJ=(v$+|NDT*S+1}J=@1U z-tRr%_kG#_{BqK6uO|T!*ld?6d9jZy*%rRB5At|I{jy8>)cZZvQ_Zwtj?H z@WmUu5fJ^(I{wo~z2tB2=`TO?V}9zx_vAOf^iMzOPrvnFfAd#A_HX~*1HN+7?s^hE zu-AU?!vy1Fx)JDh>H50JYCuXDK_DMJ#uLBDhJf*B2;_%6_wPUdcfI!aKS0C~IFMjL zg9i~NRJf2~!-5zv$h+r{qQ86cAUf2z{*hxxj~_vX6giTlK!_46TEvKH6*`n?QKLtZCRK{_!i5wtpyrU8qeqV*L5O4}VuWi~BT2x9 z6+4z}S+i%+rd6ASNCUSp!1i$4phwsqP=kaG;+65fNjMQWNXpJ8DsqDHjYxnfW{bl z>;Sy%PHS$Y5nfD^#SwBGZy+Q)Xwpf9pp^0>B2Pq7$t_ugX@m}KHnq2pg-p1`?Z{yy2F{ zuC1=4lTHF5`|1_4_6Hjvq4vj~Bh;>Y?Wy;@_inpmw1+2q0%3CR!TG-0?|tehTyeq; z_XqLCoz5Hbss5GuY3|9bj^Xe+7;n1m$2$)_>9dCw-E_=PPrYl&RfoGGU$_pWqO-L| z-FAWe_DAa5c{d%;iGdGZ_~D5Ms$v)jwaU<}H162>=OsLF!A;Tnw9-rcQZPXSXYSr+ zn-_zDt5Y*ml~tl$HQK~AQIFfFPI@0*>(ndf$rrs9Z~N`V^^b82zvtFE#pO+aON)Uz ze3G{Kh35(Vqm6MNKvf=sIc0zp@}&poh%O{>A?AXq{bLXLp8 z` z7zhQvk&mtEUmsgjKWr3Cj)BO<3$?~1K(6MGi&Ty5_Mr^b{7-IlbL1siBRNdEMu-0J z#T@k*h)y8#a+@@zYIOLxQ?7D0sZ1OzHMvSW7P1q$%S9qUxdm1V2ZXP54BV&+U;lC#)SLC27n{Hc)rcv}SOCSVV1ZlbhWP$`R|Myo@ceiRJ817@;UK zBY-9}?Sx9FT$YzlePu6SL0K1N_C-=n$}wY1jL*skBF~^tH0om`(xfR!H$sG=$#RYU ziW=%8)-+Nz5WUxP6zZBf1wJ}!HcRUs0;aleGJp zPy^E0fGz&4LXi?*$W*H`!<-hM71HOZ_?ejT^{k9@3EKJ6n7%b8C7H&|s8;?VqDQm^ zqOB*zf+*3}T-8uiMOe@Q7GfBjN#;FUAnL%?Rm${~k)2K4aLQiNNf*94&_QzVOW=<_*S#13>w?A0)e%^?zM_1k zitLNF1LqYY3?8q89k@&W1{15kylZ;J8;Aq5royDl?^2}-Cj;w1m%$-x4GavC46Asl z;Uyi96=`4)s#piB>2QiyyqXy!*u9KBa)R|M;3o4F#Zd0?e*3ClQ@-%KuAyNi;j3O2 zGkLs6jc;77TV&Q47`wso?;9~JUSeIjf>>}dL#@kKs&%QY4!5g;NGAVvqX; zbHtl`b87$_VmQ?j)pdYrwgJ~_e<<6?fv9z~1K|yABg@=10o+A(Xb&CudWR{(0216Sk{FU|V1hO61iI5&!-2PBT zkqKK9U3Q@0HbMBe0my1pt2(D8ugR;A+UsAZB-rc4g1X%u{s_imHp_J0DUr35Zs5JJ%a!*TJ2-BHn%x=lZTDSZbjqC*V(X_nFQZp6n(PnwX zZ6a@?yRD+xt~W+=tLv-o#4Otm(6RC&a)iv@A=ANgkj3*;Rf{?7{N>6?Fb4l3zjkYqAfII-m8U?r&gQ zgQWCt^1hD#A`j#aPW~)!gI2@x{q%3W0%7KG;_Ftg>^QIkJrF6*ZqV{$ zwAii$lfdofB0|_AD(X%#RM4NKCj=pqz>08&&_N}^eO@y&IXaNVPLS~g(3+Xz~u;& za0zFJ0M`%9BF+pij-wus|EgsOFR7C{0i`g2{z~c8kPuj+t;lXqYtH7}6e#_?aFkL) zmFi&T>>&w#p$gOH`s79(Izi|nNeMYhun_+73m38ZHYwm%N&3F(;1V%|F6Rv)Q4UwY zmA22jyzl#DZv~+35d-1qy3BnZQ4?n;5XLZf)L{&RO*64$U5b0?Y}2>@@89b2*11Z&rrDdJQK=W=fa zj-b}OAu3Zs>Q2qX?5N17a-wPv6fsE@Pl*&)>TsCp0>O+YeKL0@v6qgm3%^kowJn1* zux;Wm)*L74ymIJNgRRSDb>ey!Ho~|rc0}S=?A8|7`Juo1bX9U%*APrJ6 zQh;R?V`s)92pnZGq-a7KMDHL{R3uU|C=&4gNg*46@P5$nH1eSA@RW+s;=qKVz6@L} z?|$$M8<24Flp+1lk|jN|{uHa}g_HrMUP!Pg%HqC6JTFft0f=tCOx}db7lN@p9Z4-0 zi4t)_>SR(K4y>VgvIcGsHUV>M%G1kaPci2V&j4r`XHh9_Eu!L)BA6>QS!34-bdd~n zli0EiH^`zw^m1epKo?6u2gxhx@zj{ojlhrenhAxLsiDlx5?ylLSgHnGv_DrMLj9D;_w=k!vQN{y)C zk|Lm?ScS?>|1HPxjO#9ES(~qaoD~$Ypbip8RjajCnU0jUB~~vhEjyE#OftK^tS_0g z)`q2FA5`elCfm?cZV+(X-01bN%`K;oE~(ESK(Qq$Hc$Cx{B9G$!=|D%F~bP(^ML8#*p0Z^9dVA<53qljzkwv#^zH3H0EPZm0_b zn)Yeiu}B9qCF~(rehT0!mX(|?7u@!3u>k`WX!JgjrRK1I6jMt*^l@-iCvpJ~S;KvF z0vqy{lTc^_xzw5nb~g49SkwUnYlvo+Fwm{c*m-2OCX_XDR*7N9QbI(+FzASCV^JL%jzru0q zb|`KbFm>-#ZDqD*p*MOX=4J=-H+7b@dNzCa?q`1%KZsURDRm>27E^Tq=WwFxxXvbo z)&4JQLRjAjW|wtDUAI=@%Y1JFYO|1RTai;=PR)3=TwRZ2XM?a{XLot84)k}~D9$Rd(~W3^l({>+dMD32Ily@Ax(kRAH0EsceI*HQB05T zFC7@EK)C*5D1q6~FZI@5jBkGL&2*{sYH4>Hg_3Onkgy)mVA;=X>4v1#VFYLhaY(94 zYwbi)HZcLHfoY0?2kTTdSbDwqiyek~eKTjT7kjn$jJ=3^CDMB_5(tkrX%p%_YruS; zFc1c&-t2|s9JeN(EuNg0tlA(ajH z-?mgchB%lOHvnr74wdzIAIp%OmgWXi|8O)MjGL#7n+S~^I#kp+I@vf<3F?i-H;x5aHCmQ)-?>wZ6k>T*Fg*O`}n^^yS0^xN(l8 za}AL&JCKkMOk9)+RU?#_`xT!KB{6fjh0VAz0J)juk8YYxeWd=lJAkCVd6%J&ZTne) z8PG2`%(&IN29o-(J9{aYTSdasRw<&b(A%`RuOtGC1Vx5qc5e>eUdbaud&86#>7Wm>JV zTDnV0Q;{wA2v}WdRk>G0y)!h)q4ZwJ`-*Geg`j%QCH~H*A6&>qJaFy#NztpJ5}fkx zTY;Z$nHvi7UdfIQ{XdVOlbR1#K{dHlQt4hN)KlWC`6#R-Ja?L}(V0!qYsg0&J)%q< z)mP)xn0ex=dRXNe*#9$D!4`$s8X`JrJ~edLVLUJYD1%lV(N!(bqg{8(vrM%dkZ668 z`WDXrNZUIschgUmLM|)a@k8~nSU{bj2x~T9{oM1B+QRP3^?l!OBFwFK%*ou$1s=`& zPR$F?Xxp5)R)R?wUZfnp;UWIvC7wwHDdN))lKB^fhs7B&UJ@s?;g=aRCtjp7NF7XG z9mMS~86Fz9g;V2iytz+mFFp=a!W%BJf2Mr|{&4jPePPqjapWUD=p8<9gudv3o~DG} z0Yz%(TV4`#{^4;x{iGHfla)zAE>{Hfb>>>)L2qBFeruWcHsDe&~Dd?A2c4 z#~R`@$m{WKTbTZkeZI5vI{4ID%z_@oDSn3_-W5X* z_D{YBJQb3C-ep^#qlPbTX>RS$p3C_?_`BTS$r#`RUf|1|;M07!DcX$_Uep#o!=-=v zslWQAzv8JMg2Qq7imkXyU;3fj`bU8N{D7?nrXN97GQ;r?8$wbiHt73r8Xf8O<;B1L zrJv~SKmXB@>a5@Lim$i<0-L{q1PS^YF#?M{fA(r1eAsCqwugZh7QDEx&cj?63u1Fv zL!G~j4=GygXb+3Yjs;1UdeZ}UyM7Hjw(QxoYukQ(;X(@j7`$`%&e5YskRU{g6ET83xsfEyn>&9FJ-YPi z)T>u-fIYkR?cBS2{|-L9c;)2Fn?H{}J$mEChX?QRI|pwVDRSS=xl=S@Wr|_e;Lp%N z_V|*QT7fWDkUd}_6bM|QC1jC0Jc)&pI=%?yAB6;AgP~g|P4u6D0upFZLk&iT*FuQZ z;LBzsVWX0MGLZ&Yelq%pl}vQWg-|XoDma&ld$~2xK|dvRSSKR6#2|tKW`)pJ9Uh6% zM=xQ4q<~6Vbz@015`>>%UG|jMlnhz~mp|$RLLpczy0{OFQ7$@R4nR)p+f>?<4WSD7)fqD^#C3RrIp(ojs3seu1YU*vuF3W7Q&OQrm zw9?j=+it!67Tj>`nP*+M-hK;ibmEOmZn@@qr(U}1u6rJPM7S4ReDl?J!F_?HO6n1T zU=c`X`gRg%LjCp|Fun$lP)8u0?5m#+`}X^g!vM>e@W7jcFt~_&>{0^*xehDWWXv;kF^)t>rb!@YL zI)99DO{JYGbI%;N4F0v%H=+zQOu$+o7#3oW(1BRAM*Y{cYOl?9+it%NH*D3;7hkr- z8OLq7dhg9QbLajIc;MQxOL*bwxeG6Rzs+l(Z5nG0dE}B$PWeMI+gN$#nr|-SKi|B#ikgh{4TW zFoPWIpyW2_{z2@Og(28*(%H~9wfw1Yg)D4g-1gT$ZQ(6=1FYfj4v53!DR77CVPL!* zNFNB&MuH(M;`l&l#3YUhiArpu6Jf%{Cvt94EIALj$fk}>d>|+S+=ts%rqJ;J+B!R3pKQ1bA zk&M)$7kvlD8H$mNl>F8iFIg@%YBD)&j9w7o7(qS;a+DhbBq@_<%2TS+dZ}FH1QXfF zSjuviw|gXTA_+hmR&tlr!K5#fw@FQQ5{T1_WA$8#ObhCfYR60_DwpXGB06)LqiZEK zy$4JFT55Bf+;o;Le${OgBCdltbiaKa)Ani)JsQ8trIb_6bjq zZWNjWEvZRQT9AUuFqe~5XhIv>(1${FozMHBL_bMA476^iJngAZe+tx~3U#PNEviwE ziqxbkb*V%398;eP)u>8!s#FcCQ>%*AtZH?uTw^nV9! zsY}NRQ-;cvhY^*}L~}~kyy|tYd<83C{|Z>L`ZchGEv#V)JJ`c2cCl6^X<8o(Sw;S? zHH_kXD>>tO&bjWeu5ZLEV?PU8y(Tuar2Xt@ON-jnhW50oZEaZ18rj&&)_#)>XL2li zSPVB6qpWg{pF!3tghZcDB^5?hAE8w=b@=t(C>? zE_Lh65b!pEzQrkUql@0vf;714ZSPW_>)!Z67rydE?{uw;-~2|awk++gcPR;8O!i>B z%qy=uD@v34DtNE-U2uCF?BEEK7Qzx9?tS%(;S5s?yS=;Rb_4idG6wjJDIjoxY1+V> z9(Xoks<4Z*O5qnDH^wrqF@x7@|l$T$d@j*vDNHp!h!lW^>|03c`ai*zoQ--^?*IET8qz4Dz4edt6ly3vpB@Sgvi-0wIC7LX40s7rn7RImEb zl|F8uv)t-j@4DALZ^+N?(vh!g+4oU7fokyexXXR+RIdRReBtwYEF4U3 z#{%5x4*0-J-R^jw_TEp`_rD8%@r-Z$*9o8Z!$&jmj<3As3l4e1kA3qRE<2gg4*Gt7H1*vo$Qw6DGGZ;$)j>t6N#r`Nk#?$K=2?|%5iFTU}Q zk9?nN-YdXAee$0V{pd@7`qbZj?(}$h3BCEpIr?;!zU}fj zz5Cw}|M<&){`9ZE{qK+e{Of=J{O`a2;V%!qXBxoQe*b5H2Z(?PsDKN2dI5-id{=-B zD1j45ffZ8iNQi}Kh=+)Xi5Q22sECc|h>r+~ktm6Q$cU3@iI<3pc}Rwv zNL^-#hMy=rYPcS3sA6u&iKmE)si=yp$cn9qQl1Eku^29**e;}aa;E5txu}b~$cw$` zi@(@KlO|-8^?9;rjMhPmYh;U^1&qz;jL!&-(I|~$2aCsOjna{f<*|O#$c^3Tjo%24 z;kZcEc#Y-QY1yb8+bE9f$d2vkj_(MMawCk8^kMI2j@O8e>9~x)6_5SskN*ge0r`yN zc#rs)kI12p0;!M-$&d}{kY7fS1$m4IiT;rLh;I*xkr}Cx8_AK}6p<1sixk;B7Kw5l zX_6<2k}0W@Z1a&I35p__kSi&ZGf9&*X_Exek}ipcFiDcQc#}T~ltC$!{g{(FnSwlN z9PHGFZZ?!n>6A|imCaa`MtOcn*&a1za?BW&TgjDO>6Ifml~fseR{1Vi>4Hr8m1(J# zYsr=+IFBAyX&)z+J4ul-*_L;SmwBm|j1-o0d3I!}l%6xGDnU;B(#+I4(rj?+HnyIOpgejV%d1)4#m`Bu;=nAORh-RYgcc%9g(P};ef-)WxbiJqJop5jSP z;M8Bm$exV3n@Q=I+c}^A37`RLUF(^jyX2m-DWD0epbH961X`e!bf6c>pc6`=6`Dm3 z`k-SJp(Sad9qOSUN;?>up=r3ESE-rr38E>gqATi6^=VMVHlk&=p+3o?HEN?bdKw~H zY{S8$J?f)B3Zy|Qq(e%iMQWr+ilj*T9-4Vm^pOHjN~1U`rBiC7)$*j)(qWLaq)E!9 zUFxM@3Z`KyrejK`Lb{~>_SU3V%Ar)srfmwMR{Es#5oG zDypMOs-yYw~DK|s;iT_EQp$@d$Xvwv8%&M zti@`q$BL}Us;tY(tj+4I&kC*4Dy`E>t;gD{E?SRr`KuyVqGfra2x_R|Dz4w@tJ?}L z!8)$#s;<&muIB13=(?`)DzCNKuI`GA-5QwXIj{T5uc%qC_DUV_+OGpku!Z@r0E>2n!to`>+*ju}>MX66+ikd$Aqsu{D{o8jEZPX_p^svM0NdAuF;R zi?S{2vJ9!R8q2aTOS3f#k1;E;Gi$Ru%d_5yvk0rRJu9?B3!c~-mu4rj=%=vXDxpN{ zv`^cMKMSxy3$;~iwPPr?_DZ!^%e7s*aarrGTkExBOSWWZw8U6`NjsiPYntF%wr>l! zJ_fetDzww1PO+n#J2n@#(-iL1B)rMJJzw~Gt8k=sIz zo2ZW~xtEK%x zy0eSBx&E6PwVR>0tGmAoyeSp9_c^$&YoLaEoWZNS%ll)gJB)(My2e{czRSGTYr4Gq zpww%<+nc%ATcFy@z2OVF-kYD`E57IJx8%#7=8L}V8@K8!p6u(s^Ec9Wn8$Cb-XvdS!BOhv-0wJKlA)o;bECCUqzY*NM!#ifhdyN2$!T!r>AqSIo zg}_g`zzpob5G=tZJiZhRqsGXk@X;F_e2_tqUGZ^mP70-aYQi^6w*31o?!v=8Oawi^ z8yk$7`3S@Ov!pUCr&k)KILyRXtHa*n!%=J;Knw!|JjB^Z1iitdM+`(xy2P-d40WJ* z{+H)?HBbitk{ah=46l$0ZS2Nx499US$8$`_b!^9XjK_Jb$9v4jeeB1749I~j$b(GC zg>1-&jL3ARj_Zn&>DWIq;)Qi@3G1b!1G+7?3BZ z@eGcv%B#%Et?bIL49l@B%d<~0j1E+Bdx9rW|49?*!&f^@*f?&?)jLzw-&g;z1 z?d;C)4A1c_&+|;r^=!}gEYFS5#<_%Gg3U9~S4@kU z%)`d)wz4s6HE;w%fCD?t(>?9eKMmADE!0Cz)J1L7M~wqQa0Gt`85eO591Yb`E!9&^ z)qz~nSB=$Kt<_r{(rqlsy3EolILri{jJ&~CCfc^492-X<7)OBAZw=RRE!T7H(?>89 zM^F?NUDbQd*M05R7|qpzE!cxi*d(pbDGk>DtgWu;(l9{Qw8$GhOw(%p8uu_0H9*&y zt=XHM*>E=khsVZ$E!v|^+NI6Ng^k*&t=g-7&YAEE{T$XBI@T~Pijn?3(+hjdu~82+ zq0^l0+rJImJ}m?^k#z-O+Q*ID$*tUVtj>+#+|Lc&(JkH6P2JUP-PeuX*{$8%&E4JY z-QNw}?A*q%t=LGLn~nY0wOz%x&9rO!8i5hq@h#t)J=`_H63dO>`K{l6ea_(R-~SEZ z0WRPJPT<&`&f}ff&P$I+i=V`a-n6ZTwvEuN0pIg&;TL|?esSOX&EXyH;p1%11uo(v zPU0nQ;^5udnLyqO4wtK2zO#L7=qTZ^3tTjS12&H1H?HG3&f`1o<2?@KKQ81!PUJ&w z5*RM(JcsQ-o$F2hA_U#Wxd`Lt{Pz?=%Fs^qfY9jKIo2x=&7#it3Joj zE$Ocg>#;8Dv)<_8ZRt#m=>x>(n-1BXPUG){0j4hO!%pnPPUx(T?8#o}u1@RC?(EMF z?T-%Kw~oWPE9q;ju{@m1l!`4nb*#4SszU^pzxZchhpic2GFYz%C^D{5=HUIJ! zpYb`b^HaU?Rlf70jpZRf?`dA%4SwF5{v1J|z>b}ED9`V4Zo{h)CNV(uRd4lIkM&uv z^;^&NUGMc@5B6J+^FL4aWgpHxKjmic*H|sy%g>U$WkN9Gb>T1vUjZe#Hf6|U$)mGm2^xk1`pTYr+^v+@T1i%1# zf4u1w0(TDsf1mQ++O`)U0SplJpAPVe5BstHFZ;1S_L6V=w|~gaedV|>)s*ktB5%SZ zFQ}Ve_jjKFpg+A2HTncl`h^4ds2`gNaQ7rU__J^Q*N^>KU;Dhz{oSv}x^Ly)ztL>3 z&qIIdMQ_0lPW;T#`N!Y%qEGs!|MYFk0MnmS*&hQdumbzm0VA*iSq~5^1jZm((BMIY z2^B76$PfbtdH4KHq?pO#MT{9WZsgd}<42GoMUEs{(&R~$DOIjy+0x}pm>3&1qEypn zOq@A&?&NvVrp=iaDGnuC)aX&9NtG^T+SKV&s8OX(rCQbMRjgUHZiRZ`LJAmRbBxW= zqeqY+MAQ;7($=k!Byr`=rCZnTUA+Ez_3q_c*9eiYcykaSkN_@UA~|4;4MLagVY+P} zzwMzoN07H}Cuf%YHUwc9e=~399Cqz#v}P-l4aGvvPc_3!83_iy041}hA|09_;OzyxxL>oWo_@GAkra&U{m0|{V2Lbn9e zpfu2kFrdN%Lo?z-0z|yewA0i|%`CDMgTX-9w33as;}#MQgFvvcha!8_@kIv=fGfxs zWqhgeNF)_XP9maS5(Ek;qyB`F3n;h{q&w=Klyb``8^Q8Rktl((jE*Rw5(+QDRCA^4 zuro+G44-wpwhjC3eZ>T*`w1aKjZBTo}^KDGxW? zpi|9n$90zhGD&JA-7D>Et`P|sh``*8C`kd_cy&ToU3+BxkSIwv zkB1>f3C}bI)p%o${yX;g<3I_OZ!G!}Rcp~lQ&u_UNGBaIuMr6|Ace6sC^dH|_Eq07HY`C?Jd+uquZPMF?$CaSsmnaEAUw*M{H*bJTYBvard7>oWeL14! zMF!m1$#1|D$LZjkN?M$7a5bpc@{JU}fN#*l#4b;eQ&)X;)?05~WJ5_t)IQEuw*7Yf zS`Ls>0zc(_E>1OAkT3`3G6F&ZH0|NQR3$^+u%b~t8Z@_3h%hq^vhP7wBWOh}2-UKF zQCAsx73h5a^wU>geX=RSrjGNsP-dTr_6bCww*85Aeg6CR|9=3k&u&9`NxM8J2m~Uq z4EX|=5pwdl%CW0+38Y{GN%xU<5utQA*<9l`0>Q{l?nNHlAWJZp9nCQ=4;0J;7cLO4 z2z;)Dnb=$iE;vKSoi1ap<6#ed_(LFO1$Hee8SOHIJ0vF2F1oXefGXuO;mt)uaS6hK zJQWuKMF1{8vk+&NcRi!2ih6B98uw)7y|rMCHRIdbt{xJ=0Ahn8xmaH!D54JhxlLN= z)6GL3vNrXNz>kAWpKjJB$Tz~Ve*>%t-WXs*@BE7aqvPNR1J|8)LDG>L#9>Q1cu9+Z zP;&k{fglMxd6E;}&?BGR2p7Ty165kAhOsO&Wymcskw!Vp$VgB#%P2xnvrT&yBhddv_=@YF-VH+ zU;F%ljs7WvB3oEN{`MyjL0vOf=|~4Qr;Zd=5P?J3yh19&MhG<~ss4Nu zB9sO(u$F_t!0AiS=V*fkn#(# z6ua0h#nVf~CRVYFWzR2V7tE6lvzU`z*)e_hGQ9xig_w%srd|V=oO#L!2Exqr2(Y1U zPUxHA6z5yes3LRT%0=lcpFbg*K3`A-4*jbG{rmwxd+u=s80ZArCZdiy{6-+hN)}P} z;S1%q4;vVeqd)cmzuTgZkrOFkNS6t=F{)mKOnBGjR z*Ij!H@PN5cV0eWR!QzU5f-Pdl;=Uv^iPfxY2zhwI2L zp)pAqtdS85+agk4*agzm18_kBBi2?he)W5AcXb(r?;;b#Cpm0*rP-pHNcXsW%IkOe znuc2*m$t>FaLpN(&MRkm%NJ|sBIfxme5N_0{4AGb2KqowgFz1xLuk1$XqsG95Y9+@ z9|kr0w5Bx?YbV{ks?{pC;-jsmZOds-PlS$&B=rhR?GJV2q7(9jmJU{sb?ahdpJdmc zwDLEU9${D3+x16xA9=wMQ_|W!2x%GWTRDX>Ai{YlL$m%FutHDaI0sNL!ceDkY{w;g zq;oY>5(KO-##bENK1ue(!6gA0Q0(s!-~zfN8R7+dyTRbzTt=aghddx0T$Bv_;0qrv z#3w=VjHk(#K`;7@mfj7gkJpeH0E4!=A;zNwcIfkPc{Ou1#$ruLzen=#jNIDz=tWnI zAxUseLNH&53uS%F4eS)O^~2<%)8P2tLI&>54SxZ#r_$ST!TUJZohySNBwX%H*t@

SrH&~IJ(Amxec8d-Zcj{#1X)*}|Cp%1*9e(xzz~;@oh{o;?Q~{+bPT*5GsxL` zEWr^b;@`c$lKsr>E5Yc-q(^?nu)X29jf>ozhhbX}^#|nA{9W>}Z&)s06>fAGj*GGF z)KQM$W?trKe&%W}m2lV^zOVxTO#;L{=fgb)%gx+)&IE^uVE$*&=ON0(BTxfJFa~{o zHBQA0+z}b?P@oZfo{E9Re^ z^@pe2!RXors}9T<^L40B&EJiRe9ys+jPVX^TULz`g}Oe%yKIdT|26+DQmB0rdT6iR1{&>BhjYk^6!JFW2t+|6rhc{>A zxu|omcso;Aj+DhiqK_kS-6f`84lXuqGG<}|LU+i~R_DdZ>a;6PsQNlxa`+|*89_&9#{ zayaF87@%X!_;m<>@^IDKXc%BG_RuBfnZM?&ulcUu`mk@HK`{=a(i|LL*>6UTnLJ294p?3Uf@ROB$_{bgY z=8x0N9H;IW%gO6A*n{#VZ&`ZnBMlkyhKk)$jn!Fxg4`Uq&KOYb7&9oy`G5R0J?sE+ zPT)O)1cxOF5+sSddJP>q6qPW~Ly8qGUc{JDo&6lJHr@cSck; zlm{gUky{Bx)k7H)$h&$V zi&o6zv1>w86hW4Kc(&m%v1~(DRGX1$RJwJex`o2fVwr?SxO%lniRjq36wf6|!bP6I zb4U*+T!?Y5UCNa$U&fqS^JdPSJ%9cN9a{A0hR~qB)9X+5YMAd#ldk721_=@~Y~Qv` zdYxj|UN5XojBco+ zh3PU#BaltU=NTFvc4qD-E54%2rQx@fL54xU$2^4?dZ)i6maAyGw`wY^o`R-BZ@$dh zvuHf|8k#DhTf|G~!g?|cV#A!YlPQRA8Y<|S4!yHTl%^!yj6#cI5(_)~U@Gy&h6J1G z#D+k!u_3;QkSV+o^(u(RiLwgftA~aXWFr@0+-yAkplgo2p@{I&A?`$q%b_rTBx|j< zs1viyrF5jyGQ3!Hh{+(L6#j~;8yhMxp+@TAaVv_3QmjNDQ(RFlHrqrrQAHPJv{6SN zMbtFZ8d{CDdgO8K&d=l0;$1^OmS|xSZ@Rm z&Yt@7Fr!=>8cHDqAIkEjhXmvTyH^~dHM{BPgmfeBx-3n=goYt=z>664Z%3+<;K@q` z-vZH?hiD8U!e}#+ks=j|^6ne;w0pP7c;{v4Na+T9sM*NYjS)?UzEKccqpW)<6RI+K zh}#!IBvP+f=X5B~AQ*n=y?W}!xF>{yxCvnT`U}}2?DUgIJuVQG2fM9(vH(P`c?)B(GpZc{S^_r~cFsjO^ zh+D0?gr>i`v)&c5WVwK1aGQcccTbGe`|6(Y5-LNKTST#zs)^{9NEA~V89dyq+kGg& zZz*|2&Ja|z{-Q01wg~tjR&t++MsA_RaxihuR%SJAu804$@K4`H+!BQ3 zEl(@js}oiJ1n~+=I1XBJN>GDZTq(YV65yTjBbc0FwqPcrOqm5j+#W=AB5_87Rf|eto6&(_k zOdymC3iX|L>gPp4yhEU*E zc!nw`O2vv^G?!lmYgonVs9pBbgrtFn2*!3cW0Ju)$@~ouJaB>;I+GK+5TYamgg%nnbgttZZ;EyIIW!cmVc_-C$IgVh&rJ{mer?i&Oe9kOg0DakW z7~>U(7A=;C3*P3I+0l=NbfhIcX-Zex(wBbpnJtB>|AuDGZgw-A51e2W(A5S?u(NR- zaRU@E0v>{Hbf23mT^GlC%eE#hdTSkCMPu66zXo=&g*|Ly7u(oj)wF8T3>r@50@Tko zRWeCkY8&uC39CX*6>P9=LcIFe-v)QM#XW9vm)qRuHg~dp$!Wg+cE;Jy26eR6Hf^MR2WDudPIFTE;T@dPGdbjNDxSB%@AVFQ;5YtpCYa0YZby9L6~B1K zH{S8}fP1GsGk2Q%K^=OANj=oT2CXO9?|=Wg;Kly&Az1zc^N&Y;>Q%pb*0-MAk&k(1 zC=YMT*8}g>yVS&gV{sIGp6AvU{pk69gJ7KA^~Xnk@|C}Q=7$jWQg=AS{ooDTUxXpJ z$a&vC-}~T$9ppCHh4Gs|e)5;!{O3o%Wu7nh=s(*I*bqNO7-9|IklOe5?!NH*jf?oF z-~ayyzyK6L#)CbEFhD&!zyw6V1ysNTq&j0#hU#lT1eAxxFqNL;xl#kZ{2MwO@C10M zKn4`S2_(T0G{F)?!4p)$6lB2_bio#c!55Ul7^J}&w80w0!5h@U9OS_r^uZnk!XFgE zASA*eG{Pc8!Xs3|BxJ%RbiyWt!Y7o%D5SzEwEn^>#KJ4o!Yt&%Ep);41C7~hs|dV3 zjbI3Ms0MoLx%>mV4*b9Ygu^(L!#SiwgeyQTd_XeD1qpn?3X}(2U^R@pxQpvPq3bsr zXth`C!bS8#Ms&nRgv3Xb#7LyXNwmaD#KcR~#7yMGP4vW01jSDj#ZV;0Q8dL;guK(> z8Z?8sE3Ci=+(S;u!ClA#1+ztaGsIjx#8P8~Q}jht{Ka4d#$go3VkE|6G{$5^#${B- zW@N@^bjE0e#%UzMJFG&g>jXZ`!E*=(MgV~mm@B%%MP2Mbo}(*5;0kF}M`~opb#%ve zgvWQ3$9Sa2d9=rR#K(Ko$9(j{=cB?`{xl}5yF4O<2X!z8Tkr%%AOu2SgDh~!hlI$8 zWP^!h16$a~i{wX))X0qF$c^;Kjs(e%6v>bz$&ob47c4+ORLPZO$(D3UfFu=3kOzBs z$(poDKZJ*ZREM49$)4Ovdr${?PzRcP2h_O9qeRN2RLZ4f%BFP6r-aI=l**~3%Br-= ztHjEz)XJ^o%C7XvuLR4m6w9$B%d#}fvqa0ZRLiww%eHh&sr1Jyd^f9;#U;cPl`P4; zJjuP(%f95xzx2z%1kAw{%)%s0kxWUhbT-Qi%C~gP$Arwtl+4Mb%*wRP%f!sg)XdG~ z%+B=8&jiiT{LHwd!d$StnGC}IbC{IFG|bjy&DV6z*o4j5l+D_t&D*rij7-d}+{vme zO3?();1tf`B+lYA&f`SR|B2q|f@a&-=vB{M66pgwOr-&;JC_02R;y zCC~yj(C_Te169xkWzYt7&~kX~9*)1c8E<&GFJ>}Cr)l)IG(Hk|=07*c$zX}9YJoVEqR6DIePc@BGMb%VQ)m3HH zR&~`^h1FP<)mf$0TD8?%#noKZ)m`P)UiH;q1=e5{)?p>qVl~!dMb>0h)@5bZW_8wQ zh1O`5)@h~IYPHsD#nx;!4NFB5NR3pOz!XoVRBa{Kay8d;Mb~sy*L7vrc6HZxh1YnM z*LkJadbQVk#n)8*2G#yF$~yJdZ@s#r6xe|+O2#nQgGJbcRoI1P*oJl3hlSXPmDq`; z*ow8-i^bTC)!2>Y*pBtsj|JI~71@y`*^)KclSSE-RoRtg*_L(LmxbAwmD!o4*_yT4 zk|o%j4Z%_U*B0}`oCVsT722UC+M+euqea@JRobOx+NO2dr-j<6mD;JL+N!nMtHs)^ z)!MD)+OGB5uLaw%72B~T+p;y=vqjsqRok^?+qQMvqV+==``0rSB$a&IyT#kQ)!V)0 z+rIVNzXjaD72Lrk+`={7!$sW0RoulDSiF?mxusJ1-sgqh=#}2-rQYhb-s{EQ?A6}w<=*b~-tPt9@D<R02bf@Cg1`# z-~&eB1Xkb$X5a>P;0K1_2$tXprr-*;;0wm!4A$Tc=HL$Y;134j5EkJPCgBn`;S)yT z6jtFCX5kig;TMMC7?$A~rr{d4;Ty)`9M<6-=HVXp;U5O#AQs{wCgLJC;v+`lBv#@j zX5uDx{^BQw;wYBlDW>8ow&E+s;w;wUE#~4b_Tn!F<1iNEF(%_OHsdo!<1|*|HD=>B zcH=jO<2aV%Ii}+}w&Od-<2=^mJ?7&+_TxVW=4h7YX{P3C zw&rWb=4{sHZRX}~_U3N}=WrJ1aVF<-Hs^Cj=X6%*b!O*wcIS77=XjRqd8X%jwP z#^-$2=Y8hqe)i{o2Iznm=z%8aNtUhQF$Gh&1<@+#hIZ&)eujjW=!vFiihhOJ8s1l^ zXj3qT;FX70fPx5MfRQF?lD>fGIfqD&Xo`M@>V>>pkmy@5iHD|Xa~6b>#_62a>65;N z-yH^>W&jN^)7||BHvsCR<^pJV)^Nss?AKKI*J4=?KV!ci>$v zIO<{OU98q>olcrYChL)gfuUyJOc3d^mH?`@>bcJ5v@Ywcj(|+i-FLVEq$cVVxNE!? z>qWNf6kzM*t%s-%?2&c@x_0bpw(G?P>N3b(O2}#nNNLj-FR+U4t`CW4Ok)?t8wqc(!=4sG5ZUgg$l7{Hd*Fa{_9 z>L4)h2N&kZ?rF$92TJH`ptkSXt%nQO>foMT@7C!E`0UMXgrlBtL*{Pf1?!xK0&mTP z5WjB-m+@VWY`Hew;tp`xeTAI900zft3E(B!HSnB{0RA>zQvm85(EjZKSKf2L?vhS{ zAcxcv&uI^z@hi9G8lPPxhjH0Wf}G}pAunkgH{CDSX)?d@5LapxXY%B=?3^ZVNY#Lx zW^OFE^I6vN*%bw#w(`0C2Js$gs)p~8&T-R)2B4Pj+Li5`ZiMRXr_oUz_TpaG7zTvC z=x_Mljd=yon(od0hSBjJQ81U@{f0^hty@?L*-i1-E%o6MotchZGcRckkaBOOZ=4qG zJJj=Pf(>3axrghuha~7Z8Taa~}jsQ1^aY!`pZWs5=_SReAf;q=SW*biC5)bmt7~<=@XyZ5XfnVKlGeV zbjZ!`oTh}^{qr`j6u!QIm1lXb2HscT@T~qQZ~ge3F7~;dc9M<&eD4}jhK6CbH_vIWSGFE#Z5Z%sNGx*S>m`hg8N7 z>^`p)qTgzs_twhS`Lj>^+6U#ek6pXR={o<{wkPRkR}IxCX@&2aUoUCJH(t;uY2MG> zdXW3Y4*9O}{gNK>t^s?U&I50Ke#vHj1+Vhy*Xq&fY~=p@pN{~=mh2pGdey-CN-_S$ zHUij|{gIY>$bI&RxBdLT{WG^+e)+jPQ*ll#d$fk?j8J^()x0V#ks#YxXSKv})I~ZR_?e+_-Y* z(yeRvF5bL)_ww!Qx9if67{>;wI&`C`s>UQ1w)m1`(T5RW(vAGE>DikogXC%CMgz{l z5}%<=BJru=sX3DfYLtfyXqQ(d_6%BvQX`W;b9Bxl6J;Kv2YvE(-6N-`^y=5MZ}0v+{P^xxE z0P{eUoRkSiwOIt9T$Uw-;8?c+N7+EP4CmN883}ljC=sHRVMa-u6agjP5oFAS4{BZDb&GbtxF+Suj*}i$?V< zndU?u@_5yp43weKoDn>;(L29Av=fRSF2>V~Ldxh;g#-o4Km>Sd)Ch1Bfytmhs9D74 zM4~EE3PD|YNl7JYMPO$2np3;RflQ>XF@qhD$zaf zIQuNL(LUR%K!Jh^mV<(E#1fCxR$C-OMEH{xQ{GPc(TW>!Y7wIYA)C;N6E$X$RU7S? zP`#Fdi%`74x4P?WLkSAISe(Mazy@T){68#OG+Rrz?VPLQ?jC*kOx3HrZQVJeI11X7ox*go4YeM$oXy zE=e}~#OqsY(u*WV^Con(r%nR@p3KPsJ(d#JFtW0F_k1Nl0%g{}v9$pj{5Sb+{ zXh8#ocIo6Y2Pi(A42~qC*cVh#Il@-Kvbpddz=lC`X6Ju6z_ zBh!)8@gbBfo8?Q%F@cp&3o_$6ybt)N^rnR)DJuPZeo2t8l#b!J!iB})8u$Y{L zcVeMnNa=!9le&dlKrtvmoEa4yF(zoz8>wV9n%u}%4qUVCO7Nz-)u;+Yv@mc>CU61; zd8oF#-u*6kRlC-Wn34fJ1t`7TP=@ugx4rFk!;*|@SWHb+d?>w$UBHAYw+s|0{t{U1 zToD2|jWkwh<6LeArAkJdB4l`8!R-S#%T)_raxHd?z)-`h;SF=R!-E{}QBm^Tfdq?j zU`b$BYc#amqGXe45n?j)WQ-Zpn79I=B9CqXQUN24xfv<&aSv=@=?;0pRh=bC<}_e; zHrJ{nc?OMV48%b3(!*B1GM2LpKM)7EqLh+|a8nGHw(?dfD8Z>$qWe&~5a}Zdorqx_ ztfqbOXTA*h9GHwsTRWPOx(Ft6kck|qTuKD44DcY6{o$YeKAA^>Md*J3tmR2ly3&@8 zDQia}A)HWB&3^8RXjkQ}OWAfL{A)2$SjI9JZ{&6fk*kUyqOsyCPavKCe62`@JP{o8 znZmm6GaEISU=Dr=xN|;OS0_x=k68Ag9}yQpGMNI=UAo%VzBab0uAu;0LNa-*MJ6g zGB0JQjnt#OJWX_=0PdXG2?U|I&Njv~uJMhNM^!;k^~#J;Ud2p=-#;A7lAt{ia$AH8 z#)d{CcI@O=_PZ=g3PCjAac?6P8D53%6hY=I0V@_-;9m{&r;#3P1uvc0dL%70M&8Zo zKDJaJ!kfOwiocV9yz5=JF~`F$_OX+l>JF9SeB~UL`CgnNK8gN74!BSTE)c=!nWi~% z$C62eB;gg`c*i+NsmHp8*&W2@m5~ff8~3l%2V=uhi)@j zOWvb{C+~M|JR1iJZUYJGEj3V+%R_e9e>Sqy&knl0-x!_)W~^6bZ~O6+zx=4h{Vj*S z##NP*B#;(2;^FxcAQ69Gm|p8$!U`Bk6=Uf@2Vp8%BscWH$3F@XP+1+NtpNB)GGQNRF9jG#6#SkjS2i49iC z$=u<2ApZ@73FZWujnDux9R*Gn3r>*l5Y__znn0|ZPXxiJfm7_Y9|ks|6F#9IX`oSD zKpwPTpk&xO?aTK`1T=V=$>c#z_|ys>83U5VE65y5T!0kikz;^e_JI%IWY^9tO#YeI z^l8>*Szpg3oHa?IY;lC?F+>ig2=5^vJW(JNCZZxP;`-cRPF#ReQG&$v+^R_0S9~D+ zWdt;of!)mv-}qK;z2Qz7-&fQFC1ejygg_zsga4UGSL|J>>4;B+fFM*#qMU^E1(==< z#53I2e-WVZaNa{fLmlo86B^bo62vP&AEwkI{-P+M0Kpm~P9rr|W7yq;w?P3aG5{o& z!7Etec|e0GxPcMC01V_n5-=4L-UW8#ff3{Y4D#sH?r9)3}iv-qa<1*L`I}UPUHptBSl^$MrNc&Ze)^K6Gwg|NQR_H zCZa`-q)DD6N~UDCm843xq)WaeOr{n~#w1PFq)pyrFwG=R?j%q4q))z%PX44&4kb|* zWy1ueQ6{BQE+tb+2~svCR7Ry#PGw$v)JI?;Rc57DZY5WO#V{Ps;j~*7N%hyCSoS0 zVlF0QHl|}fCS*pYWKJe!R;FcMCT3=)W^N{DcBW^3CTNDHXpSanmZoW*CTgapYOW@0 zwx(;oCTzy0Y|bWa)~0RVCT`}YZtf;+DnlY{fe_$;a1JMN7N>C@CvqmIaxN!xHm7qw zCv--qbWSI83c(g6f->-?c5WxxbOLpLCwPXZc#bD|mgjd0K__shdakF$z=3(bCw#`I ze9q^4!U22UCw|t7chV<+_NRaT=X|DTehw&sYKbxcD1s)af-b0X3PE-jD1=5ReIx>d zR;YzuD19b^gl;H@vd0!+D2V=ssEA7E7IdhIp6GZ4=ZLN-i?%2Z2my-5sEpo)i`J-( zUg(VGsE)eDjrORI{^yPcsgRb%j~1zszUPo8sgjb!kv6H5QsOA&M29t>5-l(o1UnezNwG4shn;ooYrZL&MBT2sGaWV zisq@G;wPU5Dv16mp{l2#9%_Xas-p5HqBd%RE-IwrrlU@(e@3dM(k7*5>U>@*r@|(J z?!Xq*K_aBV4rr>H@&z_X#6F}dmc2p}NPqwUfUM3ct=6io-YTx;A6_fCTg%rz%4(j30o)fjSU`In;rvlIdQk!x6BmKqP{OUKtlC zE4h}dxt=S!rmMQHE4#L}R%e>0aal4s3xv2!wZ*sFi7e zyB;jUCal6PEWy_@sex`vv z`0I=AgNKc)!@exc#;nZFEY1FEyl$kvGAF^J6}SE?`Ss@xRO?1y1Id;wUDyL0_+$| z$1W#!Dudk4{w>|sZPPAj+$zJ!9;buKEgWot+~%y2+J$@WK+?Xgadtu{oUO-pg*_}T zaSA~i5p=EUt}g4gZq^ck=q@dJ z>_aEi0lwyfsLC!~(5~qAuJ3|t9Vl%g1h4CeZF911S(rm2ATACZ0UTtk{2=Xco^Dt? zF7qBi9Y8Nz(5~$QQ-9WhSE$3?B4^riMCev%9aQdtB4@pJ0vxccJuq*P+J(wKC-Ra+ z{qn8$;)Oa0rx56FSD3?a*6T#9FAn5_%7(0ZNPz1$ume9Z1e>b_Os{$D!`rgz$d+$h zSnzZHQmg+0FzP6S{3fRchlTMT=N1U9T$qF6YOgXVF7;l?-rjFm2yStrtXpt!)cO*A zhHA8WMf@sf31`GUjIV;SY7igigPJWI1Z)zoX{nX zac+V9{_k9%F+pT-m*i{}lf>SR@fOJO82@ou>_Z*%uzWVMM&z$?UZ?Lat#9`3BQL0c zp09C|u|FJW6WhZR53XH|@^Z#-M&xfN$nLcgCnd{8AEPo@)GzfWG5D@8_^!tr7qk8` zA9L5ru`KI_B?~93iY*?~1vV%HHdHe<=&RgXvof&q4(tFg@6+B20p$*aGMw$d<}z9! z@<2c{m*i~FmW96VKrNHAIV-SQH1iw_l6(>{Sl9z}3W3maGKK=IKo~G_wr`ixg*xN{ z@qrM#6^HxU#9pjR zzx2gwvH=tHMC@%ew}m>}@d0lMC);ya2=E7I#NB>$C8M-H$7e=wgdQU&KnsLGJMGzS zK_}$G38NS+8|klm4e%l+vPr~_t4v}RW}V?*>tm_uNvDn5IwXBW0W z$aZ6cE@m&SXX9^P6E%#M!&63`-5uF zc4E6Wa}z|$I_*9f46B12bF^+FHb=}iZ5y@Je)kK-C)tt( zP&;Q+`-4CW!RN;BI19M{Dnn>*Y;ij1g3q;i3xp{TCn~QmK|isB{!0Xd;{YG~LlF}v zSQ96!8Z=zXg>*w_w{AodXE#9@@O}HSz6z*xZ^7p}wbR0Khx4@;i*Rw$^hWryX1{H? zsw@Fd_;5C{jr;LH;IlpJFng1PX-Bz~Pr0t5Hb-E?TSvG+z<2ZV_#gvtIiGcy@9=&` zs}3uJAP=VyG%oEXuGl*EK&UhbD>j#RtNd!H4VP~&jP(|H>^{JLkAnlh3n@vm$2hthQY z61apLFDjonc+y2KR62C}ct#j7Q4fSUR5Qyq^L1LfKS+8|Be-%>wzZ#iE`R!@qdEnf zy8@dna+0wD*SJ4SbfN?G4kYzN0Ba6>)-dFO8aHeXP{J$R0wtubtug>T-hwFP0RiM{ z1iS(z4E(GbfqShN5&WtVWWp^Vd=KcV!80(hcKpXvLdef*4rF|L<-i4eR$oP(tTMbQ zI6TDP>OS7WD}0-;=KRhZtgWU%BOJUaxGb*BM|{>=xkeBz3G4W|%kEPb z=QH22hX?y`+WUtiXQ{rrxkq-LJ8`-b=ThVGpo{)EUvs@CqsQ7$H%IK}zMF3~+q=}0 zFV!dK7whm1pZ(jjN6$9rFpmU~d%Esk{fpYWxhto)#yv&1P$rLsI&eMT)_Q}>x;?0a zcY5nJ`}IKNz30Pl=*Kc&GjE9Fz_DL&=##$GMtiCqSz?|8V}w^9v(x$WF9y25&By^R^4b$bR!=FReP?ZPMmKn%id*Z?Qpp z>wdC$R{(3NO@ut;0K*DE`BW3Fp1(gR{rd}q`qOFzz)TA8Dgh8(5IKFVh5$hL^Ct*E zL4yYo5-=u^p}%1S3f^isQ6LwJ7yab`Q2yp3mxC$ZQEY^eBuJ46Gf_Nufh0TUR+<^lJHjD!IZQ+TdPOe;uh90r!&)&|VMRN(|sZ5|cr$mjy8OqbAKcqNv z`3t%=O)h0xc_w1`6~_^?Y`OlNn2V=UicQ-t#XB^SvQArZ20~d-Up3xtB3#*33}Z5x}2812!!8E1byyD5UhS1V4J`?@vt|z9hLT)Sw zFKaF?f$W)(p~z+w5IB2 zEK{)a%!cSRKpuw9sfVFFY|@ldPXz+CAVC>*6rwG~p0@k1Siv23AAAy7thct+E3Xa1n>`}AGaI7{j9*06MzAb($ zQ7;g2=+7T)oFee6EgGT@2W2~~*Iwb!nz12!Qe06*bq@QrAz@n!_e7~E6ta$qoW0j! z&GtbHy&dTN>8oz#g0|P}^h>rOW__{;U3J+#_TiIJPFbK3S>B;n%$U`1z5@Z9dB3f? zQqo|5n#=h!pN*3)zC;d-SVRen)p^L~$h~<a7O= z`%()U9)QaQ%3SgDZQ@lfyl`Z{;I(NsP2{v0-nro)sck63x4b@Ax5SaqKe5yY3;FD%-mBU`Ov2miEyG5i$bI$ zF!6~{)c()gLZhCysmB~!NsxqW*2F4C5oukaixB2!52`$)dm-bf7M27wF%01@z1JtAO`3&`n903bl0+=LL`)q?@LVw689aS-4U z>J=3Eq)mPqOoI%wCXDlkF4aqmD$tfu${V=}TLh2#sb8 zVFwLOS5y=)f_^KZBlM{qpN3L!Ps4z<~FZ^hG;hvnimyx){A;3LS1Z{9}g6v6m}IfD$B)M zgSdk)UL=Q5OT%H)0EgY^F{ljjV;azv9}!Z-sd(Y&viA6sxP@%7i~JiFgXU3_328O+ zS|I#@L%2b;M3n>C$Z8Ej070=*pLvKL?5;VdnnZ7P28n>2SZNW5AVg2dGsZdokV`5$ zopT}c#FYRdwOm@&b5ZX=Zkvud&gj;Ox(JEDrmXop*cwE*?=(np_4y9w0pL{x(WgJ& zl-~8W7XZN1o~YDYU+UWTAow*vd5NoAe-5}HQvHcNxC2<8?&PwD%n-)*v74?mbSfA9 zpg*QGkss9&BitGwMlZ||1&a8>NZp_t0ds^p3~{TP39DB(ldp*=V+)8)EV__`VBLJC zfPoR5Atx)X6}H%3fOVmZ87LQZ0PM6RBr#VWJirLY#l$xy@|97>SM;P6Gbj#GS!?#5 zedwreC%vd|x~yRNScWcbHQLdNG}!fd=8HjzP*j#;A@#gi%RH$df}#Gip&cY57uew8 zMIy5UGc#mL8@?!e0AnNi%~*gV{%OXH6t@u>QeNhX!G>ATZZ&7MI0R?044m%N||zFT{NVMVhqF<^_BNcL^hEn z%c8Mt!#7Hlh1xmvT_jAi&=CB`djztOLw+x%r2*su$AO~d68;%-CC14U#m2!k95s!b zhr=;9I&k`Y9yyQbH}m?52X9Q2UP=qdU{;`Y_9@r1Fx+J_9=LTGH*qimU_n^G(tWVtx-L4WhC zX&#=4PE4Nil@D0pDjC&j;U$o!)+G4w{Y!r&o8RlEZLBGg0vK!)n(NMpO{{%hfsEu; z+5>>J#jEbzb_6E`=u)e^2~K8zd)f*4{d%j7ka#*b_n)jiLZl4?IhoJ&uGfh4{e+Nl z13)7F=cBGOsO*kF36HP|FClPKjVaG2*xnuM=r~= z7A|VyrCsW0Npx<297yv*(D5!KZp@*x9xvi3Dqb|IJ8}U;Ag_`lL+>`@)KulVaEVY# zZ~NZMa10F zP56*6QjEYRysaQG5g|6IaI}vVC6N+w@gT;{_YMUWf#*#aAPJISb$}7qI1vVF@pDv> z{ldikfC}U4FAwFVjy9`=_)ZUB2BIW`B9!FrY>MIvFvKp7=`QRm^lr%xW61>PL%gHr zjIJ}L<_K8KSFEMvs%ii`svq-E@%GOK!9(NdNH`u&u4qUtoC+M{@fs;28`be5*ij%U z@-n~>J$kUuz-)<#5HQB>Yn13CBTb3U!)6SPDcFMwO-qADk|oQGDZVgT*z6*=;)Ias zJTR%E=!;-w%=_Y3JEIY9vHpxytY5PdRF>}!&&LUL_Mt z4;m9k8jYo~2*w|M>TM!W;hJR|8SWfUOK$Q8fCSLu8V&@}kv0`*A@xUqcB9lt5(O&~ zW!Qocb;i&f>iw$m(q2v*Auc#mkS8c6JnUm@hJq?!h$5r{@pdCMZ%7C)E6SWRJXeM$ zjUywqY$K(vBe$YIm}RiqjQ(z9WF}RJDIDuFRFXa6O6Ab#@p4Kmo=)ygC<%)K2q#Ys z%MkErZpJ{wfVgpBhQc@5LMQ{nH+6(6jB+^_@8+I@vhGMpLWqVaL}avuXSRaNJkKfx z;v0-%MOPG@yDxZYA~<`#gJoHV*_g1L(X0k&ZrVtd-X+5ENdApY2bqa;Q; z!gV}O&Gr`MOdujgPmkTmR2i!z1;B}&V4_Di5dd22n1)f=gf9Vv>q*13AP690y;S`u zA_pqgmN2#;Xl?kebYVZUVY?J$qtq8u7TsR$-VUOeUN85C6lQavWPMakam6Wi;0SoO zXM5I(hGH`QOso?I4=9fkaOBlGaOO@MlmIQNq;~K+wuK)76(|E$I!#w#4J-uV{(*FdJjXfgCBpao-Ij&!$z zUgASvG)7-PZ(##dAXoxmt4~S^7AJBba_S*Rn^a7RKrKH41`^g{+hlc&fD6p`d<~#u z^{M{#(sXgo7k$-NPS{s5UjZi)^D}{m2$b}5PzfRS_uEDSP02L85JG?nvzj6Re+8m@ zaW*tDa<4+86Fv}Avt{F$_TjqG9FK5mGXvx2wYF zRyFrlgH?a(PV7z&86F^alF4F2RauPiL>N9pA3h`o% z40Ktc6FPmvQhWFh0RzpXsBR}sCoND&NN5RzHih@%fC{TBtK>7oC;V;?^lqn~PX4w= zbAoz|0Tm0vndUd&GO^y4O8`RR9KKR~r_?6&Bo+-b`!cplQdwior%LVDM>!eawh0El ztD1VrAW|ZD00q2uxgdHORBqHC`q!SMrzMK{n5hX(YT27^xq%bM>p){+=&?lo=t9tD z4%I~+OiT!NYQnZLUiT1OI&WEuBMq>jj%Sk`dCD`W@iuYfjm_$Sx{#VhRXjI1h%8XU zI)R!6gG9gMn&mZ{`EHwAu7-}pYi2|?t|D^7Q!7k#JP0o|{21?~;wmnhA-uU}!uewW z%Pp3;BAeJgX|<#^MDix@jMIhkSeiUlx)WX+rHweg1WOE%Pe4fr0YdwKKEgWit?19=79RO*N3Qt8Z%Uz zwSC*@Xj(>m(;Q|xx~==Vv3u@>3LC{Uc%Qp!p1LJq)^Xm85-uSA2n>a)TX9N5fFXv| zln25M@?gH{n@*x9dm|tZB1f6bbTMBAm{bpV{J~0FBHeC5^$Z1{-Wn$i9Kjhv!S!jZ z5%Xs6yA1MszZKS)FCYopWS-g^k~LAoIs7mW*dM+j2ri(P{Q(LJ0%lteolG3XRh-3j zR3PwMzXgK7Gh0l)`@oELk$aqP37W6$G02}=9kzh9jeN-^FuR@n$)WtoncRZBd&=!4 zyqm||BH|}l>3K93uoB0Gx>;GADY>E72z+bNK$eA<%rsbHd6eDxY6-hb?{6 zIla?8{nH;N%0+$DNgWJ9{a&j4)KjZ@vn!rzi2z>5#@(QESP2;2d|;=?Ow3P~NChKe z=K=uT7-H|N(NX|JZ%_EuCi0-y{Xrhw8X+W8OqRVNoL%|K2d-~+e1bjp1VF%3G~OOj zAil5LC*m81)Cg8ZAiBqRSb3Avy?V57+bKO)IGxP4K(QR7sa1X7`Mux$J;+Nv;01o* z;ppGpm|YG2GBSI^lYnFA9Vg^+5*0jgB=Hy%2L>P;XE8nrJ{dG4o(Lu$-+I6p=`BnI zVC0ja6yOio&M>eKI&`2)3J36s!#|PBk8q%>$$$`%VX%lKJ1;`>&dOiJ?{m7@Cm>0x61AjKk?Dn@Vy@J z9sltmKk}iT@hQLZE&uXwQ1LZ?^FsvlwO;Z;KlDX^^v}ohP5<;!fABef^;thOQorX( zKlWvR_DTQsZU6Rhzvo+j_jf<{|DE=MKlp{8?R~%ajsN(kocEQV^O0ZGhyVGZKl5?`a50vz5n~cAAPug{`|?m{JBc|(O>M%f6BwZ{oVilNn-uwfBxwo zJktOEhra%M9RB&g|Gi)T0m7fafdmU0Jcux%!i5YQI(!H*qQr?5D_XpWF{8$f96Nff zh$E!Pkt9o+Jc%-;%9Sizx_k*Urp%cqKia&BGpEj*0DAiT2{fqCp+t)sJ&H7`(xptB zI(-T?s?@1es{-ALHLKRGT)TSx3O20Rv1E7BJc~B1+O=%ko-GSEu3U^zFN6poz^dN8 zeEa(S3plXgrE?1#K8!fA;>C;`mvsv{vgFB>-8#OES!dG}!|Df?r*ogz3LrETu&?t~7 zJ`tJ2hcpgD)deXEc~t%n1OX~gXg#?o&^zx8q$5xQG>Irs!ywdYQI76O5P+i!1gSrg ziu!1&LFG}Ya6SF`%{S0oDp0Muiedn+{+#2PK&JR)p+NFLdXB6@eOjrn$@X;WuLP|c zRJ5r|+UuUd`ZFw3czz3RxZ;jWZbEgQi*CA-mCJ5dOWh(+ramX#S ziu%K-QIr}Z90TzB^Nw(*o(2G{0(l5P3h`~*F90*vNWcXeYizH;0zI?j1<=GhsXzn+ zU~sG+`vWonB9n}ABcec>@K6z?cutG}(2FpDJo|&50rChWVi3Vn`;!p^G0X*!k`7$( z!8Hp7HOf*8$Nuv>J`a>{Qtf^XcGzN%J(dvao{e_eEeSz(+Z!phuRw+VB*4i2%uVQS zL~ubw*FojLg$4xhE0hLF@O>swDL|1#2re(0)W7dAz+#j@13-yDl>CGv!1ulg)W3~1 z6@h2{^75h}Y zKcoBPhVrRTUODVSv0gpU!4J>8@o2gYe)!^#KmHbKpO60eTaeFwLvUN3L{NgrQx6QC z6Vz!Ab`Mk!b|v4E178^gKq+_zLFy4OE(q`(W$?_3I=8%@@XjCd>PhEdP&e!OWK^9} z;5j}h{=K9L1PV>zLI{0ADHu$!f&jQ+QB3HMilxwH-}oS)bQikbDGLB`%h^wgWunee zOK1oaP7ZVkfaXE0CpU{B09FVT7yj#VP~;r!dKe=hdaHe0?4lRHh@eEIZ;WKDQzF0! z#-&h79!3gE^9W+2BO=5-ar*|45K<^2AVfp}h+aY-c*4+m25HR7LEL=zli5KdaR6vy z#T-TnW7ujKZ6pej0+u;{aNz=j^pxC+a!H_MvXY0~;ZG#DLjf3&g$@hiCxK@`e`o*z zM5>vUaF_$>aZ**DyxS9jgq~dzQf{iGAW&MlMKmgNnapgaSCnD4GD>rrlM#Y4oXNia zr4UIV^)N;wbn_Di;6@;g$O8!}^$a6iAPIKcT<|{D2rlFyMbL1AYX~s380G*5RuV}3 zrkKtUxM5p>tWi;Zw?S?);9}1LUX&2|lL=PrjRi4=&g2&LKfWOx zu(Hgroc&k71VE#Dob_ku2>}su0N)(+4FJVbtYnEb5b#Ryz44uIeSxxA>zcGDs4NN; zw&kH7n$m?v3BmsShtXUOSSa4rZ%__A5WFGbXg3T~DNYW6N=~9y}gM4<4%0TzZZvDbVrBei>K+1OB#RA;@gDRGW2kY#l0Q<8N@#=9T?5)k0FO19K)E{Kw1-A6KSy0W4ENz(>mio|se z%T2=aPqzFqN&ke_NMW`{?-=Z-IC|RDuC}$WjqSxKWu!qdfM*bC>0SOR6J=20s&~!i zPF+kMd1$x0-B5-+l!>$4PRf*rL0UFLl)x#n(UO0X02p9!{>PZ-TV!Rj}RE#}FdZYXk0(wu<;aeEI0-$L=Yv5}hXn9F?T zG_SeM6QUF-m<+a;0=EW<)GzaT8izP8nLi@#21;B=7hqtoHo)k4Fw7jYc%1pcUS5{ar;zm z4*0+ee(;2Es#2yXcdnyKIYe??mU-zD2o61!QBo_+#`-G)6bl*|68DNyVAt3&F<7=4 zO0mw)^sVzd$pC=iH!{T1N&-c^!t)6Xb2dAFlzu7xwC7&y7f;@eTb75E;_hiHW#fLb z)d;E8u0W0}l-%!rIjK#8a-=^L0TiDqh3~%izYqTKKUyjMtWg<40D~l``RzbA=S<6R z0U|JqWdvAz+z{xR+;8;)TmY1bat{qqz#G1@B29931ZNuV_HKDGK@^k%Fi>1iwtD@7 zPq`w13M5;r1r*Iw4`mPm^Q1YCmw*bG2X3H&#KTP<2!j6bcAVC0s@7`_7;hwqAHnkt zL2v=(M?$}sBnfaa0Ao)ih#o{Tf(mGYPc#sPLWDDDgaROgG>9KCCw@~%g;i*UR|pqN z!E~TvdvGLf2$3viHd#sdcm@GcFoX~*(mehVltc<4R&hoU#@AQAh7k3jVL=fAnMM$Z zL{SDIRz#792eEHTCKMBdL)(N$mzE=ur8*6mhHaP-G}VBG(ucMNYYXvmMS+Ez$cdfk ziJw>zO_2uzR4LIlWy2B#0wfRPgfI*shCX2gz5+3zMi8&iQAVI+Nzw=o)Dy*WbupC? zx|0Xn;t#7h6xp&oJ+MC}6LCaw0mWr9zvF6VXlG;sjWuFZQ1*g8ku5lch6s@_MvyB0 zz(y%m-B|)J#80kll{$f`_!2Xa%Apt~?S`t_l7nzZw(O{r;f*vW7`bd%`X_6;d zZ6c|XE6I{A>5?xAlS6@$Gf9&*X_L+rlR2rAJIRwh*^@U3ltC$!L-{p6X_QBalu46A|il>)MqQ%RLoX_YS-m078kTgjDTag|>QmSHKDP|=lTX_jY+mKQOWYsr>v zIhJV&mvJeVap{(IX_t5DlXIz;d&!qgiI;x~n1Lx8eMy*wX_)#bn2D*Fiy0M%>6niR znfu6?lS!GCc@>d~nVG4XGiRBd>6wYSnV~6~qlsdlX_}|$mZYhgtI3+!gqp7jn^)PI zvq_t^DL#<)9l1FS)wm7bpn+wu8m}M-2q_ft(Tq9p8@?fX zM4=w+Aqv%5oW~iG>9HQ|aT~b-JhzaWxoH|VrW*Iri~tZDvr!vK(I5XI6!0M**;N$u zNgMWwp3M=3R$-g}37`QgF50vZfA|zgGKfx*H>73|^5AqmkpiUXJOZg}xi~FIQGR=( zY;ahN4{8({iYm&+A~b?}dilQMs`FpBpua8@Z>V?AFdXI*scbKugKLYh!{u&S25V*I+lYT znbd-v<*8$6ugzorBF!UrZZ#AV`x6vvuoi=`RY9#C>#-lp5khKwqE;L!$|5#Oj>V%p zWgtNBu!Hk*M>Xk(B_lnzI$%2>7v1bKN%OsO_{{;){FV>GeX9KDUbuqGID5W3M!OI*7H*b z1r(k~zV zD@SEhIE7qd)xYGFze4p<(5kx!jKGL0q<@q;pWBTIaCa{Y0R2=m%t9?qVj`5Xu-gP6 zsHIv;;YXVEcu4cT$>XVCXbhdIdb39(oQ1X*I}p`5qA&QuSZ7HCD-^B8SMa-OpqB(e zYcZL1f^YO3pOplSOTDRQBrrn=r?E_ zzeG`C;dLojjI>07VZXXN50(@THV`Y^i}~fKUh~A6tjVk?q>X@n0td=4U@!ktL8Wx9 zpOP>Qcn|l`V|yk?%ky7M!3EpGN4Q4Fj~6gamN-9{1Gp0;hon+3Jj(;YN4^|~z>LTL zHWZyZ6v&2mp9-~W7ZiFHtZ+vVWJeTnhRv*sXFR&e-we)~DY9_8xu#4sH&R4#dl2=L zBHs`clA9E6yk{Ot$a84IBfEhD(F5w-YOlm3uO!cvLeDYV&+hxr20ei{47KwD{-^rZ zQLmGrz6Ncp<;>4Oqv@ez7H!Rf$`|46(H~uyGXhMubSE5oZP*(vce4}7HoVL@tanr+5LI`1 z1iY%RPh?eK`V9eHHn2 z-;j;kIH98}OPqUOox@fDa4MM#6hcNlJ)wN;nJc}0pUC&u1*gnH!OV%R&xWkNsTZqu zHA~tDbv}_v(1)zehdIv&h@=M?r_J8&EtEn!40BBh^G)BBpfPD(Gu7+KFfe5`L#(0z z1H}m>!$Yh96asP9UStT?!m`rtwu9T$2nq zG$_IKVL3OLg9B0Gl5O1syM#1&gbjEL4;XXFizKKun zhk`>R9X@$CHjTpoFTyB52y{$(_zjs9G`Mny8I%yAA^_9)an%8JZcyo&P^z#vjw0ge zR&EsHSPzD^T10_bjUyNW&;#Bb6+Ya!MG*oZ*%#k_#Hs#}4vCu0j_&DRc(KXu?S7Q%4(}6D zr1MVi^=|L?j_>)d@B7a0{qFDo4)6gl@B>fq1#j>NkN)rpukZ-(?hWtoFuCv%FYyyk z@fC0J7mx87ukjnt@g48+A20C^0rDeH@+EKbCy(+euky9>@GbB1qOtNZFY_}`^EGet zH;?l%rhHoZmYMpy&-+=C+CGs4 zkkZ&xQFLA91{o(BcZaHgo%@T~`_B*k9&y&$1Jw-%1wfSmJ-|3Q5F@Ws{ig*5P+-{E z`oVg_)&Sf5lo|c&&;Fsf`VHF?dQ@X;=SS#4FXtcu#oI`o>R9}&Jq832!vG8f(8%Ax zgYpCsyl~H79#aSpE*#kKAPj;T0f;)7C!+y*{@xkTSkmN4lqprNWZBZ?OPDcb&ZId| z;!T`6b?)TZ)8|j1L4^(_TGZ%Kq)C-7W!lu~Q>am;PNiDa>Q$^+wQl9w)$3QVNne(k zXp+FWgE<6z6G@WbtBU|4%0qk5*jb(anJnH^%T^!&0mgKis_0N>nSmh;V>HRpUyh6d zo*mRs)AD7^nKf_bEHmt9(4j?-CSBU}Y1FAzuV&ra^=sHv$Cfo2(I1SpZ8JK?==k6_ zQDyQRd{^c|j#~$x0f3UnAv8C6B;@{U@Svy^$Ppsm>yJ_j7?b@OE1dRZa(^i_I)>cY z{d@TFz-~N64`StJT-{1d#00R^dq1pVQs3gY($!(trK zA&5Ev2zaNXj3_w>8Ugn5XdnYVtZ<+lk|06=v(RF&!Q1Au@kShTgl|9|d-U-~AcGWg zNFs|gao$wNAz(uapG+yY zbJUcwMjhjnb51%$jCYpNxoM%0bEStOp|gTZsUP6If$^B z%Qo9aozqr(ZMNHXJJ153X@n9=L;?CBO6Z+9h!6xZtVVMcJE)Nmau7RQ^>V965b3(K z&Yw)Oi!kb=?d2GN5D?n;YJa{7;)28df#Ph^OXuuu)Kgb|b=F%qt-zg>gn_4`89TS9 z6mrwN%fJT9_}9w?m+&NTb7I^FQ{(0!5#|d_c>Vbxm$~58vYP*I59OHo^ z5D%eD1koG-{v$p-D0jAwZ-ixp6ml3XQLbh%#r0D%qAv24_y2#ENKb$RBwzsz$gTOb zC<)nX$pwOt1hotV21&4;OB7ha_k<>>sxg$SWMwOt2$K{H1Af2hSt@P$L|5igm%C&gOw6Z~XK;-<(13<| zTq1y5Kx0_9WG2jb`Ald=^JXsr(?QNbi99rdfAt&4r8f5|v>dCM0RXsg zm4*CLBIn=&*DmllR@L;TQ!$1SxW)jT`lB9PfC3ks>Qo~LCZJQLDr$}isU(y{5c2-0 z2Mm0JkiX=>pGuWlcZgP{@hSB{x>J%yLLk*hjjnRQBS=I%l?U$OEizvf31X)9H-9{% zrip#^`MqtM;tFOt#8fh>lTdQ|2DxfR}W(}f(RO6fE&WrJ&Kx$45QlUON35zEd&fDPML zH^VKwGn9cxUjOd<<;*g~3|W@IOODKSB6{$e3U5P1>=3XPC7 zV5~FTdICVIDg_lwpj;11Fy=UrNi@7hBJ6%;gaMRzg&?ZAoQ$ME6t{pv@wf@?iF`ZU z70Cf)^eygwms(n<&M`qk;8Fnm61U+~A5QM^4tYR>Bu!>e62N*UDCc^-Ogp%v4Rn$S zGN}e8C@m1!FXZ%ZY5L1m@ zKs*$~Ir^CU=b!iFnIv_9F;4poD5u z37u<%Q%V6yX)%^Lo;e!{ix8KLs6b;FsvOiV0~s2BxVP}3ghr5nBwWHK97BR&LJ(O% zQX#m5U_FgEyjB~$6zqu&^an!g0jL=Ul9)j%^D#Hf2skvsNSQ;OkcYz}09*S8CG&{h zsX<3PGnd$lO(O{JIkfQ_EP0_3Y}2!W_@(2yvrz+p2U?($kgCBN2Pd=Ve^MYnKUMWvN`4mg5iv$Oy#4BMw4@J06q> z#Nfj&1Pr{xlVX&JV`PXNP)3p~i)M5(x&F|JbATrT070E_I>mcLcATo02pwZ60~1RE z6l*je%mXO6lzK1@B9O1jQXIySfLSam7psef5CHXAH~=^t^D{BONsBzI6!7p320^tH z2m#+v57uh29FQuCc^pA7F~_J7hRg$pGz`N@2$O_?ha4VZq?6LX2*~Qazt|syP%UPZ z!*S$^I#iDoxCwRq6PX;u$*M{I!AWq`Nfks0V}b(aS~6ac%-31hIRmyDCr zfQ%p@O0xI{3n?s)%1NSJ$}EdU{)9L=e;O777%M#iOu_7&!jy=^Ov=Q}iAF<5CgC(9 zQ>`(nO43ZHn8*W>BaV3yfXN|@AP_lu@UK=xpN2E09uT}&s57amC5A|>ddP%R0>Mn+ z2v2#RtouVmyoqnv7Xjcap6X5C1I~)@P3i1Qm?Vw46PtRdMX~t{?|}x!Kul-cNoe#j zSUeh_F@-ak#XiYSrQ%ND`Ofgv%<)vrxf6v~Fa`b8&*W&d9;ge};mq>sX;Tw}kfCnN1K{JU7f}oQSfF9VO1=WZP+RzL= z#f-qvB+yXn9F@|bk_6!X8vIE&07w9YtB7-02;19Co@7j>B!~zQCdJGX8nw|6!BHKJ zydEXFzrnzqIMI+{3kJFXu8B+n-O^}^Q7`=<)1ZXV5}zNdGxH*eb}2KRY|Fv(my8P+ zzjBz#`4cf!nejQhq%j6G{RTB9%H_k+?30nj0)QNVOD;XsR{By!9V63_hg%Q@Q;4-; zFoj`IpLh5M!F!5#cm+|Iw^eaeNR?DdwNy-{igUOHQz$@0T~$|FR97_}R*h9zwWL?A zRkN8@T+LNTvQ=Ic7+w8UV1*;T>{VhV6#)oVWKGsC8p2|2);f^@WsO#8RiavSR%?}$ z;_(riFraNEpl<%1R&ZsFR~Xi7Em!zpO@Csns?bp$eP801tYY5+oNq;xt&`qf?Kj4T+Q9w(-;H@7y!@>UD1VrLEv1| zJzdnb3RU=A(QVz)RfW`TRm_N8+I^KZXk8ANf=$@nO_%}>Xx-AST~MMV;Vs@kx!urx z0&LI;Yl2uwLw?iR#tf>*e0;or&!Q-|iLP@AZuFHQ(}Gp7T}T z^zEeeb>H@ViSR{V?lmOj9RLV`-JV$8&<$SHfCf)3#9Nrkqo{`gj$Q6zvhnc<3BW9{>X1w&3V{Giq&SCRkW2(dAhYW?GKrVBY0p{$*n>WmAI0H>(9fm*pyZ^8 z!%O~Jjp*c)7-b{Q;!!qdQ!Zt3R%cBn=3!pucNS)yaOZi3=X#dsoS^4?wu=jq|+ zfA;5_(C2_gW@a|%ct+@ZR_K0a=z>;eftCn@9%wT@Bi4Naqi|mQ&54Ioj$4qCmm@sl zfEpyjvY?0<0uYad_{mchvYv3%R0X!I)Zv{Ph9KMFXUNZ6ATk?1%Woc#l33@UCTDbB z=j|nDQa);;W@jy)Xo&`BijL?rmg+EuXorsKtA1*#_T`G^>OZz>u#+W6 zv4(1`F6*r(qh<)*7A6X82wlwOi+GYWQSi~$qG_jTggO&|>C-?nExG=MxPU)20H7fc zOlzyx1PV%ihq*Jj@%aW8{j~AW2(kQWpbqDDHVLFI>dsc`b7tx*2JNO+YqL)4tsd*D zR&BQS>Oem0*miB%9^~5YvR=JkT85xqv8G&s}q z5`q}BUwN2D6Mzul8tL{4N^TDVfB_{H33!qKgxfjH*6ei62%+Ze(H8A=W^beJ-p?j& zDrRliwr$$JZ?w+u+179S=5O4_@BI$&{uc26_HW&G?Lh(p(47LLm;%rR0-WIPMtFwB z`NkYp?oMPG);m(1Kxvg8h~$)q{Zj6S!H67y8b)MpiI8Ll>HaXX!;l`f4QTL};^}Gg zK5v3ZZ}eVj_YQ6N=J6k2?;vmQ*QReHNA1=&aNY*+0dMjGC-4JTa$aWgCwKBFhjLvW zB-W+oqu7K+j&Q(0F}=RfsEj^#ij%1p)z*#znIh15^tGSo!32qh%Aftr#h=?47 z0#Oh=RS$F?DfH6zY(|go9zX47Pxfb@ZzQkpDzA1){4ubd5QGyQ*_p<0z(@fopa|9)1}IPyGc)*u2atuw zVr5r!W*>5hpZKVM`jHR!k|+6%$Lgx*_^e;+t=IaixB9M6?XgetkZ&& zNcJ$kj(G`rb(~Q1aMW{}E>(ZHF1eamSbv!Q>}pPgFq3hVhi~{S?4fC-PkR4YdM6&| zWsiKPmwbvZduqRS%+GdgFZa#2_|EtE&o}wd$NID9`qBscKmKjd4ep~D?&8))^R(ev zf(#M|x_M7?n!o#6*I{@|D~_W3en&a@XYC(oWfe*z6EbSTlHMvo#*s&pySgD9T@ zKp}N1RjN>-R?Vsvr9hNgzfR@q&nwukV#$`xdNyiWwE()l{kazCT)A<@`mCGxZrr_a z>GB2q*Y04yWBc~hn>aAx!-5?*#=E%iWXOgk^HiJ}ansJ8KZ6d<6T*N2PlP@xJwQ?? zbsBR3$aoOi)u#|U-aZIhwoZpAQ^eG8@1R764sVx1DIiD+<;Hd%|9BE*2m)6HL9-4Z zL=Wvo1QJB)Ml|{I=Fg)~uYNsy(Y4!3t&J9be7^Lp+P_~PuKxXp`=g-QP6FygU`__& zgy2mI+QeW@4srzHMiOd7q5e~hUB=mf7*>YahLmjv;)j-Xs9}K|hS;8pE3#BN9yq^{^vP^(0x4KaBtYgGLhF;!lyead8y^ zRqk}kKH)`_MqMUQmS*M+M;<+JK@X5!Pero{;Xn%tK znO~tIo`~pyD30i1q9ms1=%R`y$mpYzR!ZrDmQD(3qb7EW>86@C$Z4jYUJB}|qLxbP zgR15ktE^3tHUI?J_=HWZ5UA6MjjiQ$k1^0ZtU$-sbI>r9 zEc>NK-<(tJtlxqg{;s&=g4?IL=lXdcq3RmSE`NizOP_^b|S? zYwy4M){F1G6Xxr0!3ZB*VZs70>~O;uPE2pW1`q5nxf^qw(~PCv%9E}z`qS&L_B3@8 z11q!KvH;C4RnG+pv|KX*L3Et6&O7tm^Lpq4eN~|G@=0`C>&k0%TJu5{F~u1}d@;jN zW9+oWk6GQc)DlxYb=FLS{czP=KmE1XUXy)wWNCNpv)gj6p&A4+dR*%OgB)ZsYfYJw z0}+1%9(WJZGF1;0K?I(74?MozxZ{sQ{&vulQ(hZfl|ywo=4)-vdFPsM4m#$dS57+R zrcaJ~(5eIe%{scSp9{OWviC{5e70MDSm%80j{8-8{#mBo)IDWbm+q$0x^g}9O6+1 z(LxsvphnHBi9KeR3nD@>ic*{+6{~2)D`GK=I%~!nfB=CnLI4PBXhsGl@x+@@&qhns zA{+kO=*Bm~F^+PaBON`M#!loU7bmMDAN%OXKLRq4f*j-^?NPm)G|@(9BqSpn>BvVy zGLn*) z%Uj|ym%7{~FNJc)osCSPEvX(kYq#567&wJuCpZZi~EPIkhNpRC9 zT=XIs#n_B}A~c~2T_{6s_{*N0Go3auA`<0>w}xUgqZ-{PM>lrJp3w7@HDO^zU;aq9 zj-oWBDqSf{nR3lsRZpH8iJJo|s;|Siq{D@2XmrDprrzRrP(9ePMOqSm9Sz`JI)1=zD5Bfnv%H8g*_u zA!%uJYA0xj0%jYL$1R5O)QzUqeu2f`VD(p6{vFnTT-~Zw7wg!@LN=?9m270a%2>)` z_IOY2=TAcFnvdBksT>(uX->K#c^HFmc>sU}+QgCQ{57NVN!q&(^&fcE)?K%??QDyd zTiUj*Y-|f##k7XC;hv3aW-BgngR8a-9XGmblP+|p+nA?5^e1|2&8|f2{=8ku3AQl_ z!A-;)TZZ=bXi6&!(tNvH-KKYG>jg`D)yv%E3YWSi(r=`Yd*A;$*SOZLE`j$uU;`Jp zzmIvUHun}3y-|T=KLKqFStMS92*)P!Fa<--+tB#3cfMv3@m5IemfyM;#Pl_BRhqlp z0K3?~Fvjn55o}`x9~j38{uFrZ7%VfzmpLpv*Wy zHFadIoTo^jTCkmt0IGwMc-y+2xNb3v@e^!158KWG$MeATTyS9*d&kFCc(NJ3Y=<|S z*^8!jekJ~Fi$@#dQ~PUMhXRDsOo1pu z-w{YXa*zVO5*?gUudl!t&-AGKn%I$V{NxFq@Umas>KE61#yLN2tXJIgSqJ^rz0UR1 zkLa(Q6$;yl@^)Lr9XJP}J8nL59FpiJHhYits;7Jog&4=@D7kmP_$p%#R=`%U2X`({Xqxs$sa-hpa1#8 zI|xE^K*cK%pg0{M0|p^(Jz&!nA>;{R1b$!zF5v}g;PpLW_C?|LabVR|VT@^=6{bjR z4Ovip+yI2!P>kFFl-&EZAh{hx&Dmb8#UT9k1`Qen{{`P|9E(86!!SSrN}S0LDxLy5 z;1KebA3|Uf5+V^^-V}b{6;d4*lAabe;RYgN20kJbPNEc6;>P{eqXosbbz5nGTkgfl zYT;WO(jObzpBv@`-^HN~_Fr^714^jb{so{SA663}!j~W#p)K}WAqHX=G9vgnq6jjf zFgoESCSoLZ;4p&VGAd&;>Pu_|RZuj;r1_F++yOS7Vw_CJc*w(Q^&AXlBmR{c?G;Du zNd!e$1V+%}H|gT986qws;XCr;E&Adj+T)%nAu&RtF-oF7QldXvB0y3hG6v%aa-5B* z-A~lYkpTtttcEbPVVuy0e3T+M%HSLRMsT=RIl@+%EC&w`-8yztJ4)U>?xH-RWG^=2 zFDfEF)}u?V8@NxEYodZpyOY1sr_ z4ZsY*!HqZ(-}p`92+m*LCPpRJ0hMR)oF`RXmSs&AdsY@$ttWi8r+mJrSjDG(&L@7> zCt2O6`wg*zI_Qq_Xpj1+ z&O~ETU}s6GMg|S>hfq%7sDu5J zj&^FQs_Lq8)c&W21{{p)q;6-a;)pY`>aF5xuI`g`qQ{>`>ZDR?sh$xn>1weW>#>3p zfQm*gKx(9>0fLsQB-Btap(?Uk>$PI*Emco!h6Xl>s$##UC{Zb> z!s@TW!Hxb(-fUC4(rdliDjoX_y$bBW5^NixX?b))vvTR6vc{4M z1f~{j#7gYM3Q?b$2ebZa8o+^rSX?LA5Exl5EME?9Nzhc__oT2I{-Ah9>$$ zz}^H!l@tWn)yd*)&g$%(xGHF1L%X)DzmAeDh15+9DUr(6&MNKFGA()Z>}S{m&?4!( zCg?d${!dKRgn$l!i{4a>)>YGjZP+xE=NEZPxb_bJq`~-!7zBE0SIORv&aEcUjy&3_I~9pLU0g2f&{NG z2zN007BLZ9um>k``39%?HgUd{@MoyQzJlxJc0$jZjFF)R+;IexIT@7Y;FjfJm6fqY zWZ9O$OAx@32n6)@fFDC2l?JyBg|~fT8$!jn z?O)yXoO}!&yP=ETX_EB>@p4`MSjupvYA z_eL`~XEaEMb60zGSbz0acl20GbWO_Snl-A*ek+omG{-KJDBHyPxt$k>+snkCE8Crt zu%g~cgj99}0Q50<6vRDzgHh)(nxKRa=7dBrH9>$QRHsKmS2b^1wr^TB^<}kIpLIn$ zCq|RCSA(@#Q}j)CwrK;{nw70*D1+2KYo)erItv6{tDs%y-arROxqYJ?hoeLQG!Evr zZ!`il5H~aw38(3VV{ZgxYl~!0HbZCjAYbu%tD}4;rE{wXWiKXG z7kG3#G!S1mc5gTNHuwjJwpfq$ghx1Ohc{ZMwukX5dQ`E(KC9X)QYdq)Ke+c#z_%&` zxL$u_ee2$R1Gj#o1OtS?jMMl9{5DQBM}M!DK?}Hf5cqCZcaT%}knh@pGx&vj_C;SZ zg=e>eBROh6Id_k@Ciku+sD~#nY#pR99?cMgvb4yN918;YJo|$h0(WoUAbx8Lj0?bD zz5`O+~FP8n>_H;TKsd3oCYL=NR~Gj>iu2>>+9E7~-k=Q)B; zwX9dRkpFp+FZiMpI%qR`p(A=(54xd4c(3z1(d{qgO1hxVsv2$hMoeRAB(L*a8m9}y zHtL)=hB_$fbpNS&UJFD>002N=#8L0WH&{mm6bn$BBCM0gk549a`+1++y5t2qu`ju< z3%jo?c{bzwuZuRm+u9U=MlQ6qpa$!b=IcUsG3-&hKTKq8WP3zz`^=GgVZSm;(9A#g zM0fyzKcj>ouseFV`vUiHyfd@OJ9DnvJHM}dqPIM-_xrxP{I1`7T9a_Lp~uR)E413> z3U5RM4FL1bm1?9QLG$%N6eUUo$&h?RkvRUfQYwj313;=@i7jDwE>;Mj)@?yD+%O49(Ey%G^xkugK)<=DX{;@6-BX*1O!-J@Jn| z-5izs%1)(T%idbht^o?7aGXQOS+a!~W-PjNu>-c>c}e=)Q`KE&!STROKz- z>h1cgfBX9_`@g^Y1McqHZsX!E{Ql2B{+q7;qpoJDwOs;)KY;@Y7BqMe;lV^(jvU#t z1r8iZ6D2A0r*2}Wglh(L9Pk7iLTpbw4p@^AWlEJRS+;cf5@t-9Gilbec@t+&ojZB< z)CmA+P@zMK0uWdfY0;xenF3wfH0e{QO8rfZTGcAmtXj7YmD<%PRJQWXTw(>+2^{`DJscJ14_clZ7se0cHW$(J{O9)029!%^o-^B5u=!cn#i6dwcL`+F1`E`%rM1NQp5V3tRu=T>X7Whfi$#e%sAzo zlg>Kryc5qnWzsOS)9gVLzBMz#OrRfy(-YA|698`#08tkxhWBN3weLYt=aR)NA z6ML%e2qgZINb=|;vF&CpFS7CuYj423Dhn*Xg%VsZz|k^XEyUSU+%3l8a$GLR>5|+o z%JIsZaJ@0NoG;G(GRyP8LjSw((Fea9C$brnq}RwKQMlu4C7GhS>rx83Bd$^3-LTSq z-~9K#OfNim(2Eazc+G`B{&40KcYbl`8<+lZ>La&)a^7hw6WU(Qg?-(`B(bBqK2yR* zzX5CqP!IqF(H>c#P?(?o`t7G*^ZWI;KmYyx@85s=17HC8$2R~FP=E$R;QciB2nRk8 zf)RY6=3qj>msrpx7;K3KS;E1Ucu*xEOo<3lLc)ZY5Fsc$hzbqD!h*PvATS(=43|>F z3;u=$etRMmalA(@%y6$DxzG;;%%>9c@kc-s06-aXXe{|HuzmxCq7)ZMMFd)rfLIKm z7XN2J34SqxG{lMxGibmHn(>BY+#njusKySmagA>LARON)#}LwSj&>X&9`C5f67q46 zemo%{|JXzwN}`B}$pyK5RR^6_L|+DpgGbia5>-zAQ)*WMq3hckb+DhE_bQR74q_zzHA{dhbhb#67!hGj3F|Y zsmvKN^O?@HAz9iu#x#QRB~m+$jSdD8orTO$4I7Alo* zD9AhHF%WtR#2CXchCBx%k9GPZpT-!-IfgL|XdL7l1$_og+9#AxVpCS49OWwun95dC zG>WmDs79yA(JFe8mSDW4G_6U^I9jusCaoh%PpZ;9vUH^`?ITQID$_vHbfz{fBu;Ot z(_Hd&r#|f^P=5+hmZ)u;P^y%CE)_d}G{Yl_WC=DrQZa($V2NA+iYVHr0fHC<00j7k zKZX(3MkF980?33w-hoy@gdivpc!nE<(t~^SGYLY8fGAwj(4aijsHBo;M=dH#k6!Vx zSxoE}7u&_cZgeRj6)7~QiNls!Hl~=Zsb+J^*`9hfsG$9+P=iU-p^~=#n5Hc)YLltj z)Uvjju5B%Bqbb|i2393Vt=Y|_BoPZq!XFeVmO!e;0h(y7ZdV-&R{;+j+ zwQX%_TiG*`RKNW#sc272;ME%VwFsWAf@3S-05h1s5ccmi9V}sN>J~9J>!#}zrVeca zp(9hE3GD^|1cPAJtk0-NSDzc!v6dAmf{-U&0bmMvqIHS&s7DWgLIA!#1Fb0#h*klB zU9ytb0RL=>bpdN$P@xyG>P_!_4J+j+QyH>YUN4jtdtdxQ@WKB7-S31`YT+WfW^iFQPMkE>l8o0+d$jb$S+yW@CcnOp1EGjND*2-Ph@{~{Q*i~P-)$mORju!e@|)KF z&giUN4HR30IoG>}GqR7J*8(u8mvn;%-YfDW0Y`TARG_MhSeC1eZ0y3w}WQ3O(OOuQJhx zZt#OUaN!n@IL0sD*@{zr>Q>MAXRogHs&jqoJN5ZMI)0dF<%p@5vJ8FxJb-G$1m!0# z@6Es6=yVH&+&8Xq-ER(K2800cfxp1HSyJz@I|}Hetna@Q?_Q+uyYUzeI@6Q>bf_o1 z%?ii-oHeiO%zvKqpzk`^zaHAroBo=Ix6i|PIH3wn=1_k22Z#qC;+c@x#M)kYa|!Ya zo4Y*bR6Zh z_{Cp-uV#U ztH90X1aRi~PTexiAE3d}zADs4&H0{>QKs+l7?1ngsswN#C}9HS zfdGGR0r5`ehR*^0LD66UD89=W&??C+aPBZrQ8du{IB*21Z}P+t4MVW$ywCDb@C8>8 z4&P7?W6%ZZ5C&^d25*oC_0R?nO#W2w3^%a)MDggxuoTs>^4f6oaL^C;@D=+o!CY|` zVX+ovalvRY7j1DDtLga;F{w6;O|ZouWN$M%qLNtRk^t=>^6JqDLKFxfu0-Jiq6@4H z;GG77#~$q+v`f_RDXg;53b$<_py2|9!pCBw8v)=MK`|6n@%ly)70(bJ$?z0Sap|1y z>DM<4N5g-1g&m`#)C0DWpYmF5>@BMhO!DbQ{Yf=|+k|rZ8CwX!w z$&4X=(ia_ahdc&hA|nx$LnDH4AeIr^iqIf1Qhl_e9xh-L82~G@5-SNH2uKoAQZoBm zQY`P$B^_@bUs4}WuoZuj*KSfMgAynOvLNO1An7t8?Q$XUQYVM<@E$V5^h`bqF+D#yE^~P>(|9x!d9bH)Jo9ogQ#3boG&_?s zo98s2CpDvIHK(UFALsdGuW3N8x8|c~ijf!z;%z>n2uGqM0L^PyTd!p&U+$4IEZmrhN~&lM`*Au8RHW`15`i<6gB44FwSmTsN_O=#4iN` zWm2X;2vkBRltL@iCd%$Wy+=WV#Z~w(AtEM3M1({vltfF^L{HQoFtjl2ubTv=O;R*` zP*g@|ltwL7MG0dNAHqd}2pDVBM}HJZEAvJNBS%-|J`t%yvn5EElu4Vk&NO8QAp}jH zCNTMBNSzc*vs6pHCJ+rn!>E*1JT$lnb4$zAOwY7vx>P+DBIF!tVeUgV(Ns?7lurFc zO$h@VJ}%@E{sCB;KeBu*KFS8yg_uw`A!b08jcKOjIcdzD$6)mg#AKvxt>Aj6SDhH0P_NfEO@iRM|m z)my(+H=>m=dJr-yWM_a;WOlPNK0`gV#v_&$T;o+<=T#6(wLqt}HlwB}S8s0MrZ@Z5 zBVVg5zpL1fP6FR5!VyhEjiIY3K^HmCTO7k^s z?6nyF`;}XvBYogiU@bFax3gsvmSJPoVPzI#X;xxwmSS<%Vk;J7c~)azmScg|IA@k< z69)tTv?r7yWT)kIh?VsO;@X~as?z5_$-@|oKpFvn0p4e2bptH7Z!Gh%EJ@HT)A0NF zaUlIMZ2@vF39>HT_AcQzFXeVG;j%65R=@ypK#sp43Tb)5X3_vMBm;b&77_UaDw%DF z(!=+NA_NYwYir|cyDw~2GIGO`EYEf=-7+re_AT{tb2T?})wXRx_iaTtZb^4;HSYQL zb|>=GZ;_)AgM(O(m6Bk>Dc?>!tZRvkAro+nG8=a`9@hgW7i`H=c)QPXWwIwfmu*LP zc}utcc~AFw>y~qs_aCcwZLK#4pN}b1gLVHFZ4gOn5-}#uDV_eQo!%*)=Bb|8YJCHW zas3IO^l6|9O56$}p@zXDSwa+I%$~H%0DaeS$JPwdQh-U3C5ab-#rAkRkAX#xEzve~ zqqlQSS9(47Zm0KxEm*(6@JctNmugExUCw{l~$fo&3RFE@&j_kpDt zgCUrCu{Vp03IjWMFhVt7!KqdJZzhmS?u3H5oQv;Zj3}r}@20}Y*75i_5da7eC0PDW zAn1ySi`X@eSOk}ta>X`@L2!W`7;`h2f+tvlsrPytIfAv=c_A5kCAoqVnWT)$ivw$9OEPek{m_3@D6j7?6zlP~x5j z!Wg9Sh56Ws3-~O{mYDr`4F|dC3K@#8*ormynJpQS7kQc~S&^-okssNSON)b}l!uC5{0jht8m>c?cLyQt6t(6u<(`vS~dha9t|3Dd0L4PdLN^?qJpnV#Nn>eVc_>e6+s-2pmi(0SydZMp6u(O$|_qw&fkg5m6s_m1{ z?rf{!uB%u2t08cWqfoNRy7*4o?*dQo>^LFLLA%V#t$#PJ>H3ca*|ZB9sMQj$7h0j6 zxwT=Np{qEyp}DrDId35SRi~4{t{u%t1%n+#?hznh7{JNuZqq5oR2p&GE!`@9P~z1e%c`TD&LyS?H2 zk|F9>5kqN@QZkgoB9?=UWx|XZaUclrf2DA_joZ1Ski{I4GUJV(QGyBOdAn&tr|nU^ z4?4Uve7qC7nQeQv^O~p!+o;`}u<3imP29Xo{ID@lT#0l|d|Nr5gHK4sx9Sy4LCKZ8ttVVHD*Plz z%T=7qzkJKX+{?v0%fWoi&wSU6s>ONx#VI5?loB^FM0{cXf_&u;upE&ndc3)RJhP!2 z6N6$Cy^AHT8!7hK$qxg{517h-TEj7X!|(dTUAx0^JH*u+&9NNQE1ks4ytcq_&3&SK z}tgcZU{b3v7$s2t+95KzL%@G~vO(p6fw2J~84?Q&! zoq)UB!W+HHRg%1EJJMzQwLSdVDgCyGdfNRu;XM6gPb1VRWYlSdDTk=W2?8Ti4!k%r ze+vU7MN-(igV>GP*rR;W>)Oh{EYqu9#OuA;p?%V+J>R9h-){;GRaYmvH#{vwIqHLZ zoYEqVkZP&YDzy^gxpLh}``r~C*%>{MFP@M&yuAK39mPGq%Q^khLEg+o-sJH;^8!9b zR|h=ZbUbMlPTuBZ69O?+b~hN)F(WfFCv)O+dSi#SW{dV_g+6DAerI*IXOaGAeb!}} z9%hX`>7jnhz{^^bOXjdLFScgh4#3CrtA9gctmZ?BQum0|%zV7k?2?D z=M+k5hNGu-N~&czgo_}wRcK~@@3SsVzKwTGW-%*sUt#1-}PS~NK3ybtlC8% z1ndg}L?>oMVW0PVA4O%KCukq>*-qen-~RZIe?m0`RT(1}rqoE&WmAw}`lla7ciShF z)^DFbH}9MJyWjia)B2OP&CT^l;pFzeAN|wcE5n~BltE7uv`-&qd)1%*>%T8Ix@O*A z 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 literal 0 HcmV?d00001 diff --git a/airflow/www/static/bootstrap-theme.css b/airflow/www/static/bootstrap-theme.css new file mode 100644 index 0000000000000..734f940feed61 --- /dev/null +++ b/airflow/www/static/bootstrap-theme.css @@ -0,0 +1,6494 @@ +/*! + * Bootstrap v3.2 + * + * Copyright 2014 Twitter, Inc + * Licensed under the Apache License v2.0 + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Designed and built with all the love in the world by @mdo and @fat. + * BootSwatchr built and provided by @DrewStrickland + */ +/*! normalize.css v3.0.2 | MIT License | git.io/normalize */ +html { + font-family: sans-serif; + -ms-text-size-adjust: 100%; + -webkit-text-size-adjust: 100%; +} +body { + margin: 0; +} +article, +aside, +details, +figcaption, +figure, +footer, +header, +hgroup, +main, +menu, +nav, +section, +summary { + display: block; +} +audio, +canvas, +progress, +video { + display: inline-block; + vertical-align: baseline; +} +audio:not([controls]) { + display: none; + height: 0; +} +[hidden], +template { + display: none; +} +a { + background-color: transparent; +} +a:active, +a:hover { + outline: 0; +} +abbr[title] { + border-bottom: 1px dotted; +} +b, +strong { + font-weight: bold; +} +dfn { + font-style: italic; +} +h1 { + font-size: 2em; + margin: 0.67em 0; +} +mark { + background: #ff0; + color: #000; +} +small { + font-size: 80%; +} +sub, +sup { + font-size: 75%; + line-height: 0; + position: relative; + vertical-align: baseline; +} +sup { + top: -0.5em; +} +sub { + bottom: -0.25em; +} +img { + border: 0; +} +svg:not(:root) { + overflow: hidden; +} +figure { + margin: 1em 40px; +} +hr { + -moz-box-sizing: content-box; + box-sizing: content-box; + height: 0; +} +pre { + overflow: auto; +} +code, +kbd, +pre, +samp { + font-family: monospace, monospace; + font-size: 1em; +} +button, +input, +optgroup, +select, +textarea { + color: inherit; + font: inherit; + margin: 0; +} +button { + overflow: visible; +} +button, +select { + text-transform: none; +} +button, +html input[type="button"], +input[type="reset"], +input[type="submit"] { + -webkit-appearance: button; + cursor: pointer; +} +button[disabled], +html input[disabled] { + cursor: default; +} +button::-moz-focus-inner, +input::-moz-focus-inner { + border: 0; + padding: 0; +} +input { + line-height: normal; +} +input[type="checkbox"], +input[type="radio"] { + box-sizing: border-box; + padding: 0; +} +input[type="number"]::-webkit-inner-spin-button, +input[type="number"]::-webkit-outer-spin-button { + height: auto; +} +input[type="search"] { + -webkit-appearance: textfield; + -moz-box-sizing: content-box; + -webkit-box-sizing: content-box; + box-sizing: content-box; +} +input[type="search"]::-webkit-search-cancel-button, +input[type="search"]::-webkit-search-decoration { + -webkit-appearance: none; +} +fieldset { + border: 1px solid #c0c0c0; + margin: 0 2px; + padding: 0.35em 0.625em 0.75em; +} +legend { + border: 0; + padding: 0; +} +textarea { + overflow: auto; +} +optgroup { + font-weight: bold; +} +table { + border-collapse: collapse; + border-spacing: 0; +} +td, +th { + padding: 0; +} +/*! Source: https://github.com/h5bp/html5-boilerplate/blob/master/src/css/main.css */ +@media print { + *, + *:before, + *:after { + background: transparent !important; + color: #000 !important; + box-shadow: none !important; + text-shadow: none !important; + } + a, + a:visited { + text-decoration: underline; + } + a[href]:after { + content: " (" attr(href) ")"; + } + abbr[title]:after { + content: " (" attr(title) ")"; + } + a[href^="#"]:after, + a[href^="javascript:"]:after { + content: ""; + } + pre, + blockquote { + border: 1px solid #999; + page-break-inside: avoid; + } + thead { + display: table-header-group; + } + tr, + img { + page-break-inside: avoid; + } + img { + max-width: 100% !important; + } + p, + h2, + h3 { + orphans: 3; + widows: 3; + } + h2, + h3 { + page-break-after: avoid; + } + select { + background: #fff !important; + } + .navbar { + display: none; + } + .btn > .caret, + .dropup > .btn > .caret { + border-top-color: #000 !important; + } + .label { + border: 1px solid #000; + } + .table { + border-collapse: collapse !important; + } + .table td, + .table th { + background-color: #fff !important; + } + .table-bordered th, + .table-bordered td { + border: 1px solid #ddd !important; + } +} +* { + -webkit-box-sizing: border-box; + -moz-box-sizing: border-box; + box-sizing: border-box; +} +*:before, +*:after { + -webkit-box-sizing: border-box; + -moz-box-sizing: border-box; + box-sizing: border-box; +} +html { + font-size: 10px; + -webkit-tap-highlight-color: rgba(0, 0, 0, 0); +} +body { + font-family: "Helvetica Neue", Helvetica, Arial, sans-serif; + font-size: 14px; + line-height: 1.428571429; + color: #333333; + background-color: #fafafa; +} +input, +button, +select, +textarea { + font-family: inherit; + font-size: inherit; + line-height: inherit; +} +a { + color: #0091a1; + text-decoration: none; +} +a:hover, +a:focus { + color: #004c54; + text-decoration: underline; +} +a:focus { + outline: thin dotted; + outline: 5px auto -webkit-focus-ring-color; + outline-offset: -2px; +} +figure { + margin: 0; +} +img { + vertical-align: middle; +} +.img-responsive, +.thumbnail > img, +.thumbnail a > img, +.carousel-inner > .item > img, +.carousel-inner > .item > a > img { + display: block; + max-width: 100%; + height: auto; +} +.img-rounded { + border-radius: 6px; +} +.img-thumbnail { + padding: 4px; + line-height: 1.428571429; + background-color: #fafafa; + border: 1px solid #dddddd; + border-radius: 4px; + -webkit-transition: all 0.2s ease-in-out; + -o-transition: all 0.2s ease-in-out; + transition: all 0.2s ease-in-out; + display: inline-block; + max-width: 100%; + height: auto; +} +.img-circle { + border-radius: 50%; +} +hr { + margin-top: 15px; + margin-bottom: 15px; + border: 0; + border-top: 1px solid #eeeeee; +} +.sr-only { + position: absolute; + width: 1px; + height: 1px; + margin: -1px; + padding: 0; + overflow: hidden; + clip: rect(0, 0, 0, 0); + border: 0; +} +.sr-only-focusable:active, +.sr-only-focusable:focus { + position: static; + width: auto; + height: auto; + margin: 0; + overflow: visible; + clip: auto; +} +h1, +h2, +h3, +h4, +h5, +h6, +.h1, +.h2, +.h3, +.h4, +.h5, +.h6 { + font-family: inherit; + font-weight: 500; + line-height: 1.1; + color: inherit; +} +h1 small, +h2 small, +h3 small, +h4 small, +h5 small, +h6 small, +.h1 small, +.h2 small, +.h3 small, +.h4 small, +.h5 small, +.h6 small, +h1 .small, +h2 .small, +h3 .small, +h4 .small, +h5 .small, +h6 .small, +.h1 .small, +.h2 .small, +.h3 .small, +.h4 .small, +.h5 .small, +.h6 .small { + font-weight: normal; + line-height: 1; + color: #777777; +} +h1, +.h1, +h2, +.h2, +h3, +.h3 { + margin-top: 20px; + margin-bottom: 10px; +} +h1 small, +.h1 small, +h2 small, +.h2 small, +h3 small, +.h3 small, +h1 .small, +.h1 .small, +h2 .small, +.h2 .small, +h3 .small, +.h3 .small { + font-size: 65%; +} +h4, +.h4, +h5, +.h5, +h6, +.h6 { + margin-top: 10px; + margin-bottom: 10px; +} +h4 small, +.h4 small, +h5 small, +.h5 small, +h6 small, +.h6 small, +h4 .small, +.h4 .small, +h5 .small, +.h5 .small, +h6 .small, +.h6 .small { + font-size: 75%; +} +h1, +.h1 { + font-size: 36px; +} +h2, +.h2 { + font-size: 30px; +} +h3, +.h3 { + font-size: 24px; +} +h4, +.h4 { + font-size: 18px; +} +h5, +.h5 { + font-size: 14px; +} +h6, +.h6 { + font-size: 12px; +} +p { + margin: 0 0 10px; +} +.lead { + margin-bottom: 20px; + font-size: 16px; + font-weight: 300; + line-height: 1.4; +} +@media (min-width: 768px) { + .lead { + font-size: 21px; + } +} +small, +.small { + font-size: 85%; +} +mark, +.mark { + background-color: #fcf8e3; + padding: .2em; +} +.text-left { + text-align: left; +} +.text-right { + text-align: right; +} +.text-center { + text-align: center; +} +.text-justify { + text-align: justify; +} +.text-nowrap { + white-space: nowrap; +} +.text-lowercase { + text-transform: lowercase; +} +.text-uppercase { + text-transform: uppercase; +} +.text-capitalize { + text-transform: capitalize; +} +.text-muted { + color: #777777; +} +.text-primary { + color: #005c66; +} +a.text-primary:hover { + color: #002e33; +} +.text-success { + color: #3c763d; +} +a.text-success:hover { + color: #2b542c; +} +.text-info { + color: #31708f; +} +a.text-info:hover { + color: #245269; +} +.text-warning { + color: #8a6d3b; +} +a.text-warning:hover { + color: #66512c; +} +.text-danger { + color: #a94442; +} +a.text-danger:hover { + color: #843534; +} +.bg-primary { + color: #fff; + background-color: #005c66; +} +a.bg-primary:hover { + background-color: #002e33; +} +.bg-success { + background-color: #dff0d8; +} +a.bg-success:hover { + background-color: #c1e2b3; +} +.bg-info { + background-color: #d9edf7; +} +a.bg-info:hover { + background-color: #afd9ee; +} +.bg-warning { + background-color: #fcf8e3; +} +a.bg-warning:hover { + background-color: #f7ecb5; +} +.bg-danger { + background-color: #f2dede; +} +a.bg-danger:hover { + background-color: #e4b9b9; +} +.page-header { + padding-bottom: 9px; + margin: 40px 0 20px; + border-bottom: 1px solid #eeeeee; +} +ul, +ol { + margin-top: 0; + margin-bottom: 10px; +} +ul ul, +ol ul, +ul ol, +ol ol { + margin-bottom: 0; +} +.list-unstyled { + padding-left: 0; + list-style: none; +} +.list-inline { + padding-left: 0; + list-style: none; + margin-left: -5px; +} +.list-inline > li { + display: inline-block; + padding-left: 5px; + padding-right: 5px; +} +dl { + margin-top: 0; + margin-bottom: 20px; +} +dt, +dd { + line-height: 1.428571429; +} +dt { + font-weight: bold; +} +dd { + margin-left: 0; +} +@media (min-width: 768px) { + .dl-horizontal dt { + float: left; + width: 160px; + clear: left; + text-align: right; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + .dl-horizontal dd { + margin-left: 180px; + } +} +abbr[title], +abbr[data-original-title] { + cursor: help; + border-bottom: 1px dotted #777777; +} +.initialism { + font-size: 90%; + text-transform: uppercase; +} +blockquote { + padding: 10px 20px; + margin: 0 0 20px; + font-size: 17.5px; + border-left: 5px solid #eeeeee; +} +blockquote p:last-child, +blockquote ul:last-child, +blockquote ol:last-child { + margin-bottom: 0; +} +blockquote footer, +blockquote small, +blockquote .small { + display: block; + font-size: 80%; + line-height: 1.428571429; + color: #777777; +} +blockquote footer:before, +blockquote small:before, +blockquote .small:before { + content: '\2014 \00A0'; +} +.blockquote-reverse, +blockquote.pull-right { + padding-right: 15px; + padding-left: 0; + border-right: 5px solid #eeeeee; + border-left: 0; + text-align: right; +} +.blockquote-reverse footer:before, +blockquote.pull-right footer:before, +.blockquote-reverse small:before, +blockquote.pull-right small:before, +.blockquote-reverse .small:before, +blockquote.pull-right .small:before { + content: ''; +} +.blockquote-reverse footer:after, +blockquote.pull-right footer:after, +.blockquote-reverse small:after, +blockquote.pull-right small:after, +.blockquote-reverse .small:after, +blockquote.pull-right .small:after { + content: '\00A0 \2014'; +} +address { + margin-bottom: 20px; + font-style: normal; + line-height: 1.428571429; +} +code, +kbd, +pre, +samp { + font-family: Menlo, Monaco, Consolas, "Courier New", monospace; +} +code { + padding: 2px 4px; + font-size: 90%; + color: #c7254e; + background-color: #f9f2f4; + border-radius: 4px; +} +kbd { + padding: 2px 4px; + font-size: 90%; + color: #ffffff; + background-color: #333333; + border-radius: 3px; + box-shadow: inset 0 -1px 0 rgba(0, 0, 0, 0.25); +} +kbd kbd { + padding: 0; + font-size: 100%; + font-weight: bold; + box-shadow: none; +} +pre { + display: block; + padding: 9.5px; + margin: 0 0 10px; + font-size: 13px; + line-height: 1.428571429; + word-break: break-all; + word-wrap: break-word; + color: #333333; + background-color: #f5f5f5; + border: 1px solid #cccccc; + border-radius: 4px; +} +pre code { + padding: 0; + font-size: inherit; + color: inherit; + white-space: pre-wrap; + background-color: transparent; + border-radius: 0; +} +.pre-scrollable { + max-height: 340px; + overflow-y: scroll; +} +.container { + margin-right: auto; + margin-left: auto; + padding-left: 15px; + padding-right: 15px; +} +@media (min-width: 768px) { + .container { + width: 750px; + } +} +@media (min-width: 992px) { + .container { + width: 970px; + } +} +@media (min-width: 1200px) { + .container { + width: 1170px; + } +} +.container-fluid { + margin-right: auto; + margin-left: auto; + padding-left: 15px; + padding-right: 15px; +} +.row { + margin-left: -15px; + margin-right: -15px; +} +.col-xs-1, .col-sm-1, .col-md-1, .col-lg-1, .col-xs-2, .col-sm-2, .col-md-2, .col-lg-2, .col-xs-3, .col-sm-3, .col-md-3, .col-lg-3, .col-xs-4, .col-sm-4, .col-md-4, .col-lg-4, .col-xs-5, .col-sm-5, .col-md-5, .col-lg-5, .col-xs-6, .col-sm-6, .col-md-6, .col-lg-6, .col-xs-7, .col-sm-7, .col-md-7, .col-lg-7, .col-xs-8, .col-sm-8, .col-md-8, .col-lg-8, .col-xs-9, .col-sm-9, .col-md-9, .col-lg-9, .col-xs-10, .col-sm-10, .col-md-10, .col-lg-10, .col-xs-11, .col-sm-11, .col-md-11, .col-lg-11, .col-xs-12, .col-sm-12, .col-md-12, .col-lg-12 { + position: relative; + min-height: 1px; + padding-left: 15px; + padding-right: 15px; +} +.col-xs-1, .col-xs-2, .col-xs-3, .col-xs-4, .col-xs-5, .col-xs-6, .col-xs-7, .col-xs-8, .col-xs-9, .col-xs-10, .col-xs-11, .col-xs-12 { + float: left; +} +.col-xs-12 { + width: 100%; +} +.col-xs-11 { + width: 91.66666666666666%; +} +.col-xs-10 { + width: 83.33333333333334%; +} +.col-xs-9 { + width: 75%; +} +.col-xs-8 { + width: 66.66666666666666%; +} +.col-xs-7 { + width: 58.333333333333336%; +} +.col-xs-6 { + width: 50%; +} +.col-xs-5 { + width: 41.66666666666667%; +} +.col-xs-4 { + width: 33.33333333333333%; +} +.col-xs-3 { + width: 25%; +} +.col-xs-2 { + width: 16.666666666666664%; +} +.col-xs-1 { + width: 8.333333333333332%; +} +.col-xs-pull-12 { + right: 100%; +} +.col-xs-pull-11 { + right: 91.66666666666666%; +} +.col-xs-pull-10 { + right: 83.33333333333334%; +} +.col-xs-pull-9 { + right: 75%; +} +.col-xs-pull-8 { + right: 66.66666666666666%; +} +.col-xs-pull-7 { + right: 58.333333333333336%; +} +.col-xs-pull-6 { + right: 50%; +} +.col-xs-pull-5 { + right: 41.66666666666667%; +} +.col-xs-pull-4 { + right: 33.33333333333333%; +} +.col-xs-pull-3 { + right: 25%; +} +.col-xs-pull-2 { + right: 16.666666666666664%; +} +.col-xs-pull-1 { + right: 8.333333333333332%; +} +.col-xs-pull-0 { + right: auto; +} +.col-xs-push-12 { + left: 100%; +} +.col-xs-push-11 { + left: 91.66666666666666%; +} +.col-xs-push-10 { + left: 83.33333333333334%; +} +.col-xs-push-9 { + left: 75%; +} +.col-xs-push-8 { + left: 66.66666666666666%; +} +.col-xs-push-7 { + left: 58.333333333333336%; +} +.col-xs-push-6 { + left: 50%; +} +.col-xs-push-5 { + left: 41.66666666666667%; +} +.col-xs-push-4 { + left: 33.33333333333333%; +} +.col-xs-push-3 { + left: 25%; +} +.col-xs-push-2 { + left: 16.666666666666664%; +} +.col-xs-push-1 { + left: 8.333333333333332%; +} +.col-xs-push-0 { + left: auto; +} +.col-xs-offset-12 { + margin-left: 100%; +} +.col-xs-offset-11 { + margin-left: 91.66666666666666%; +} +.col-xs-offset-10 { + margin-left: 83.33333333333334%; +} +.col-xs-offset-9 { + margin-left: 75%; +} +.col-xs-offset-8 { + margin-left: 66.66666666666666%; +} +.col-xs-offset-7 { + margin-left: 58.333333333333336%; +} +.col-xs-offset-6 { + margin-left: 50%; +} +.col-xs-offset-5 { + margin-left: 41.66666666666667%; +} +.col-xs-offset-4 { + margin-left: 33.33333333333333%; +} +.col-xs-offset-3 { + margin-left: 25%; +} +.col-xs-offset-2 { + margin-left: 16.666666666666664%; +} +.col-xs-offset-1 { + margin-left: 8.333333333333332%; +} +.col-xs-offset-0 { + margin-left: 0%; +} +@media (min-width: 768px) { + .col-sm-1, .col-sm-2, .col-sm-3, .col-sm-4, .col-sm-5, .col-sm-6, .col-sm-7, .col-sm-8, .col-sm-9, .col-sm-10, .col-sm-11, .col-sm-12 { + float: left; + } + .col-sm-12 { + width: 100%; + } + .col-sm-11 { + width: 91.66666666666666%; + } + .col-sm-10 { + width: 83.33333333333334%; + } + .col-sm-9 { + width: 75%; + } + .col-sm-8 { + width: 66.66666666666666%; + } + .col-sm-7 { + width: 58.333333333333336%; + } + .col-sm-6 { + width: 50%; + } + .col-sm-5 { + width: 41.66666666666667%; + } + .col-sm-4 { + width: 33.33333333333333%; + } + .col-sm-3 { + width: 25%; + } + .col-sm-2 { + width: 16.666666666666664%; + } + .col-sm-1 { + width: 8.333333333333332%; + } + .col-sm-pull-12 { + right: 100%; + } + .col-sm-pull-11 { + right: 91.66666666666666%; + } + .col-sm-pull-10 { + right: 83.33333333333334%; + } + .col-sm-pull-9 { + right: 75%; + } + .col-sm-pull-8 { + right: 66.66666666666666%; + } + .col-sm-pull-7 { + right: 58.333333333333336%; + } + .col-sm-pull-6 { + right: 50%; + } + .col-sm-pull-5 { + right: 41.66666666666667%; + } + .col-sm-pull-4 { + right: 33.33333333333333%; + } + .col-sm-pull-3 { + right: 25%; + } + .col-sm-pull-2 { + right: 16.666666666666664%; + } + .col-sm-pull-1 { + right: 8.333333333333332%; + } + .col-sm-pull-0 { + right: auto; + } + .col-sm-push-12 { + left: 100%; + } + .col-sm-push-11 { + left: 91.66666666666666%; + } + .col-sm-push-10 { + left: 83.33333333333334%; + } + .col-sm-push-9 { + left: 75%; + } + .col-sm-push-8 { + left: 66.66666666666666%; + } + .col-sm-push-7 { + left: 58.333333333333336%; + } + .col-sm-push-6 { + left: 50%; + } + .col-sm-push-5 { + left: 41.66666666666667%; + } + .col-sm-push-4 { + left: 33.33333333333333%; + } + .col-sm-push-3 { + left: 25%; + } + .col-sm-push-2 { + left: 16.666666666666664%; + } + .col-sm-push-1 { + left: 8.333333333333332%; + } + .col-sm-push-0 { + left: auto; + } + .col-sm-offset-12 { + margin-left: 100%; + } + .col-sm-offset-11 { + margin-left: 91.66666666666666%; + } + .col-sm-offset-10 { + margin-left: 83.33333333333334%; + } + .col-sm-offset-9 { + margin-left: 75%; + } + .col-sm-offset-8 { + margin-left: 66.66666666666666%; + } + .col-sm-offset-7 { + margin-left: 58.333333333333336%; + } + .col-sm-offset-6 { + margin-left: 50%; + } + .col-sm-offset-5 { + margin-left: 41.66666666666667%; + } + .col-sm-offset-4 { + margin-left: 33.33333333333333%; + } + .col-sm-offset-3 { + margin-left: 25%; + } + .col-sm-offset-2 { + margin-left: 16.666666666666664%; + } + .col-sm-offset-1 { + margin-left: 8.333333333333332%; + } + .col-sm-offset-0 { + margin-left: 0%; + } +} +@media (min-width: 992px) { + .col-md-1, .col-md-2, .col-md-3, .col-md-4, .col-md-5, .col-md-6, .col-md-7, .col-md-8, .col-md-9, .col-md-10, .col-md-11, .col-md-12 { + float: left; + } + .col-md-12 { + width: 100%; + } + .col-md-11 { + width: 91.66666666666666%; + } + .col-md-10 { + width: 83.33333333333334%; + } + .col-md-9 { + width: 75%; + } + .col-md-8 { + width: 66.66666666666666%; + } + .col-md-7 { + width: 58.333333333333336%; + } + .col-md-6 { + width: 50%; + } + .col-md-5 { + width: 41.66666666666667%; + } + .col-md-4 { + width: 33.33333333333333%; + } + .col-md-3 { + width: 25%; + } + .col-md-2 { + width: 16.666666666666664%; + } + .col-md-1 { + width: 8.333333333333332%; + } + .col-md-pull-12 { + right: 100%; + } + .col-md-pull-11 { + right: 91.66666666666666%; + } + .col-md-pull-10 { + right: 83.33333333333334%; + } + .col-md-pull-9 { + right: 75%; + } + .col-md-pull-8 { + right: 66.66666666666666%; + } + .col-md-pull-7 { + right: 58.333333333333336%; + } + .col-md-pull-6 { + right: 50%; + } + .col-md-pull-5 { + right: 41.66666666666667%; + } + .col-md-pull-4 { + right: 33.33333333333333%; + } + .col-md-pull-3 { + right: 25%; + } + .col-md-pull-2 { + right: 16.666666666666664%; + } + .col-md-pull-1 { + right: 8.333333333333332%; + } + .col-md-pull-0 { + right: auto; + } + .col-md-push-12 { + left: 100%; + } + .col-md-push-11 { + left: 91.66666666666666%; + } + .col-md-push-10 { + left: 83.33333333333334%; + } + .col-md-push-9 { + left: 75%; + } + .col-md-push-8 { + left: 66.66666666666666%; + } + .col-md-push-7 { + left: 58.333333333333336%; + } + .col-md-push-6 { + left: 50%; + } + .col-md-push-5 { + left: 41.66666666666667%; + } + .col-md-push-4 { + left: 33.33333333333333%; + } + .col-md-push-3 { + left: 25%; + } + .col-md-push-2 { + left: 16.666666666666664%; + } + .col-md-push-1 { + left: 8.333333333333332%; + } + .col-md-push-0 { + left: auto; + } + .col-md-offset-12 { + margin-left: 100%; + } + .col-md-offset-11 { + margin-left: 91.66666666666666%; + } + .col-md-offset-10 { + margin-left: 83.33333333333334%; + } + .col-md-offset-9 { + margin-left: 75%; + } + .col-md-offset-8 { + margin-left: 66.66666666666666%; + } + .col-md-offset-7 { + margin-left: 58.333333333333336%; + } + .col-md-offset-6 { + margin-left: 50%; + } + .col-md-offset-5 { + margin-left: 41.66666666666667%; + } + .col-md-offset-4 { + margin-left: 33.33333333333333%; + } + .col-md-offset-3 { + margin-left: 25%; + } + .col-md-offset-2 { + margin-left: 16.666666666666664%; + } + .col-md-offset-1 { + margin-left: 8.333333333333332%; + } + .col-md-offset-0 { + margin-left: 0%; + } +} +@media (min-width: 1200px) { + .col-lg-1, .col-lg-2, .col-lg-3, .col-lg-4, .col-lg-5, .col-lg-6, .col-lg-7, .col-lg-8, .col-lg-9, .col-lg-10, .col-lg-11, .col-lg-12 { + float: left; + } + .col-lg-12 { + width: 100%; + } + .col-lg-11 { + width: 91.66666666666666%; + } + .col-lg-10 { + width: 83.33333333333334%; + } + .col-lg-9 { + width: 75%; + } + .col-lg-8 { + width: 66.66666666666666%; + } + .col-lg-7 { + width: 58.333333333333336%; + } + .col-lg-6 { + width: 50%; + } + .col-lg-5 { + width: 41.66666666666667%; + } + .col-lg-4 { + width: 33.33333333333333%; + } + .col-lg-3 { + width: 25%; + } + .col-lg-2 { + width: 16.666666666666664%; + } + .col-lg-1 { + width: 8.333333333333332%; + } + .col-lg-pull-12 { + right: 100%; + } + .col-lg-pull-11 { + right: 91.66666666666666%; + } + .col-lg-pull-10 { + right: 83.33333333333334%; + } + .col-lg-pull-9 { + right: 75%; + } + .col-lg-pull-8 { + right: 66.66666666666666%; + } + .col-lg-pull-7 { + right: 58.333333333333336%; + } + .col-lg-pull-6 { + right: 50%; + } + .col-lg-pull-5 { + right: 41.66666666666667%; + } + .col-lg-pull-4 { + right: 33.33333333333333%; + } + .col-lg-pull-3 { + right: 25%; + } + .col-lg-pull-2 { + right: 16.666666666666664%; + } + .col-lg-pull-1 { + right: 8.333333333333332%; + } + .col-lg-pull-0 { + right: auto; + } + .col-lg-push-12 { + left: 100%; + } + .col-lg-push-11 { + left: 91.66666666666666%; + } + .col-lg-push-10 { + left: 83.33333333333334%; + } + .col-lg-push-9 { + left: 75%; + } + .col-lg-push-8 { + left: 66.66666666666666%; + } + .col-lg-push-7 { + left: 58.333333333333336%; + } + .col-lg-push-6 { + left: 50%; + } + .col-lg-push-5 { + left: 41.66666666666667%; + } + .col-lg-push-4 { + left: 33.33333333333333%; + } + .col-lg-push-3 { + left: 25%; + } + .col-lg-push-2 { + left: 16.666666666666664%; + } + .col-lg-push-1 { + left: 8.333333333333332%; + } + .col-lg-push-0 { + left: auto; + } + .col-lg-offset-12 { + margin-left: 100%; + } + .col-lg-offset-11 { + margin-left: 91.66666666666666%; + } + .col-lg-offset-10 { + margin-left: 83.33333333333334%; + } + .col-lg-offset-9 { + margin-left: 75%; + } + .col-lg-offset-8 { + margin-left: 66.66666666666666%; + } + .col-lg-offset-7 { + margin-left: 58.333333333333336%; + } + .col-lg-offset-6 { + margin-left: 50%; + } + .col-lg-offset-5 { + margin-left: 41.66666666666667%; + } + .col-lg-offset-4 { + margin-left: 33.33333333333333%; + } + .col-lg-offset-3 { + margin-left: 25%; + } + .col-lg-offset-2 { + margin-left: 16.666666666666664%; + } + .col-lg-offset-1 { + margin-left: 8.333333333333332%; + } + .col-lg-offset-0 { + margin-left: 0%; + } +} +table { + background-color: transparent; +} +caption { + padding-top: 8px; + padding-bottom: 8px; + color: #777777; + text-align: left; +} +th { + text-align: left; +} +.table { + width: 100%; + max-width: 100%; + margin-bottom: 20px; +} +.table > thead > tr > th, +.table > tbody > tr > th, +.table > tfoot > tr > th, +.table > thead > tr > td, +.table > tbody > tr > td, +.table > tfoot > tr > td { + padding: 8px; + line-height: 1.428571429; + vertical-align: top; + border-top: 1px solid #dddddd; +} +.table > thead > tr > th { + vertical-align: bottom; + border-bottom: 2px solid #dddddd; +} +.table > caption + thead > tr:first-child > th, +.table > colgroup + thead > tr:first-child > th, +.table > thead:first-child > tr:first-child > th, +.table > caption + thead > tr:first-child > td, +.table > colgroup + thead > tr:first-child > td, +.table > thead:first-child > tr:first-child > td { + border-top: 0; +} +.table > tbody + tbody { + border-top: 2px solid #dddddd; +} +.table .table { + background-color: #fafafa; +} +.table-condensed > thead > tr > th, +.table-condensed > tbody > tr > th, +.table-condensed > tfoot > tr > th, +.table-condensed > thead > tr > td, +.table-condensed > tbody > tr > td, +.table-condensed > tfoot > tr > td { + padding: 5px; +} +.table-bordered { + border: 1px solid #dddddd; +} +.table-bordered > thead > tr > th, +.table-bordered > tbody > tr > th, +.table-bordered > tfoot > tr > th, +.table-bordered > thead > tr > td, +.table-bordered > tbody > tr > td, +.table-bordered > tfoot > tr > td { + border: 1px solid #dddddd; +} +.table-bordered > thead > tr > th, +.table-bordered > thead > tr > td { + border-bottom-width: 2px; +} +.table-striped > tbody > tr:nth-child(odd) { + background-color: #f9f9f9; +} +.table-hover > tbody > tr:hover { + background-color: #f5f5f5; +} +table col[class*="col-"] { + position: static; + float: none; + display: table-column; +} +table td[class*="col-"], +table th[class*="col-"] { + position: static; + float: none; + display: table-cell; +} +.table > thead > tr > td.active, +.table > tbody > tr > td.active, +.table > tfoot > tr > td.active, +.table > thead > tr > th.active, +.table > tbody > tr > th.active, +.table > tfoot > tr > th.active, +.table > thead > tr.active > td, +.table > tbody > tr.active > td, +.table > tfoot > tr.active > td, +.table > thead > tr.active > th, +.table > tbody > tr.active > th, +.table > tfoot > tr.active > th { + background-color: #f5f5f5; +} +.table-hover > tbody > tr > td.active:hover, +.table-hover > tbody > tr > th.active:hover, +.table-hover > tbody > tr.active:hover > td, +.table-hover > tbody > tr:hover > .active, +.table-hover > tbody > tr.active:hover > th { + background-color: #e8e8e8; +} +.table > thead > tr > td.success, +.table > tbody > tr > td.success, +.table > tfoot > tr > td.success, +.table > thead > tr > th.success, +.table > tbody > tr > th.success, +.table > tfoot > tr > th.success, +.table > thead > tr.success > td, +.table > tbody > tr.success > td, +.table > tfoot > tr.success > td, +.table > thead > tr.success > th, +.table > tbody > tr.success > th, +.table > tfoot > tr.success > th { + background-color: #dff0d8; +} +.table-hover > tbody > tr > td.success:hover, +.table-hover > tbody > tr > th.success:hover, +.table-hover > tbody > tr.success:hover > td, +.table-hover > tbody > tr:hover > .success, +.table-hover > tbody > tr.success:hover > th { + background-color: #d0e9c6; +} +.table > thead > tr > td.info, +.table > tbody > tr > td.info, +.table > tfoot > tr > td.info, +.table > thead > tr > th.info, +.table > tbody > tr > th.info, +.table > tfoot > tr > th.info, +.table > thead > tr.info > td, +.table > tbody > tr.info > td, +.table > tfoot > tr.info > td, +.table > thead > tr.info > th, +.table > tbody > tr.info > th, +.table > tfoot > tr.info > th { + background-color: #d9edf7; +} +.table-hover > tbody > tr > td.info:hover, +.table-hover > tbody > tr > th.info:hover, +.table-hover > tbody > tr.info:hover > td, +.table-hover > tbody > tr:hover > .info, +.table-hover > tbody > tr.info:hover > th { + background-color: #c4e3f3; +} +.table > thead > tr > td.warning, +.table > tbody > tr > td.warning, +.table > tfoot > tr > td.warning, +.table > thead > tr > th.warning, +.table > tbody > tr > th.warning, +.table > tfoot > tr > th.warning, +.table > thead > tr.warning > td, +.table > tbody > tr.warning > td, +.table > tfoot > tr.warning > td, +.table > thead > tr.warning > th, +.table > tbody > tr.warning > th, +.table > tfoot > tr.warning > th { + background-color: #fcf8e3; +} +.table-hover > tbody > tr > td.warning:hover, +.table-hover > tbody > tr > th.warning:hover, +.table-hover > tbody > tr.warning:hover > td, +.table-hover > tbody > tr:hover > .warning, +.table-hover > tbody > tr.warning:hover > th { + background-color: #faf2cc; +} +.table > thead > tr > td.danger, +.table > tbody > tr > td.danger, +.table > tfoot > tr > td.danger, +.table > thead > tr > th.danger, +.table > tbody > tr > th.danger, +.table > tfoot > tr > th.danger, +.table > thead > tr.danger > td, +.table > tbody > tr.danger > td, +.table > tfoot > tr.danger > td, +.table > thead > tr.danger > th, +.table > tbody > tr.danger > th, +.table > tfoot > tr.danger > th { + background-color: #f2dede; +} +.table-hover > tbody > tr > td.danger:hover, +.table-hover > tbody > tr > th.danger:hover, +.table-hover > tbody > tr.danger:hover > td, +.table-hover > tbody > tr:hover > .danger, +.table-hover > tbody > tr.danger:hover > th { + background-color: #ebcccc; +} +.table-responsive { + overflow-x: auto; + min-height: 0.01%; +} +@media screen and (max-width: 767px) { + .table-responsive { + width: 100%; + margin-bottom: 15px; + overflow-y: hidden; + -ms-overflow-style: -ms-autohiding-scrollbar; + border: 1px solid #dddddd; + } + .table-responsive > .table { + margin-bottom: 0; + } + .table-responsive > .table > thead > tr > th, + .table-responsive > .table > tbody > tr > th, + .table-responsive > .table > tfoot > tr > th, + .table-responsive > .table > thead > tr > td, + .table-responsive > .table > tbody > tr > td, + .table-responsive > .table > tfoot > tr > td { + white-space: nowrap; + } + .table-responsive > .table-bordered { + border: 0; + } + .table-responsive > .table-bordered > thead > tr > th:first-child, + .table-responsive > .table-bordered > tbody > tr > th:first-child, + .table-responsive > .table-bordered > tfoot > tr > th:first-child, + .table-responsive > .table-bordered > thead > tr > td:first-child, + .table-responsive > .table-bordered > tbody > tr > td:first-child, + .table-responsive > .table-bordered > tfoot > tr > td:first-child { + border-left: 0; + } + .table-responsive > .table-bordered > thead > tr > th:last-child, + .table-responsive > .table-bordered > tbody > tr > th:last-child, + .table-responsive > .table-bordered > tfoot > tr > th:last-child, + .table-responsive > .table-bordered > thead > tr > td:last-child, + .table-responsive > .table-bordered > tbody > tr > td:last-child, + .table-responsive > .table-bordered > tfoot > tr > td:last-child { + border-right: 0; + } + .table-responsive > .table-bordered > tbody > tr:last-child > th, + .table-responsive > .table-bordered > tfoot > tr:last-child > th, + .table-responsive > .table-bordered > tbody > tr:last-child > td, + .table-responsive > .table-bordered > tfoot > tr:last-child > td { + border-bottom: 0; + } +} +fieldset { + padding: 0; + margin: 0; + border: 0; + min-width: 0; +} +legend { + display: block; + width: 100%; + padding: 0; + margin-bottom: 20px; + font-size: 21px; + line-height: inherit; + color: #333333; + border: 0; + border-bottom: 1px solid #e5e5e5; +} +label { + display: inline-block; + max-width: 100%; + margin-bottom: 5px; + font-weight: bold; +} +input[type="search"] { + -webkit-box-sizing: border-box; + -moz-box-sizing: border-box; + box-sizing: border-box; +} +input[type="radio"], +input[type="checkbox"] { + margin: 4px 0 0; + margin-top: 1px \9; + line-height: normal; +} +input[type="file"] { + display: block; +} +input[type="range"] { + display: block; + width: 100%; +} +select[multiple], +select[size] { + height: auto; +} +input[type="file"]:focus, +input[type="radio"]:focus, +input[type="checkbox"]:focus { + outline: thin dotted; + outline: 5px auto -webkit-focus-ring-color; + outline-offset: -2px; +} +output { + display: block; + padding-top: 7px; + font-size: 14px; + line-height: 1.428571429; + color: #555555; +} +.form-control { + display: block; + width: 100%; + height: 34px; + padding: 6px 12px; + font-size: 14px; + line-height: 1.428571429; + color: #555555; + background-color: #ffffff; + background-image: none; + border: 1px solid #cccccc; + border-radius: 4px; + -webkit-box-shadow: inset 0 1px 1px rgba(0, 0, 0, 0.075); + box-shadow: inset 0 1px 1px rgba(0, 0, 0, 0.075); + -webkit-transition: border-color ease-in-out .15s, box-shadow ease-in-out .15s; + -o-transition: border-color ease-in-out .15s, box-shadow ease-in-out .15s; + transition: border-color ease-in-out .15s, box-shadow ease-in-out .15s; +} +.form-control:focus { + border-color: #66afe9; + outline: 0; + -webkit-box-shadow: inset 0 1px 1px rgba(0,0,0,.075), 0 0 8px rgba(102, 175, 233, 0.6); + box-shadow: inset 0 1px 1px rgba(0,0,0,.075), 0 0 8px rgba(102, 175, 233, 0.6); +} +.form-control::-moz-placeholder { + color: #999999; + opacity: 1; +} +.form-control:-ms-input-placeholder { + color: #999999; +} +.form-control::-webkit-input-placeholder { + color: #999999; +} +.form-control[disabled], +.form-control[readonly], +fieldset[disabled] .form-control { + cursor: not-allowed; + background-color: #eeeeee; + opacity: 1; +} +textarea.form-control { + height: auto; +} +input[type="search"] { + -webkit-appearance: none; +} +@media screen and (-webkit-min-device-pixel-ratio: 0) { + input[type="date"], + input[type="time"], + input[type="datetime-local"], + input[type="month"] { + line-height: 34px; + } + input[type="date"].input-sm, + input[type="time"].input-sm, + input[type="datetime-local"].input-sm, + input[type="month"].input-sm { + line-height: 30px; + } + input[type="date"].input-lg, + input[type="time"].input-lg, + input[type="datetime-local"].input-lg, + input[type="month"].input-lg { + line-height: 46px; + } +} +.form-group { + margin-bottom: 15px; +} +.radio, +.checkbox { + position: relative; + display: block; + margin-top: 10px; + margin-bottom: 10px; +} +.radio label, +.checkbox label { + min-height: 20px; + padding-left: 20px; + margin-bottom: 0; + font-weight: normal; + cursor: pointer; +} +.radio input[type="radio"], +.radio-inline input[type="radio"], +.checkbox input[type="checkbox"], +.checkbox-inline input[type="checkbox"] { + position: absolute; + margin-left: -20px; + margin-top: 4px \9; +} +.radio + .radio, +.checkbox + .checkbox { + margin-top: -5px; +} +.radio-inline, +.checkbox-inline { + display: inline-block; + padding-left: 20px; + margin-bottom: 0; + vertical-align: middle; + font-weight: normal; + cursor: pointer; +} +.radio-inline + .radio-inline, +.checkbox-inline + .checkbox-inline { + margin-top: 0; + margin-left: 10px; +} +input[type="radio"][disabled], +input[type="checkbox"][disabled], +input[type="radio"].disabled, +input[type="checkbox"].disabled, +fieldset[disabled] input[type="radio"], +fieldset[disabled] input[type="checkbox"] { + cursor: not-allowed; +} +.radio-inline.disabled, +.checkbox-inline.disabled, +fieldset[disabled] .radio-inline, +fieldset[disabled] .checkbox-inline { + cursor: not-allowed; +} +.radio.disabled label, +.checkbox.disabled label, +fieldset[disabled] .radio label, +fieldset[disabled] .checkbox label { + cursor: not-allowed; +} +.form-control-static { + padding-top: 7px; + padding-bottom: 7px; + margin-bottom: 0; +} +.form-control-static.input-lg, +.form-control-static.input-sm { + padding-left: 0; + padding-right: 0; +} +.input-sm { + height: 30px; + padding: 5px 10px; + font-size: 12px; + line-height: 1.5; + border-radius: 3px; +} +select.input-sm { + height: 30px; + line-height: 30px; +} +textarea.input-sm, +select[multiple].input-sm { + height: auto; +} +.form-group-sm .form-control { + height: 30px; + padding: 5px 10px; + font-size: 12px; + line-height: 1.5; + border-radius: 3px; +} +select.form-group-sm .form-control { + height: 30px; + line-height: 30px; +} +textarea.form-group-sm .form-control, +select[multiple].form-group-sm .form-control { + height: auto; +} +.input-lg { + height: 46px; + padding: 10px 16px; + font-size: 18px; + line-height: 1.33; + border-radius: 6px; +} +select.input-lg { + height: 46px; + line-height: 46px; +} +textarea.input-lg, +select[multiple].input-lg { + height: auto; +} +.form-group-lg .form-control { + height: 46px; + padding: 10px 16px; + font-size: 18px; + line-height: 1.33; + border-radius: 6px; +} +select.form-group-lg .form-control { + height: 46px; + line-height: 46px; +} +textarea.form-group-lg .form-control, +select[multiple].form-group-lg .form-control { + height: auto; +} +.has-feedback { + position: relative; +} +.has-feedback .form-control { + padding-right: 42.5px; +} +.form-control-feedback { + position: absolute; + top: 0; + right: 0; + z-index: 2; + display: block; + width: 34px; + height: 34px; + line-height: 34px; + text-align: center; + pointer-events: none; +} +.input-lg + .form-control-feedback { + width: 46px; + height: 46px; + line-height: 46px; +} +.input-sm + .form-control-feedback { + width: 30px; + height: 30px; + line-height: 30px; +} +.has-success .help-block, +.has-success .control-label, +.has-success .radio, +.has-success .checkbox, +.has-success .radio-inline, +.has-success .checkbox-inline, +.has-success.radio label, +.has-success.checkbox label, +.has-success.radio-inline label, +.has-success.checkbox-inline label { + color: #3c763d; +} +.has-success .form-control { + border-color: #3c763d; + -webkit-box-shadow: inset 0 1px 1px rgba(0, 0, 0, 0.075); + box-shadow: inset 0 1px 1px rgba(0, 0, 0, 0.075); +} +.has-success .form-control:focus { + border-color: #2b542c; + -webkit-box-shadow: inset 0 1px 1px rgba(0, 0, 0, 0.075), 0 0 6px #67b168; + box-shadow: inset 0 1px 1px rgba(0, 0, 0, 0.075), 0 0 6px #67b168; +} +.has-success .input-group-addon { + color: #3c763d; + border-color: #3c763d; + background-color: #dff0d8; +} +.has-success .form-control-feedback { + color: #3c763d; +} +.has-warning .help-block, +.has-warning .control-label, +.has-warning .radio, +.has-warning .checkbox, +.has-warning .radio-inline, +.has-warning .checkbox-inline, +.has-warning.radio label, +.has-warning.checkbox label, +.has-warning.radio-inline label, +.has-warning.checkbox-inline label { + color: #8a6d3b; +} +.has-warning .form-control { + border-color: #8a6d3b; + -webkit-box-shadow: inset 0 1px 1px rgba(0, 0, 0, 0.075); + box-shadow: inset 0 1px 1px rgba(0, 0, 0, 0.075); +} +.has-warning .form-control:focus { + border-color: #66512c; + -webkit-box-shadow: inset 0 1px 1px rgba(0, 0, 0, 0.075), 0 0 6px #c0a16b; + box-shadow: inset 0 1px 1px rgba(0, 0, 0, 0.075), 0 0 6px #c0a16b; +} +.has-warning .input-group-addon { + color: #8a6d3b; + border-color: #8a6d3b; + background-color: #fcf8e3; +} +.has-warning .form-control-feedback { + color: #8a6d3b; +} +.has-error .help-block, +.has-error .control-label, +.has-error .radio, +.has-error .checkbox, +.has-error .radio-inline, +.has-error .checkbox-inline, +.has-error.radio label, +.has-error.checkbox label, +.has-error.radio-inline label, +.has-error.checkbox-inline label { + color: #a94442; +} +.has-error .form-control { + border-color: #a94442; + -webkit-box-shadow: inset 0 1px 1px rgba(0, 0, 0, 0.075); + box-shadow: inset 0 1px 1px rgba(0, 0, 0, 0.075); +} +.has-error .form-control:focus { + border-color: #843534; + -webkit-box-shadow: inset 0 1px 1px rgba(0, 0, 0, 0.075), 0 0 6px #ce8483; + box-shadow: inset 0 1px 1px rgba(0, 0, 0, 0.075), 0 0 6px #ce8483; +} +.has-error .input-group-addon { + color: #a94442; + border-color: #a94442; + background-color: #f2dede; +} +.has-error .form-control-feedback { + color: #a94442; +} +.has-feedback label ~ .form-control-feedback { + top: 25px; +} +.has-feedback label.sr-only ~ .form-control-feedback { + top: 0; +} +.help-block { + display: block; + margin-top: 5px; + margin-bottom: 10px; + color: #737373; +} +@media (min-width: 768px) { + .form-inline .form-group { + display: inline-block; + margin-bottom: 0; + vertical-align: middle; + } + .form-inline .form-control { + display: inline-block; + width: auto; + vertical-align: middle; + } + .form-inline .form-control-static { + display: inline-block; + } + .form-inline .input-group { + display: inline-table; + vertical-align: middle; + } + .form-inline .input-group .input-group-addon, + .form-inline .input-group .input-group-btn, + .form-inline .input-group .form-control { + width: auto; + } + .form-inline .input-group > .form-control { + width: 100%; + } + .form-inline .control-label { + margin-bottom: 0; + vertical-align: middle; + } + .form-inline .radio, + .form-inline .checkbox { + display: inline-block; + margin-top: 0; + margin-bottom: 0; + vertical-align: middle; + } + .form-inline .radio label, + .form-inline .checkbox label { + padding-left: 0; + } + .form-inline .radio input[type="radio"], + .form-inline .checkbox input[type="checkbox"] { + position: relative; + margin-left: 0; + } + .form-inline .has-feedback .form-control-feedback { + top: 0; + } +} +.form-horizontal .radio, +.form-horizontal .checkbox, +.form-horizontal .radio-inline, +.form-horizontal .checkbox-inline { + margin-top: 0; + margin-bottom: 0; + padding-top: 7px; +} +.form-horizontal .radio, +.form-horizontal .checkbox { + min-height: 27px; +} +.form-horizontal .form-group { + margin-left: -15px; + margin-right: -15px; +} +@media (min-width: 768px) { + .form-horizontal .control-label { + text-align: right; + margin-bottom: 0; + padding-top: 7px; + } +} +.form-horizontal .has-feedback .form-control-feedback { + right: 15px; +} +@media (min-width: 768px) { + .form-horizontal .form-group-lg .control-label { + padding-top: 14.3px; + } +} +@media (min-width: 768px) { + .form-horizontal .form-group-sm .control-label { + padding-top: 6px; + } +} +.btn { + display: inline-block; + margin-bottom: 0; + font-weight: normal; + text-align: center; + vertical-align: middle; + touch-action: manipulation; + cursor: pointer; + background-image: none; + border: 1px solid transparent; + white-space: nowrap; + padding: 6px 12px; + font-size: 14px; + line-height: 1.428571429; + border-radius: 4px; + -webkit-user-select: none; + -moz-user-select: none; + -ms-user-select: none; + user-select: none; +} +.btn:focus, +.btn:active:focus, +.btn.active:focus, +.btn.focus, +.btn:active.focus, +.btn.active.focus { + outline: thin dotted; + outline: 5px auto -webkit-focus-ring-color; + outline-offset: -2px; +} +.btn:hover, +.btn:focus, +.btn.focus { + color: #333333; + text-decoration: none; +} +.btn:active, +.btn.active { + outline: 0; + background-image: none; + -webkit-box-shadow: inset 0 3px 5px rgba(0, 0, 0, 0.125); + box-shadow: inset 0 3px 5px rgba(0, 0, 0, 0.125); +} +.btn.disabled, +.btn[disabled], +fieldset[disabled] .btn { + cursor: not-allowed; + pointer-events: none; + opacity: 0.65; + filter: alpha(opacity=65); + -webkit-box-shadow: none; + box-shadow: none; +} +.btn-default { + color: #333333; + background-color: #ffffff; + border-color: #cccccc; +} +.btn-default:hover, +.btn-default:focus, +.btn-default.focus, +.btn-default:active, +.btn-default.active, +.open > .dropdown-toggle.btn-default { + color: #333333; + background-color: #e6e6e6; + border-color: #adadad; +} +.btn-default:active, +.btn-default.active, +.open > .dropdown-toggle.btn-default { + background-image: none; +} +.btn-default.disabled, +.btn-default[disabled], +fieldset[disabled] .btn-default, +.btn-default.disabled:hover, +.btn-default[disabled]:hover, +fieldset[disabled] .btn-default:hover, +.btn-default.disabled:focus, +.btn-default[disabled]:focus, +fieldset[disabled] .btn-default:focus, +.btn-default.disabled.focus, +.btn-default[disabled].focus, +fieldset[disabled] .btn-default.focus, +.btn-default.disabled:active, +.btn-default[disabled]:active, +fieldset[disabled] .btn-default:active, +.btn-default.disabled.active, +.btn-default[disabled].active, +fieldset[disabled] .btn-default.active { + background-color: #ffffff; + border-color: #cccccc; +} +.btn-default .badge { + color: #ffffff; + background-color: #333333; +} +.btn-primary { + color: #ffffff; + background-color: #005c66; + border-color: #00454c; +} +.btn-primary:hover, +.btn-primary:focus, +.btn-primary.focus, +.btn-primary:active, +.btn-primary.active, +.open > .dropdown-toggle.btn-primary { + color: #ffffff; + background-color: #002e33; + border-color: #000e0f; +} +.btn-primary:active, +.btn-primary.active, +.open > .dropdown-toggle.btn-primary { + background-image: none; +} +.btn-primary.disabled, +.btn-primary[disabled], +fieldset[disabled] .btn-primary, +.btn-primary.disabled:hover, +.btn-primary[disabled]:hover, +fieldset[disabled] .btn-primary:hover, +.btn-primary.disabled:focus, +.btn-primary[disabled]:focus, +fieldset[disabled] .btn-primary:focus, +.btn-primary.disabled.focus, +.btn-primary[disabled].focus, +fieldset[disabled] .btn-primary.focus, +.btn-primary.disabled:active, +.btn-primary[disabled]:active, +fieldset[disabled] .btn-primary:active, +.btn-primary.disabled.active, +.btn-primary[disabled].active, +fieldset[disabled] .btn-primary.active { + background-color: #005c66; + border-color: #00454c; +} +.btn-primary .badge { + color: #005c66; + background-color: #ffffff; +} +.btn-success { + color: #ffffff; + background-color: #3fb34f; + border-color: #38a047; +} +.btn-success:hover, +.btn-success:focus, +.btn-success.focus, +.btn-success:active, +.btn-success.active, +.open > .dropdown-toggle.btn-success { + color: #ffffff; + background-color: #328d3e; + border-color: #287333; +} +.btn-success:active, +.btn-success.active, +.open > .dropdown-toggle.btn-success { + background-image: none; +} +.btn-success.disabled, +.btn-success[disabled], +fieldset[disabled] .btn-success, +.btn-success.disabled:hover, +.btn-success[disabled]:hover, +fieldset[disabled] .btn-success:hover, +.btn-success.disabled:focus, +.btn-success[disabled]:focus, +fieldset[disabled] .btn-success:focus, +.btn-success.disabled.focus, +.btn-success[disabled].focus, +fieldset[disabled] .btn-success.focus, +.btn-success.disabled:active, +.btn-success[disabled]:active, +fieldset[disabled] .btn-success:active, +.btn-success.disabled.active, +.btn-success[disabled].active, +fieldset[disabled] .btn-success.active { + background-color: #3fb34f; + border-color: #38a047; +} +.btn-success .badge { + color: #3fb34f; + background-color: #ffffff; +} +.btn-info { + color: #ffffff; + background-color: #00d1c1; + border-color: #00b8a9; +} +.btn-info:hover, +.btn-info:focus, +.btn-info.focus, +.btn-info:active, +.btn-info.active, +.open > .dropdown-toggle.btn-info { + color: #ffffff; + background-color: #009e92; + border-color: #007a71; +} +.btn-info:active, +.btn-info.active, +.open > .dropdown-toggle.btn-info { + background-image: none; +} +.btn-info.disabled, +.btn-info[disabled], +fieldset[disabled] .btn-info, +.btn-info.disabled:hover, +.btn-info[disabled]:hover, +fieldset[disabled] .btn-info:hover, +.btn-info.disabled:focus, +.btn-info[disabled]:focus, +fieldset[disabled] .btn-info:focus, +.btn-info.disabled.focus, +.btn-info[disabled].focus, +fieldset[disabled] .btn-info.focus, +.btn-info.disabled:active, +.btn-info[disabled]:active, +fieldset[disabled] .btn-info:active, +.btn-info.disabled.active, +.btn-info[disabled].active, +fieldset[disabled] .btn-info.active { + background-color: #00d1c1; + border-color: #00b8a9; +} +.btn-info .badge { + color: #00d1c1; + background-color: #ffffff; +} +.btn-warning { + color: #ffffff; + background-color: #ffb400; + border-color: #e6a200; +} +.btn-warning:hover, +.btn-warning:focus, +.btn-warning.focus, +.btn-warning:active, +.btn-warning.active, +.open > .dropdown-toggle.btn-warning { + color: #ffffff; + background-color: #cc9000; + border-color: #a87700; +} +.btn-warning:active, +.btn-warning.active, +.open > .dropdown-toggle.btn-warning { + background-image: none; +} +.btn-warning.disabled, +.btn-warning[disabled], +fieldset[disabled] .btn-warning, +.btn-warning.disabled:hover, +.btn-warning[disabled]:hover, +fieldset[disabled] .btn-warning:hover, +.btn-warning.disabled:focus, +.btn-warning[disabled]:focus, +fieldset[disabled] .btn-warning:focus, +.btn-warning.disabled.focus, +.btn-warning[disabled].focus, +fieldset[disabled] .btn-warning.focus, +.btn-warning.disabled:active, +.btn-warning[disabled]:active, +fieldset[disabled] .btn-warning:active, +.btn-warning.disabled.active, +.btn-warning[disabled].active, +fieldset[disabled] .btn-warning.active { + background-color: #ffb400; + border-color: #e6a200; +} +.btn-warning .badge { + color: #ffb400; + background-color: #ffffff; +} +.btn-danger { + color: #ffffff; + background-color: #ff5a5f; + border-color: #ff4146; +} +.btn-danger:hover, +.btn-danger:focus, +.btn-danger.focus, +.btn-danger:active, +.btn-danger.active, +.open > .dropdown-toggle.btn-danger { + color: #ffffff; + background-color: #ff272e; + border-color: #ff030b; +} +.btn-danger:active, +.btn-danger.active, +.open > .dropdown-toggle.btn-danger { + background-image: none; +} +.btn-danger.disabled, +.btn-danger[disabled], +fieldset[disabled] .btn-danger, +.btn-danger.disabled:hover, +.btn-danger[disabled]:hover, +fieldset[disabled] .btn-danger:hover, +.btn-danger.disabled:focus, +.btn-danger[disabled]:focus, +fieldset[disabled] .btn-danger:focus, +.btn-danger.disabled.focus, +.btn-danger[disabled].focus, +fieldset[disabled] .btn-danger.focus, +.btn-danger.disabled:active, +.btn-danger[disabled]:active, +fieldset[disabled] .btn-danger:active, +.btn-danger.disabled.active, +.btn-danger[disabled].active, +fieldset[disabled] .btn-danger.active { + background-color: #ff5a5f; + border-color: #ff4146; +} +.btn-danger .badge { + color: #ff5a5f; + background-color: #ffffff; +} +.btn-link { + color: #0091a1; + font-weight: normal; + border-radius: 0; +} +.btn-link, +.btn-link:active, +.btn-link.active, +.btn-link[disabled], +fieldset[disabled] .btn-link { + background-color: transparent; + -webkit-box-shadow: none; + box-shadow: none; +} +.btn-link, +.btn-link:hover, +.btn-link:focus, +.btn-link:active { + border-color: transparent; +} +.btn-link:hover, +.btn-link:focus { + color: #004c54; + text-decoration: underline; + background-color: transparent; +} +.btn-link[disabled]:hover, +fieldset[disabled] .btn-link:hover, +.btn-link[disabled]:focus, +fieldset[disabled] .btn-link:focus { + color: #777777; + text-decoration: none; +} +.btn-lg, +.btn-group-lg > .btn { + padding: 10px 16px; + font-size: 18px; + line-height: 1.33; + border-radius: 6px; +} +.btn-sm, +.btn-group-sm > .btn { + padding: 5px 10px; + font-size: 12px; + line-height: 1.5; + border-radius: 3px; +} +.btn-xs, +.btn-group-xs > .btn { + padding: 1px 5px; + font-size: 12px; + line-height: 1.5; + border-radius: 3px; +} +.btn-block { + display: block; + width: 100%; +} +.btn-block + .btn-block { + margin-top: 5px; +} +input[type="submit"].btn-block, +input[type="reset"].btn-block, +input[type="button"].btn-block { + width: 100%; +} +.fade { + opacity: 0; + -webkit-transition: opacity 0.15s linear; + -o-transition: opacity 0.15s linear; + transition: opacity 0.15s linear; +} +.fade.in { + opacity: 1; +} +.collapse { + display: none; + visibility: hidden; +} +.collapse.in { + display: block; + visibility: visible; +} +tr.collapse.in { + display: table-row; +} +tbody.collapse.in { + display: table-row-group; +} +.collapsing { + position: relative; + height: 0; + overflow: hidden; + -webkit-transition-property: height, visibility; + transition-property: height, visibility; + -webkit-transition-duration: 0.35s; + transition-duration: 0.35s; + -webkit-transition-timing-function: ease; + transition-timing-function: ease; +} +.glyphicon { + position: relative; + top: 1px; + display: inline-block; + font-family: 'Glyphicons Halflings'; + font-style: normal; + font-weight: normal; + line-height: 1; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; +} +.glyphicon-asterisk:before { + content: "\2a"; +} +.glyphicon-plus:before { + content: "\2b"; +} +.glyphicon-euro:before, +.glyphicon-eur:before { + content: "\20ac"; +} +.glyphicon-minus:before { + content: "\2212"; +} +.glyphicon-cloud:before { + content: "\2601"; +} +.glyphicon-envelope:before { + content: "\2709"; +} +.glyphicon-pencil:before { + content: "\270f"; +} +.glyphicon-glass:before { + content: "\e001"; +} +.glyphicon-music:before { + content: "\e002"; +} +.glyphicon-search:before { + content: "\e003"; +} +.glyphicon-heart:before { + content: "\e005"; +} +.glyphicon-star:before { + content: "\e006"; +} +.glyphicon-star-empty:before { + content: "\e007"; +} +.glyphicon-user:before { + content: "\e008"; +} +.glyphicon-film:before { + content: "\e009"; +} +.glyphicon-th-large:before { + content: "\e010"; +} +.glyphicon-th:before { + content: "\e011"; +} +.glyphicon-th-list:before { + content: "\e012"; +} +.glyphicon-ok:before { + content: "\e013"; +} +.glyphicon-remove:before { + content: "\e014"; +} +.glyphicon-zoom-in:before { + content: "\e015"; +} +.glyphicon-zoom-out:before { + content: "\e016"; +} +.glyphicon-off:before { + content: "\e017"; +} +.glyphicon-signal:before { + content: "\e018"; +} +.glyphicon-cog:before { + content: "\e019"; +} +.glyphicon-trash:before { + content: "\e020"; +} +.glyphicon-home:before { + content: "\e021"; +} +.glyphicon-file:before { + content: "\e022"; +} +.glyphicon-time:before { + content: "\e023"; +} +.glyphicon-road:before { + content: "\e024"; +} +.glyphicon-download-alt:before { + content: "\e025"; +} +.glyphicon-download:before { + content: "\e026"; +} +.glyphicon-upload:before { + content: "\e027"; +} +.glyphicon-inbox:before { + content: "\e028"; +} +.glyphicon-play-circle:before { + content: "\e029"; +} +.glyphicon-repeat:before { + content: "\e030"; +} +.glyphicon-refresh:before { + content: "\e031"; +} +.glyphicon-list-alt:before { + content: "\e032"; +} +.glyphicon-lock:before { + content: "\e033"; +} +.glyphicon-flag:before { + content: "\e034"; +} +.glyphicon-headphones:before { + content: "\e035"; +} +.glyphicon-volume-off:before { + content: "\e036"; +} +.glyphicon-volume-down:before { + content: "\e037"; +} +.glyphicon-volume-up:before { + content: "\e038"; +} +.glyphicon-qrcode:before { + content: "\e039"; +} +.glyphicon-barcode:before { + content: "\e040"; +} +.glyphicon-tag:before { + content: "\e041"; +} +.glyphicon-tags:before { + content: "\e042"; +} +.glyphicon-book:before { + content: "\e043"; +} +.glyphicon-bookmark:before { + content: "\e044"; +} +.glyphicon-print:before { + content: "\e045"; +} +.glyphicon-camera:before { + content: "\e046"; +} +.glyphicon-font:before { + content: "\e047"; +} +.glyphicon-bold:before { + content: "\e048"; +} +.glyphicon-italic:before { + content: "\e049"; +} +.glyphicon-text-height:before { + content: "\e050"; +} +.glyphicon-text-width:before { + content: "\e051"; +} +.glyphicon-align-left:before { + content: "\e052"; +} +.glyphicon-align-center:before { + content: "\e053"; +} +.glyphicon-align-right:before { + content: "\e054"; +} +.glyphicon-align-justify:before { + content: "\e055"; +} +.glyphicon-list:before { + content: "\e056"; +} +.glyphicon-indent-left:before { + content: "\e057"; +} +.glyphicon-indent-right:before { + content: "\e058"; +} +.glyphicon-facetime-video:before { + content: "\e059"; +} +.glyphicon-picture:before { + content: "\e060"; +} +.glyphicon-map-marker:before { + content: "\e062"; +} +.glyphicon-adjust:before { + content: "\e063"; +} +.glyphicon-tint:before { + content: "\e064"; +} +.glyphicon-edit:before { + content: "\e065"; +} +.glyphicon-share:before { + content: "\e066"; +} +.glyphicon-check:before { + content: "\e067"; +} +.glyphicon-move:before { + content: "\e068"; +} +.glyphicon-step-backward:before { + content: "\e069"; +} +.glyphicon-fast-backward:before { + content: "\e070"; +} +.glyphicon-backward:before { + content: "\e071"; +} +.glyphicon-play:before { + content: "\e072"; +} +.glyphicon-pause:before { + content: "\e073"; +} +.glyphicon-stop:before { + content: "\e074"; +} +.glyphicon-forward:before { + content: "\e075"; +} +.glyphicon-fast-forward:before { + content: "\e076"; +} +.glyphicon-step-forward:before { + content: "\e077"; +} +.glyphicon-eject:before { + content: "\e078"; +} +.glyphicon-chevron-left:before { + content: "\e079"; +} +.glyphicon-chevron-right:before { + content: "\e080"; +} +.glyphicon-plus-sign:before { + content: "\e081"; +} +.glyphicon-minus-sign:before { + content: "\e082"; +} +.glyphicon-remove-sign:before { + content: "\e083"; +} +.glyphicon-ok-sign:before { + content: "\e084"; +} +.glyphicon-question-sign:before { + content: "\e085"; +} +.glyphicon-info-sign:before { + content: "\e086"; +} +.glyphicon-screenshot:before { + content: "\e087"; +} +.glyphicon-remove-circle:before { + content: "\e088"; +} +.glyphicon-ok-circle:before { + content: "\e089"; +} +.glyphicon-ban-circle:before { + content: "\e090"; +} +.glyphicon-arrow-left:before { + content: "\e091"; +} +.glyphicon-arrow-right:before { + content: "\e092"; +} +.glyphicon-arrow-up:before { + content: "\e093"; +} +.glyphicon-arrow-down:before { + content: "\e094"; +} +.glyphicon-share-alt:before { + content: "\e095"; +} +.glyphicon-resize-full:before { + content: "\e096"; +} +.glyphicon-resize-small:before { + content: "\e097"; +} +.glyphicon-exclamation-sign:before { + content: "\e101"; +} +.glyphicon-gift:before { + content: "\e102"; +} +.glyphicon-leaf:before { + content: "\e103"; +} +.glyphicon-fire:before { + content: "\e104"; +} +.glyphicon-eye-open:before { + content: "\e105"; +} +.glyphicon-eye-close:before { + content: "\e106"; +} +.glyphicon-warning-sign:before { + content: "\e107"; +} +.glyphicon-plane:before { + content: "\e108"; +} +.glyphicon-calendar:before { + content: "\e109"; +} +.glyphicon-random:before { + content: "\e110"; +} +.glyphicon-comment:before { + content: "\e111"; +} +.glyphicon-magnet:before { + content: "\e112"; +} +.glyphicon-chevron-up:before { + content: "\e113"; +} +.glyphicon-chevron-down:before { + content: "\e114"; +} +.glyphicon-retweet:before { + content: "\e115"; +} +.glyphicon-shopping-cart:before { + content: "\e116"; +} +.glyphicon-folder-close:before { + content: "\e117"; +} +.glyphicon-folder-open:before { + content: "\e118"; +} +.glyphicon-resize-vertical:before { + content: "\e119"; +} +.glyphicon-resize-horizontal:before { + content: "\e120"; +} +.glyphicon-hdd:before { + content: "\e121"; +} +.glyphicon-bullhorn:before { + content: "\e122"; +} +.glyphicon-bell:before { + content: "\e123"; +} +.glyphicon-certificate:before { + content: "\e124"; +} +.glyphicon-thumbs-up:before { + content: "\e125"; +} +.glyphicon-thumbs-down:before { + content: "\e126"; +} +.glyphicon-hand-right:before { + content: "\e127"; +} +.glyphicon-hand-left:before { + content: "\e128"; +} +.glyphicon-hand-up:before { + content: "\e129"; +} +.glyphicon-hand-down:before { + content: "\e130"; +} +.glyphicon-circle-arrow-right:before { + content: "\e131"; +} +.glyphicon-circle-arrow-left:before { + content: "\e132"; +} +.glyphicon-circle-arrow-up:before { + content: "\e133"; +} +.glyphicon-circle-arrow-down:before { + content: "\e134"; +} +.glyphicon-globe:before { + content: "\e135"; +} +.glyphicon-wrench:before { + content: "\e136"; +} +.glyphicon-tasks:before { + content: "\e137"; +} +.glyphicon-filter:before { + content: "\e138"; +} +.glyphicon-briefcase:before { + content: "\e139"; +} +.glyphicon-fullscreen:before { + content: "\e140"; +} +.glyphicon-dashboard:before { + content: "\e141"; +} +.glyphicon-paperclip:before { + content: "\e142"; +} +.glyphicon-heart-empty:before { + content: "\e143"; +} +.glyphicon-link:before { + content: "\e144"; +} +.glyphicon-phone:before { + content: "\e145"; +} +.glyphicon-pushpin:before { + content: "\e146"; +} +.glyphicon-usd:before { + content: "\e148"; +} +.glyphicon-gbp:before { + content: "\e149"; +} +.glyphicon-sort:before { + content: "\e150"; +} +.glyphicon-sort-by-alphabet:before { + content: "\e151"; +} +.glyphicon-sort-by-alphabet-alt:before { + content: "\e152"; +} +.glyphicon-sort-by-order:before { + content: "\e153"; +} +.glyphicon-sort-by-order-alt:before { + content: "\e154"; +} +.glyphicon-sort-by-attributes:before { + content: "\e155"; +} +.glyphicon-sort-by-attributes-alt:before { + content: "\e156"; +} +.glyphicon-unchecked:before { + content: "\e157"; +} +.glyphicon-expand:before { + content: "\e158"; +} +.glyphicon-collapse-down:before { + content: "\e159"; +} +.glyphicon-collapse-up:before { + content: "\e160"; +} +.glyphicon-log-in:before { + content: "\e161"; +} +.glyphicon-file:before { + content: "\e162"; +} +.glyphicon-log-out:before { + content: "\e163"; +} +.glyphicon-new-window:before { + content: "\e164"; +} +.glyphicon-record:before { + content: "\e165"; +} +.glyphicon-save:before { + content: "\e166"; +} +.glyphicon-open:before { + content: "\e167"; +} +.glyphicon-saved:before { + content: "\e168"; +} +.glyphicon-import:before { + content: "\e169"; +} +.glyphicon-export:before { + content: "\e170"; +} +.glyphicon-send:before { + content: "\e171"; +} +.glyphicon-floppy-disk:before { + content: "\e172"; +} +.glyphicon-floppy-saved:before { + content: "\e173"; +} +.glyphicon-floppy-remove:before { + content: "\e174"; +} +.glyphicon-floppy-save:before { + content: "\e175"; +} +.glyphicon-floppy-open:before { + content: "\e176"; +} +.glyphicon-credit-card:before { + content: "\e177"; +} +.glyphicon-transfer:before { + content: "\e178"; +} +.glyphicon-cutlery:before { + content: "\e179"; +} +.glyphicon-header:before { + content: "\e180"; +} +.glyphicon-compressed:before { + content: "\e181"; +} +.glyphicon-earphone:before { + content: "\e182"; +} +.glyphicon-phone-alt:before { + content: "\e183"; +} +.glyphicon-tower:before { + content: "\e184"; +} +.glyphicon-stats:before { + content: "\e185"; +} +.glyphicon-sd-video:before { + content: "\e186"; +} +.glyphicon-hd-video:before { + content: "\e187"; +} +.glyphicon-subtitles:before { + content: "\e188"; +} +.glyphicon-sound-stereo:before { + content: "\e189"; +} +.glyphicon-sound-dolby:before { + content: "\e190"; +} +.glyphicon-sound-5-1:before { + content: "\e191"; +} +.glyphicon-sound-6-1:before { + content: "\e192"; +} +.glyphicon-sound-7-1:before { + content: "\e193"; +} +.glyphicon-copyright-mark:before { + content: "\e194"; +} +.glyphicon-registration-mark:before { + content: "\e195"; +} +.glyphicon-cloud-download:before { + content: "\e197"; +} +.glyphicon-cloud-upload:before { + content: "\e198"; +} +.glyphicon-tree-conifer:before { + content: "\e199"; +} +.glyphicon-tree-deciduous:before { + content: "\e200"; +} +.glyphicon-cd:before { + content: "\e201"; +} +.glyphicon-save-file:before { + content: "\e202"; +} +.glyphicon-open-file:before { + content: "\e203"; +} +.glyphicon-level-up:before { + content: "\e204"; +} +.glyphicon-copy:before { + content: "\e205"; +} +.glyphicon-paste:before { + content: "\e206"; +} +.glyphicon-door:before { + content: "\1f6aa"; +} +.glyphicon-key:before { + content: "\1f511"; +} +.glyphicon-alert:before { + content: "\e209"; +} +.glyphicon-equalizer:before { + content: "\e210"; +} +.glyphicon-king:before { + content: "\e211"; +} +.glyphicon-queen:before { + content: "\e212"; +} +.glyphicon-pawn:before { + content: "\e213"; +} +.glyphicon-bishop:before { + content: "\e214"; +} +.glyphicon-knight:before { + content: "\e215"; +} +.glyphicon-baby-formula:before { + content: "\e216"; +} +.glyphicon-tent:before { + content: "\26fa"; +} +.glyphicon-blackboard:before { + content: "\e218"; +} +.glyphicon-bed:before { + content: "\e219"; +} +.glyphicon-apple:before { + content: "\f8ff"; +} +.glyphicon-erase:before { + content: "\e221"; +} +.glyphicon-hourglass:before { + content: "\231b"; +} +.glyphicon-lamp:before { + content: "\e223"; +} +.glyphicon-duplicate:before { + content: "\e224"; +} +.glyphicon-piggy-bank:before { + content: "\e225"; +} +.glyphicon-scissors:before { + content: "\e226"; +} +.glyphicon-bitcoin:before { + content: "\e227"; +} +.glyphicon-yen:before { + content: "\00a5"; +} +.glyphicon-ruble:before { + content: "\20bd"; +} +.glyphicon-scale:before { + content: "\e230"; +} +.glyphicon-ice-lolly:before { + content: "\e231"; +} +.glyphicon-ice-lolly-tasted:before { + content: "\e232"; +} +.glyphicon-education:before { + content: "\e233"; +} +.glyphicon-option-horizontal:before { + content: "\e234"; +} +.glyphicon-option-vertical:before { + content: "\e235"; +} +.glyphicon-menu-hamburger:before { + content: "\e236"; +} +.glyphicon-modal-window:before { + content: "\e237"; +} +.glyphicon-oil:before { + content: "\e238"; +} +.glyphicon-grain:before { + content: "\e239"; +} +.glyphicon-sunglasses:before { + content: "\e240"; +} +.glyphicon-text-size:before { + content: "\e241"; +} +.glyphicon-text-color:before { + content: "\e242"; +} +.glyphicon-text-background:before { + content: "\e243"; +} +.glyphicon-object-align-top:before { + content: "\e244"; +} +.glyphicon-object-align-bottom:before { + content: "\e245"; +} +.glyphicon-object-align-horizontal:before { + content: "\e246"; +} +.glyphicon-object-align-left:before { + content: "\e247"; +} +.glyphicon-object-align-vertical:before { + content: "\e248"; +} +.glyphicon-object-align-right:before { + content: "\e249"; +} +.glyphicon-triangle-right:before { + content: "\e250"; +} +.glyphicon-triangle-left:before { + content: "\e251"; +} +.glyphicon-triangle-bottom:before { + content: "\e252"; +} +.glyphicon-triangle-top:before { + content: "\e253"; +} +.glyphicon-console:before { + content: "\e254"; +} +.glyphicon-superscript:before { + content: "\e255"; +} +.glyphicon-subscript:before { + content: "\e256"; +} +.glyphicon-menu-left:before { + content: "\e257"; +} +.glyphicon-menu-right:before { + content: "\e258"; +} +.glyphicon-menu-down:before { + content: "\e259"; +} +.glyphicon-menu-up:before { + content: "\e260"; +} +.caret { + display: inline-block; + width: 0; + height: 0; + margin-left: 2px; + vertical-align: middle; + border-top: 4px solid; + border-right: 4px solid transparent; + border-left: 4px solid transparent; +} +.dropdown { + position: relative; +} +.dropdown-toggle:focus { + outline: 0; +} +.dropdown-menu { + position: absolute; + top: 100%; + left: 0; + z-index: 1000; + display: none; + float: left; + min-width: 160px; + padding: 5px 0; + margin: 2px 0 0; + list-style: none; + font-size: 14px; + text-align: left; + background-color: #ffffff; + border: 1px solid #cccccc; + border: 1px solid rgba(0, 0, 0, 0.15); + border-radius: 4px; + -webkit-box-shadow: 0 6px 12px rgba(0, 0, 0, 0.175); + box-shadow: 0 6px 12px rgba(0, 0, 0, 0.175); + background-clip: padding-box; +} +.dropdown-menu.pull-right { + right: 0; + left: auto; +} +.dropdown-menu .divider { + height: 1px; + margin: 9px 0; + overflow: hidden; + background-color: #e5e5e5; +} +.dropdown-menu > li > a { + display: block; + padding: 3px 20px; + clear: both; + font-weight: normal; + line-height: 1.428571429; + color: #333333; + white-space: nowrap; +} +.dropdown-menu > li > a:hover, +.dropdown-menu > li > a:focus { + text-decoration: none; + color: #262626; + background-color: #f5f5f5; +} +.dropdown-menu > .active > a, +.dropdown-menu > .active > a:hover, +.dropdown-menu > .active > a:focus { + color: #ffffff; + text-decoration: none; + outline: 0; + background-color: #005c66; +} +.dropdown-menu > .disabled > a, +.dropdown-menu > .disabled > a:hover, +.dropdown-menu > .disabled > a:focus { + color: #777777; +} +.dropdown-menu > .disabled > a:hover, +.dropdown-menu > .disabled > a:focus { + text-decoration: none; + background-color: transparent; + background-image: none; + filter: progid:DXImageTransform.Microsoft.gradient(enabled = false); + cursor: not-allowed; +} +.open > .dropdown-menu { + display: block; +} +.open > a { + outline: 0; +} +.dropdown-menu-right { + left: auto; + right: 0; +} +.dropdown-menu-left { + left: 0; + right: auto; +} +.dropdown-header { + display: block; + padding: 3px 20px; + font-size: 12px; + line-height: 1.428571429; + color: #777777; + white-space: nowrap; +} +.dropdown-backdrop { + position: fixed; + left: 0; + right: 0; + bottom: 0; + top: 0; + z-index: 990; +} +.pull-right > .dropdown-menu { + right: 0; + left: auto; +} +.dropup .caret, +.navbar-fixed-bottom .dropdown .caret { + border-top: 0; + border-bottom: 4px solid; + content: ""; +} +.dropup .dropdown-menu, +.navbar-fixed-bottom .dropdown .dropdown-menu { + top: auto; + bottom: 100%; + margin-bottom: 2px; +} +@media (min-width: 768px) { + .navbar-right .dropdown-menu { + left: auto; + right: 0; + } + .navbar-right .dropdown-menu-left { + left: 0; + right: auto; + } +} +.btn-group, +.btn-group-vertical { + position: relative; + display: inline-block; + vertical-align: middle; +} +.btn-group > .btn, +.btn-group-vertical > .btn { + position: relative; + float: left; +} +.btn-group > .btn:hover, +.btn-group-vertical > .btn:hover, +.btn-group > .btn:focus, +.btn-group-vertical > .btn:focus, +.btn-group > .btn:active, +.btn-group-vertical > .btn:active, +.btn-group > .btn.active, +.btn-group-vertical > .btn.active { + z-index: 2; +} +.btn-group .btn + .btn, +.btn-group .btn + .btn-group, +.btn-group .btn-group + .btn, +.btn-group .btn-group + .btn-group { + margin-left: -1px; +} +.btn-toolbar { + margin-left: -5px; +} +.btn-toolbar .btn-group, +.btn-toolbar .input-group { + float: left; +} +.btn-toolbar > .btn, +.btn-toolbar > .btn-group, +.btn-toolbar > .input-group { + margin-left: 5px; +} +.btn-group > .btn:not(:first-child):not(:last-child):not(.dropdown-toggle) { + border-radius: 0; +} +.btn-group > .btn:first-child { + margin-left: 0; +} +.btn-group > .btn:first-child:not(:last-child):not(.dropdown-toggle) { + border-bottom-right-radius: 0; + border-top-right-radius: 0; +} +.btn-group > .btn:last-child:not(:first-child), +.btn-group > .dropdown-toggle:not(:first-child) { + border-bottom-left-radius: 0; + border-top-left-radius: 0; +} +.btn-group > .btn-group { + float: left; +} +.btn-group > .btn-group:not(:first-child):not(:last-child) > .btn { + border-radius: 0; +} +.btn-group > .btn-group:first-child > .btn:last-child, +.btn-group > .btn-group:first-child > .dropdown-toggle { + border-bottom-right-radius: 0; + border-top-right-radius: 0; +} +.btn-group > .btn-group:last-child > .btn:first-child { + border-bottom-left-radius: 0; + border-top-left-radius: 0; +} +.btn-group .dropdown-toggle:active, +.btn-group.open .dropdown-toggle { + outline: 0; +} +.btn-group > .btn + .dropdown-toggle { + padding-left: 8px; + padding-right: 8px; +} +.btn-group > .btn-lg + .dropdown-toggle { + padding-left: 12px; + padding-right: 12px; +} +.btn-group.open .dropdown-toggle { + -webkit-box-shadow: inset 0 3px 5px rgba(0, 0, 0, 0.125); + box-shadow: inset 0 3px 5px rgba(0, 0, 0, 0.125); +} +.btn-group.open .dropdown-toggle.btn-link { + -webkit-box-shadow: none; + box-shadow: none; +} +.btn .caret { + margin-left: 0; +} +.btn-lg .caret { + border-width: 5px 5px 0; + border-bottom-width: 0; +} +.dropup .btn-lg .caret { + border-width: 0 5px 5px; +} +.btn-group-vertical > .btn, +.btn-group-vertical > .btn-group, +.btn-group-vertical > .btn-group > .btn { + display: block; + float: none; + width: 100%; + max-width: 100%; +} +.btn-group-vertical > .btn-group > .btn { + float: none; +} +.btn-group-vertical > .btn + .btn, +.btn-group-vertical > .btn + .btn-group, +.btn-group-vertical > .btn-group + .btn, +.btn-group-vertical > .btn-group + .btn-group { + margin-top: -1px; + margin-left: 0; +} +.btn-group-vertical > .btn:not(:first-child):not(:last-child) { + border-radius: 0; +} +.btn-group-vertical > .btn:first-child:not(:last-child) { + border-top-right-radius: 4px; + border-bottom-right-radius: 0; + border-bottom-left-radius: 0; +} +.btn-group-vertical > .btn:last-child:not(:first-child) { + border-bottom-left-radius: 4px; + border-top-right-radius: 0; + border-top-left-radius: 0; +} +.btn-group-vertical > .btn-group:not(:first-child):not(:last-child) > .btn { + border-radius: 0; +} +.btn-group-vertical > .btn-group:first-child:not(:last-child) > .btn:last-child, +.btn-group-vertical > .btn-group:first-child:not(:last-child) > .dropdown-toggle { + border-bottom-right-radius: 0; + border-bottom-left-radius: 0; +} +.btn-group-vertical > .btn-group:last-child:not(:first-child) > .btn:first-child { + border-top-right-radius: 0; + border-top-left-radius: 0; +} +.btn-group-justified { + display: table; + width: 100%; + table-layout: fixed; + border-collapse: separate; +} +.btn-group-justified > .btn, +.btn-group-justified > .btn-group { + float: none; + display: table-cell; + width: 1%; +} +.btn-group-justified > .btn-group .btn { + width: 100%; +} +.btn-group-justified > .btn-group .dropdown-menu { + left: auto; +} +[data-toggle="buttons"] > .btn input[type="radio"], +[data-toggle="buttons"] > .btn-group > .btn input[type="radio"], +[data-toggle="buttons"] > .btn input[type="checkbox"], +[data-toggle="buttons"] > .btn-group > .btn input[type="checkbox"] { + position: absolute; + clip: rect(0, 0, 0, 0); + pointer-events: none; +} +.input-group { + position: relative; + display: table; + border-collapse: separate; +} +.input-group[class*="col-"] { + float: none; + padding-left: 0; + padding-right: 0; +} +.input-group .form-control { + position: relative; + z-index: 2; + float: left; + width: 100%; + margin-bottom: 0; +} +.input-group-lg > .form-control, +.input-group-lg > .input-group-addon, +.input-group-lg > .input-group-btn > .btn { + height: 46px; + padding: 10px 16px; + font-size: 18px; + line-height: 1.33; + border-radius: 6px; +} +select.input-group-lg > .form-control, +select.input-group-lg > .input-group-addon, +select.input-group-lg > .input-group-btn > .btn { + height: 46px; + line-height: 46px; +} +textarea.input-group-lg > .form-control, +textarea.input-group-lg > .input-group-addon, +textarea.input-group-lg > .input-group-btn > .btn, +select[multiple].input-group-lg > .form-control, +select[multiple].input-group-lg > .input-group-addon, +select[multiple].input-group-lg > .input-group-btn > .btn { + height: auto; +} +.input-group-sm > .form-control, +.input-group-sm > .input-group-addon, +.input-group-sm > .input-group-btn > .btn { + height: 30px; + padding: 5px 10px; + font-size: 12px; + line-height: 1.5; + border-radius: 3px; +} +select.input-group-sm > .form-control, +select.input-group-sm > .input-group-addon, +select.input-group-sm > .input-group-btn > .btn { + height: 30px; + line-height: 30px; +} +textarea.input-group-sm > .form-control, +textarea.input-group-sm > .input-group-addon, +textarea.input-group-sm > .input-group-btn > .btn, +select[multiple].input-group-sm > .form-control, +select[multiple].input-group-sm > .input-group-addon, +select[multiple].input-group-sm > .input-group-btn > .btn { + height: auto; +} +.input-group-addon, +.input-group-btn, +.input-group .form-control { + display: table-cell; +} +.input-group-addon:not(:first-child):not(:last-child), +.input-group-btn:not(:first-child):not(:last-child), +.input-group .form-control:not(:first-child):not(:last-child) { + border-radius: 0; +} +.input-group-addon, +.input-group-btn { + width: 1%; + white-space: nowrap; + vertical-align: middle; +} +.input-group-addon { + padding: 6px 12px; + font-size: 14px; + font-weight: normal; + line-height: 1; + color: #555555; + text-align: center; + background-color: #eeeeee; + border: 1px solid #cccccc; + border-radius: 4px; +} +.input-group-addon.input-sm { + padding: 5px 10px; + font-size: 12px; + border-radius: 3px; +} +.input-group-addon.input-lg { + padding: 10px 16px; + font-size: 18px; + border-radius: 6px; +} +.input-group-addon input[type="radio"], +.input-group-addon input[type="checkbox"] { + margin-top: 0; +} +.input-group .form-control:first-child, +.input-group-addon:first-child, +.input-group-btn:first-child > .btn, +.input-group-btn:first-child > .btn-group > .btn, +.input-group-btn:first-child > .dropdown-toggle, +.input-group-btn:last-child > .btn:not(:last-child):not(.dropdown-toggle), +.input-group-btn:last-child > .btn-group:not(:last-child) > .btn { + border-bottom-right-radius: 0; + border-top-right-radius: 0; +} +.input-group-addon:first-child { + border-right: 0; +} +.input-group .form-control:last-child, +.input-group-addon:last-child, +.input-group-btn:last-child > .btn, +.input-group-btn:last-child > .btn-group > .btn, +.input-group-btn:last-child > .dropdown-toggle, +.input-group-btn:first-child > .btn:not(:first-child), +.input-group-btn:first-child > .btn-group:not(:first-child) > .btn { + border-bottom-left-radius: 0; + border-top-left-radius: 0; +} +.input-group-addon:last-child { + border-left: 0; +} +.input-group-btn { + position: relative; + font-size: 0; + white-space: nowrap; +} +.input-group-btn > .btn { + position: relative; +} +.input-group-btn > .btn + .btn { + margin-left: -1px; +} +.input-group-btn > .btn:hover, +.input-group-btn > .btn:focus, +.input-group-btn > .btn:active { + z-index: 2; +} +.input-group-btn:first-child > .btn, +.input-group-btn:first-child > .btn-group { + margin-right: -1px; +} +.input-group-btn:last-child > .btn, +.input-group-btn:last-child > .btn-group { + margin-left: -1px; +} +.nav { + margin-bottom: 0; + padding-left: 0; + list-style: none; +} +.nav > li { + position: relative; + display: block; +} +.nav > li > a { + position: relative; + display: block; + padding: 10px 15px; +} +.nav > li > a:hover, +.nav > li > a:focus { + text-decoration: none; + background-color: #eeeeee; +} +.nav > li.disabled > a { + color: #777777; +} +.nav > li.disabled > a:hover, +.nav > li.disabled > a:focus { + color: #777777; + text-decoration: none; + background-color: transparent; + cursor: not-allowed; +} +.nav .open > a, +.nav .open > a:hover, +.nav .open > a:focus { + background-color: #eeeeee; + border-color: #0091a1; +} +.nav .nav-divider { + height: 1px; + margin: 9px 0; + overflow: hidden; + background-color: #e5e5e5; +} +.nav > li > a > img { + max-width: none; +} +.nav-tabs { + border-bottom: 1px solid #dddddd; +} +.nav-tabs > li { + float: left; + margin-bottom: -1px; +} +.nav-tabs > li > a { + margin-right: 2px; + line-height: 1.428571429; + border: 1px solid transparent; + border-radius: 4px 4px 0 0; +} +.nav-tabs > li > a:hover { + border-color: #eeeeee #eeeeee #dddddd; +} +.nav-tabs > li.active > a, +.nav-tabs > li.active > a:hover, +.nav-tabs > li.active > a:focus { + color: #555555; + background-color: #fafafa; + border: 1px solid #dddddd; + border-bottom-color: transparent; + cursor: default; +} +.nav-tabs.nav-justified { + width: 100%; + border-bottom: 0; +} +.nav-tabs.nav-justified > li { + float: none; +} +.nav-tabs.nav-justified > li > a { + text-align: center; + margin-bottom: 5px; +} +.nav-tabs.nav-justified > .dropdown .dropdown-menu { + top: auto; + left: auto; +} +@media (min-width: 768px) { + .nav-tabs.nav-justified > li { + display: table-cell; + width: 1%; + } + .nav-tabs.nav-justified > li > a { + margin-bottom: 0; + } +} +.nav-tabs.nav-justified > li > a { + margin-right: 0; + border-radius: 4px; +} +.nav-tabs.nav-justified > .active > a, +.nav-tabs.nav-justified > .active > a:hover, +.nav-tabs.nav-justified > .active > a:focus { + border: 1px solid #dddddd; +} +@media (min-width: 768px) { + .nav-tabs.nav-justified > li > a { + border-bottom: 1px solid #dddddd; + border-radius: 4px 4px 0 0; + } + .nav-tabs.nav-justified > .active > a, + .nav-tabs.nav-justified > .active > a:hover, + .nav-tabs.nav-justified > .active > a:focus { + border-bottom-color: #fafafa; + } +} +.nav-pills > li { + float: left; +} +.nav-pills > li > a { + border-radius: 4px; +} +.nav-pills > li + li { + margin-left: 2px; +} +.nav-pills > li.active > a, +.nav-pills > li.active > a:hover, +.nav-pills > li.active > a:focus { + color: #ffffff; + background-color: #005c66; +} +.nav-stacked > li { + float: none; +} +.nav-stacked > li + li { + margin-top: 2px; + margin-left: 0; +} +.nav-justified { + width: 100%; +} +.nav-justified > li { + float: none; +} +.nav-justified > li > a { + text-align: center; + margin-bottom: 5px; +} +.nav-justified > .dropdown .dropdown-menu { + top: auto; + left: auto; +} +@media (min-width: 768px) { + .nav-justified > li { + display: table-cell; + width: 1%; + } + .nav-justified > li > a { + margin-bottom: 0; + } +} +.nav-tabs-justified { + border-bottom: 0; +} +.nav-tabs-justified > li > a { + margin-right: 0; + border-radius: 4px; +} +.nav-tabs-justified > .active > a, +.nav-tabs-justified > .active > a:hover, +.nav-tabs-justified > .active > a:focus { + border: 1px solid #dddddd; +} +@media (min-width: 768px) { + .nav-tabs-justified > li > a { + border-bottom: 1px solid #dddddd; + border-radius: 4px 4px 0 0; + } + .nav-tabs-justified > .active > a, + .nav-tabs-justified > .active > a:hover, + .nav-tabs-justified > .active > a:focus { + border-bottom-color: #fafafa; + } +} +.tab-content > .tab-pane { + display: none; + visibility: hidden; +} +.tab-content > .active { + display: block; + visibility: visible; +} +.nav-tabs .dropdown-menu { + margin-top: -1px; + border-top-right-radius: 0; + border-top-left-radius: 0; +} +.navbar { + position: relative; + min-height: 50px; + margin-bottom: 20px; + border: 1px solid transparent; +} +@media (min-width: 768px) { + .navbar { + border-radius: 4px; + } +} +@media (min-width: 768px) { + .navbar-header { + float: left; + } +} +.navbar-collapse { + overflow-x: visible; + padding-right: 15px; + padding-left: 15px; + border-top: 1px solid transparent; + box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.1); + -webkit-overflow-scrolling: touch; +} +.navbar-collapse.in { + overflow-y: auto; +} +@media (min-width: 768px) { + .navbar-collapse { + width: auto; + border-top: 0; + box-shadow: none; + } + .navbar-collapse.collapse { + display: block !important; + visibility: visible !important; + height: auto !important; + padding-bottom: 0; + overflow: visible !important; + } + .navbar-collapse.in { + overflow-y: visible; + } + .navbar-fixed-top .navbar-collapse, + .navbar-static-top .navbar-collapse, + .navbar-fixed-bottom .navbar-collapse { + padding-left: 0; + padding-right: 0; + } +} +.navbar-fixed-top .navbar-collapse, +.navbar-fixed-bottom .navbar-collapse { + max-height: 340px; +} +@media (max-device-width: 480px) and (orientation: landscape) { + .navbar-fixed-top .navbar-collapse, + .navbar-fixed-bottom .navbar-collapse { + max-height: 200px; + } +} +.container > .navbar-header, +.container-fluid > .navbar-header, +.container > .navbar-collapse, +.container-fluid > .navbar-collapse { + margin-right: -15px; + margin-left: -15px; +} +@media (min-width: 768px) { + .container > .navbar-header, + .container-fluid > .navbar-header, + .container > .navbar-collapse, + .container-fluid > .navbar-collapse { + margin-right: 0; + margin-left: 0; + } +} +.navbar-static-top { + z-index: 1000; + border-width: 0 0 1px; +} +@media (min-width: 768px) { + .navbar-static-top { + border-radius: 0; + } +} +.navbar-fixed-top, +.navbar-fixed-bottom { + position: fixed; + right: 0; + left: 0; + z-index: 1030; +} +@media (min-width: 768px) { + .navbar-fixed-top, + .navbar-fixed-bottom { + border-radius: 0; + } +} +.navbar-fixed-top { + top: 0; + border-width: 0 0 1px; +} +.navbar-fixed-bottom { + bottom: 0; + margin-bottom: 0; + border-width: 1px 0 0; +} +.navbar-brand { + float: left; + padding: 15px 15px; + font-size: 18px; + line-height: 20px; + height: 50px; +} +.navbar-brand:hover, +.navbar-brand:focus { + text-decoration: none; +} +.navbar-brand > img { + display: block; +} +@media (min-width: 768px) { + .navbar > .container .navbar-brand, + .navbar > .container-fluid .navbar-brand { + margin-left: -15px; + } +} +.navbar-toggle { + position: relative; + float: right; + margin-right: 15px; + padding: 9px 10px; + margin-top: 8px; + margin-bottom: 8px; + background-color: transparent; + background-image: none; + border: 1px solid transparent; + border-radius: 4px; +} +.navbar-toggle:focus { + outline: 0; +} +.navbar-toggle .icon-bar { + display: block; + width: 22px; + height: 2px; + border-radius: 1px; +} +.navbar-toggle .icon-bar + .icon-bar { + margin-top: 4px; +} +@media (min-width: 768px) { + .navbar-toggle { + display: none; + } +} +.navbar-nav { + margin: 7.5px -15px; +} +.navbar-nav > li > a { + padding-top: 10px; + padding-bottom: 10px; + line-height: 20px; +} +@media (max-width: 767px) { + .navbar-nav .open .dropdown-menu { + position: static; + float: none; + width: auto; + margin-top: 0; + background-color: transparent; + border: 0; + box-shadow: none; + } + .navbar-nav .open .dropdown-menu > li > a, + .navbar-nav .open .dropdown-menu .dropdown-header { + padding: 5px 15px 5px 25px; + } + .navbar-nav .open .dropdown-menu > li > a { + line-height: 20px; + } + .navbar-nav .open .dropdown-menu > li > a:hover, + .navbar-nav .open .dropdown-menu > li > a:focus { + background-image: none; + } +} +@media (min-width: 768px) { + .navbar-nav { + float: left; + margin: 0; + } + .navbar-nav > li { + float: left; + } + .navbar-nav > li > a { + padding-top: 15px; + padding-bottom: 15px; + } +} +.navbar-form { + margin-left: -15px; + margin-right: -15px; + padding: 10px 15px; + border-top: 1px solid transparent; + border-bottom: 1px solid transparent; + -webkit-box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.1), 0 1px 0 rgba(255, 255, 255, 0.1); + box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.1), 0 1px 0 rgba(255, 255, 255, 0.1); + margin-top: 8px; + margin-bottom: 8px; +} +@media (min-width: 768px) { + .navbar-form .form-group { + display: inline-block; + margin-bottom: 0; + vertical-align: middle; + } + .navbar-form .form-control { + display: inline-block; + width: auto; + vertical-align: middle; + } + .navbar-form .form-control-static { + display: inline-block; + } + .navbar-form .input-group { + display: inline-table; + vertical-align: middle; + } + .navbar-form .input-group .input-group-addon, + .navbar-form .input-group .input-group-btn, + .navbar-form .input-group .form-control { + width: auto; + } + .navbar-form .input-group > .form-control { + width: 100%; + } + .navbar-form .control-label { + margin-bottom: 0; + vertical-align: middle; + } + .navbar-form .radio, + .navbar-form .checkbox { + display: inline-block; + margin-top: 0; + margin-bottom: 0; + vertical-align: middle; + } + .navbar-form .radio label, + .navbar-form .checkbox label { + padding-left: 0; + } + .navbar-form .radio input[type="radio"], + .navbar-form .checkbox input[type="checkbox"] { + position: relative; + margin-left: 0; + } + .navbar-form .has-feedback .form-control-feedback { + top: 0; + } +} +@media (max-width: 767px) { + .navbar-form .form-group { + margin-bottom: 5px; + } + .navbar-form .form-group:last-child { + margin-bottom: 0; + } +} +@media (min-width: 768px) { + .navbar-form { + width: auto; + border: 0; + margin-left: 0; + margin-right: 0; + padding-top: 0; + padding-bottom: 0; + -webkit-box-shadow: none; + box-shadow: none; + } +} +.navbar-nav > li > .dropdown-menu { + margin-top: 0; + border-top-right-radius: 0; + border-top-left-radius: 0; +} +.navbar-fixed-bottom .navbar-nav > li > .dropdown-menu { + border-top-right-radius: 4px; + border-top-left-radius: 4px; + border-bottom-right-radius: 0; + border-bottom-left-radius: 0; +} +.navbar-btn { + margin-top: 8px; + margin-bottom: 8px; +} +.navbar-btn.btn-sm { + margin-top: 10px; + margin-bottom: 10px; +} +.navbar-btn.btn-xs { + margin-top: 14px; + margin-bottom: 14px; +} +.navbar-text { + margin-top: 15px; + margin-bottom: 15px; +} +@media (min-width: 768px) { + .navbar-text { + float: left; + margin-left: 15px; + margin-right: 15px; + } +} +@media (min-width: 768px) { + .navbar-left { + float: left !important; + } + .navbar-right { + float: right !important; + margin-right: -15px; + } + .navbar-right ~ .navbar-right { + margin-right: 0; + } +} +.navbar-default { + background-color: #ffb400; + border-color: #de9d00; +} +.navbar-default .navbar-brand { + color: #007a87; +} +.navbar-default .navbar-brand:hover, +.navbar-default .navbar-brand:focus { + color: #004c54; + background-color: transparent; +} +.navbar-default .navbar-text { + color: #777777; +} +.navbar-default .navbar-nav > li > a { + color: #007a87; +} +.navbar-default .navbar-nav > li > a:hover, +.navbar-default .navbar-nav > li > a:focus { + color: #333333; + background-color: transparent; +} +.navbar-default .navbar-nav > .active > a, +.navbar-default .navbar-nav > .active > a:hover, +.navbar-default .navbar-nav > .active > a:focus { + color: #555555; + background-color: #de9d00; +} +.navbar-default .navbar-nav > .disabled > a, +.navbar-default .navbar-nav > .disabled > a:hover, +.navbar-default .navbar-nav > .disabled > a:focus { + color: #cccccc; + background-color: transparent; +} +.navbar-default .navbar-toggle { + border-color: #dddddd; +} +.navbar-default .navbar-toggle:hover, +.navbar-default .navbar-toggle:focus { + background-color: #dddddd; +} +.navbar-default .navbar-toggle .icon-bar { + background-color: #888888; +} +.navbar-default .navbar-collapse, +.navbar-default .navbar-form { + border-color: #de9d00; +} +.navbar-default .navbar-nav > .open > a, +.navbar-default .navbar-nav > .open > a:hover, +.navbar-default .navbar-nav > .open > a:focus { + background-color: #de9d00; + color: #555555; +} +@media (max-width: 767px) { + .navbar-default .navbar-nav .open .dropdown-menu > li > a { + color: #007a87; + } + .navbar-default .navbar-nav .open .dropdown-menu > li > a:hover, + .navbar-default .navbar-nav .open .dropdown-menu > li > a:focus { + color: #333333; + background-color: transparent; + } + .navbar-default .navbar-nav .open .dropdown-menu > .active > a, + .navbar-default .navbar-nav .open .dropdown-menu > .active > a:hover, + .navbar-default .navbar-nav .open .dropdown-menu > .active > a:focus { + color: #555555; + background-color: #de9d00; + } + .navbar-default .navbar-nav .open .dropdown-menu > .disabled > a, + .navbar-default .navbar-nav .open .dropdown-menu > .disabled > a:hover, + .navbar-default .navbar-nav .open .dropdown-menu > .disabled > a:focus { + color: #cccccc; + background-color: transparent; + } +} +.navbar-default .navbar-link { + color: #007a87; +} +.navbar-default .navbar-link:hover { + color: #333333; +} +.navbar-default .btn-link { + color: #007a87; +} +.navbar-default .btn-link:hover, +.navbar-default .btn-link:focus { + color: #333333; +} +.navbar-default .btn-link[disabled]:hover, +fieldset[disabled] .navbar-default .btn-link:hover, +.navbar-default .btn-link[disabled]:focus, +fieldset[disabled] .navbar-default .btn-link:focus { + color: #cccccc; +} +.navbar-inverse { + background-color: #007a87; + border-color: #004c54; + background-image: none; +} +.navbar-inverse .navbar-nav>.active>a, .navbar-inverse .navbar-nav>.open>a { + background-image: none; +} +.dropdown-menu>.active>a, ee.dropdown-menu>.active>a:focus, .dropdown-menu>.active>a:hover { + background-image: none; +} +.navbar-inverse .navbar-brand { + color: #dddddd; +} +.navbar-inverse .navbar-brand:hover, +.navbar-inverse .navbar-brand:focus { + color: #ffffff; + background-color: transparent; +} +.navbar-inverse .navbar-text { + color: #ffb400; +} +.navbar-inverse .navbar-nav > li > a { + color: #dddddd; +} +.navbar-inverse .navbar-nav > li > a:hover, +.navbar-inverse .navbar-nav > li > a:focus { + color: #ffffff; + background-color: transparent; +} +.navbar-inverse .navbar-nav > .active > a, +.navbar-inverse .navbar-nav > .active > a:hover, +.navbar-inverse .navbar-nav > .active > a:focus { + color: #ffffff; + background-color: #004c54; +} +.navbar-inverse .navbar-nav > .disabled > a, +.navbar-inverse .navbar-nav > .disabled > a:hover, +.navbar-inverse .navbar-nav > .disabled > a:focus { + color: #444444; + background-color: transparent; +} +.navbar-inverse .navbar-toggle { + border-color: #333333; +} +.navbar-inverse .navbar-toggle:hover, +.navbar-inverse .navbar-toggle:focus { + background-color: #333333; +} +.navbar-inverse .navbar-toggle .icon-bar { + background-color: #ffffff; +} +.navbar-inverse .navbar-collapse, +.navbar-inverse .navbar-form { + border-color: #005a63; +} +.navbar-inverse .navbar-nav > .open > a, +.navbar-inverse .navbar-nav > .open > a:hover, +.navbar-inverse .navbar-nav > .open > a:focus { + background-color: #004c54; + color: #ffffff; +} +@media (max-width: 767px) { + .navbar-inverse .navbar-nav .open .dropdown-menu > .dropdown-header { + border-color: #004c54; + } + .navbar-inverse .navbar-nav .open .dropdown-menu .divider { + background-color: #004c54; + } + .navbar-inverse .navbar-nav .open .dropdown-menu > li > a { + color: #dddddd; + } + .navbar-inverse .navbar-nav .open .dropdown-menu > li > a:hover, + .navbar-inverse .navbar-nav .open .dropdown-menu > li > a:focus { + color: #ffffff; + background-color: transparent; + } + .navbar-inverse .navbar-nav .open .dropdown-menu > .active > a, + .navbar-inverse .navbar-nav .open .dropdown-menu > .active > a:hover, + .navbar-inverse .navbar-nav .open .dropdown-menu > .active > a:focus { + color: #ffffff; + background-color: #004c54; + } + .navbar-inverse .navbar-nav .open .dropdown-menu > .disabled > a, + .navbar-inverse .navbar-nav .open .dropdown-menu > .disabled > a:hover, + .navbar-inverse .navbar-nav .open .dropdown-menu > .disabled > a:focus { + color: #444444; + background-color: transparent; + } +} +.navbar-inverse .navbar-link { + color: #dddddd; +} +.navbar-inverse .navbar-link:hover { + color: #ffffff; +} +.navbar-inverse .btn-link { + color: #dddddd; +} +.navbar-inverse .btn-link:hover, +.navbar-inverse .btn-link:focus { + color: #ffffff; +} +.navbar-inverse .btn-link[disabled]:hover, +fieldset[disabled] .navbar-inverse .btn-link:hover, +.navbar-inverse .btn-link[disabled]:focus, +fieldset[disabled] .navbar-inverse .btn-link:focus { + color: #444444; +} +.breadcrumb { + padding: 8px 15px; + margin-bottom: 20px; + list-style: none; + background-color: #f5f5f5; + border-radius: 4px; +} +.breadcrumb > li { + display: inline-block; +} +.breadcrumb > li + li:before { + content: "/\00a0"; + padding: 0 5px; + color: #cccccc; +} +.breadcrumb > .active { + color: #777777; +} +.pagination { + display: inline-block; + padding-left: 0; + margin: 20px 0; + border-radius: 4px; +} +.pagination > li { + display: inline; +} +.pagination > li > a, +.pagination > li > span { + position: relative; + float: left; + padding: 6px 12px; + line-height: 1.428571429; + text-decoration: none; + color: #0091a1; + background-color: #ffffff; + border: 1px solid #dddddd; + margin-left: -1px; +} +.pagination > li:first-child > a, +.pagination > li:first-child > span { + margin-left: 0; + border-bottom-left-radius: 4px; + border-top-left-radius: 4px; +} +.pagination > li:last-child > a, +.pagination > li:last-child > span { + border-bottom-right-radius: 4px; + border-top-right-radius: 4px; +} +.pagination > li > a:hover, +.pagination > li > span:hover, +.pagination > li > a:focus, +.pagination > li > span:focus { + color: #004c54; + background-color: #eeeeee; + border-color: #dddddd; +} +.pagination > .active > a, +.pagination > .active > span, +.pagination > .active > a:hover, +.pagination > .active > span:hover, +.pagination > .active > a:focus, +.pagination > .active > span:focus { + z-index: 2; + color: #ffffff; + background-color: #005c66; + border-color: #005c66; + cursor: default; +} +.pagination > .disabled > span, +.pagination > .disabled > span:hover, +.pagination > .disabled > span:focus, +.pagination > .disabled > a, +.pagination > .disabled > a:hover, +.pagination > .disabled > a:focus { + color: #777777; + background-color: #ffffff; + border-color: #dddddd; + cursor: not-allowed; +} +.pagination-lg > li > a, +.pagination-lg > li > span { + padding: 10px 16px; + font-size: 18px; +} +.pagination-lg > li:first-child > a, +.pagination-lg > li:first-child > span { + border-bottom-left-radius: 6px; + border-top-left-radius: 6px; +} +.pagination-lg > li:last-child > a, +.pagination-lg > li:last-child > span { + border-bottom-right-radius: 6px; + border-top-right-radius: 6px; +} +.pagination-sm > li > a, +.pagination-sm > li > span { + padding: 5px 10px; + font-size: 12px; +} +.pagination-sm > li:first-child > a, +.pagination-sm > li:first-child > span { + border-bottom-left-radius: 3px; + border-top-left-radius: 3px; +} +.pagination-sm > li:last-child > a, +.pagination-sm > li:last-child > span { + border-bottom-right-radius: 3px; + border-top-right-radius: 3px; +} +.pager { + padding-left: 0; + margin: 20px 0; + list-style: none; + text-align: center; +} +.pager li { + display: inline; +} +.pager li > a, +.pager li > span { + display: inline-block; + padding: 5px 14px; + background-color: #ffffff; + border: 1px solid #dddddd; + border-radius: 15px; +} +.pager li > a:hover, +.pager li > a:focus { + text-decoration: none; + background-color: #eeeeee; +} +.pager .next > a, +.pager .next > span { + float: right; +} +.pager .previous > a, +.pager .previous > span { + float: left; +} +.pager .disabled > a, +.pager .disabled > a:hover, +.pager .disabled > a:focus, +.pager .disabled > span { + color: #777777; + background-color: #ffffff; + cursor: not-allowed; +} +.label { + display: inline; + padding: .2em .6em .3em; + font-size: 75%; + font-weight: bold; + line-height: 1; + color: #ffffff; + text-align: center; + white-space: nowrap; + vertical-align: baseline; + border-radius: .25em; +} +a.label:hover, +a.label:focus { + color: #ffffff; + text-decoration: none; + cursor: pointer; +} +.label:empty { + display: none; +} +.btn .label { + position: relative; + top: -1px; +} +.label-default { + background-color: #777777; +} +.label-default[href]:hover, +.label-default[href]:focus { + background-color: #5e5e5e; +} +.label-primary { + background-color: #005c66; +} +.label-primary[href]:hover, +.label-primary[href]:focus { + background-color: #002e33; +} +.label-success { + background-color: #3fb34f; +} +.label-success[href]:hover, +.label-success[href]:focus { + background-color: #328d3e; +} +.label-info { + background-color: #00d1c1; +} +.label-info[href]:hover, +.label-info[href]:focus { + background-color: #009e92; +} +.label-warning { + background-color: #ffb400; +} +.label-warning[href]:hover, +.label-warning[href]:focus { + background-color: #cc9000; +} +.label-danger { + background-color: #ff5a5f; +} +.label-danger[href]:hover, +.label-danger[href]:focus { + background-color: #ff272e; +} +.badge { + display: inline-block; + min-width: 10px; + padding: 3px 7px; + font-size: 12px; + font-weight: bold; + color: #ffffff; + line-height: 1; + vertical-align: baseline; + white-space: nowrap; + text-align: center; + background-color: #777777; + border-radius: 10px; +} +.badge:empty { + display: none; +} +.btn .badge { + position: relative; + top: -1px; +} +.btn-xs .badge { + top: 0; + padding: 1px 5px; +} +a.badge:hover, +a.badge:focus { + color: #ffffff; + text-decoration: none; + cursor: pointer; +} +.list-group-item.active > .badge, +.nav-pills > .active > a > .badge { + color: #0091a1; + background-color: #ffffff; +} +.list-group-item > .badge { + float: right; +} +.list-group-item > .badge + .badge { + margin-right: 5px; +} +.nav-pills > li > a > .badge { + margin-left: 3px; +} +.jumbotron { + padding: 30px 15px; + margin-bottom: 30px; + color: inherit; + background-color: #eeeeee; +} +.jumbotron h1, +.jumbotron .h1 { + color: inherit; +} +.jumbotron p { + margin-bottom: 15px; + font-size: 21px; + font-weight: 200; +} +.jumbotron > hr { + border-top-color: #d5d5d5; +} +.container .jumbotron, +.container-fluid .jumbotron { + border-radius: 6px; +} +.jumbotron .container { + max-width: 100%; +} +@media screen and (min-width: 768px) { + .jumbotron { + padding: 48px 0; + } + .container .jumbotron, + .container-fluid .jumbotron { + padding-left: 60px; + padding-right: 60px; + } + .jumbotron h1, + .jumbotron .h1 { + font-size: 63px; + } +} +.thumbnail { + display: block; + padding: 4px; + margin-bottom: 20px; + line-height: 1.428571429; + background-color: #fafafa; + border: 1px solid #dddddd; + border-radius: 4px; + -webkit-transition: border 0.2s ease-in-out; + -o-transition: border 0.2s ease-in-out; + transition: border 0.2s ease-in-out; +} +.thumbnail > img, +.thumbnail a > img { + margin-left: auto; + margin-right: auto; +} +a.thumbnail:hover, +a.thumbnail:focus, +a.thumbnail.active { + border-color: #0091a1; +} +.thumbnail .caption { + padding: 9px; + color: #333333; +} +.alert { + padding: 15px; + margin-bottom: 20px; + border: 1px solid transparent; + border-radius: 4px; +} +.alert h4 { + margin-top: 0; + color: inherit; +} +.alert .alert-link { + font-weight: bold; +} +.alert > p, +.alert > ul { + margin-bottom: 0; +} +.alert > p + p { + margin-top: 5px; +} +.alert-dismissable, +.alert-dismissible { + padding-right: 35px; +} +.alert-dismissable .close, +.alert-dismissible .close { + position: relative; + top: -2px; + right: -21px; + color: inherit; +} +.alert-success { + background-color: #dff0d8; + border-color: #d6e9c6; + color: #3c763d; +} +.alert-success hr { + border-top-color: #c9e2b3; +} +.alert-success .alert-link { + color: #2b542c; +} +.alert-info { + background-color: #d9edf7; + border-color: #bce8f1; + color: #31708f; +} +.alert-info hr { + border-top-color: #a6e1ec; +} +.alert-info .alert-link { + color: #245269; +} +.alert-warning { + background-color: #fcf8e3; + border-color: #faebcc; + color: #8a6d3b; +} +.alert-warning hr { + border-top-color: #f7e1b5; +} +.alert-warning .alert-link { + color: #66512c; +} +.alert-danger { + background-color: #f2dede; + border-color: #ebccd1; + color: #a94442; +} +.alert-danger hr { + border-top-color: #e4b9c0; +} +.alert-danger .alert-link { + color: #843534; +} +@-webkit-keyframes progress-bar-stripes { + from { + background-position: 40px 0; + } + to { + background-position: 0 0; + } +} +@keyframes progress-bar-stripes { + from { + background-position: 40px 0; + } + to { + background-position: 0 0; + } +} +.progress { + overflow: hidden; + height: 20px; + margin-bottom: 20px; + background-color: #f5f5f5; + border-radius: 4px; + -webkit-box-shadow: inset 0 1px 2px rgba(0, 0, 0, 0.1); + box-shadow: inset 0 1px 2px rgba(0, 0, 0, 0.1); +} +.progress-bar { + float: left; + width: 0%; + height: 100%; + font-size: 12px; + line-height: 20px; + color: #ffffff; + text-align: center; + background-color: #005c66; + -webkit-box-shadow: inset 0 -1px 0 rgba(0, 0, 0, 0.15); + box-shadow: inset 0 -1px 0 rgba(0, 0, 0, 0.15); + -webkit-transition: width 0.6s ease; + -o-transition: width 0.6s ease; + transition: width 0.6s ease; +} +.progress-striped .progress-bar, +.progress-bar-striped { + background-image: -webkit-linear-gradient(45deg, rgba(255, 255, 255, 0.15) 25%, transparent 25%, transparent 50%, rgba(255, 255, 255, 0.15) 50%, rgba(255, 255, 255, 0.15) 75%, transparent 75%, transparent); + background-image: -o-linear-gradient(45deg, rgba(255, 255, 255, 0.15) 25%, transparent 25%, transparent 50%, rgba(255, 255, 255, 0.15) 50%, rgba(255, 255, 255, 0.15) 75%, transparent 75%, transparent); + background-image: linear-gradient(45deg, rgba(255, 255, 255, 0.15) 25%, transparent 25%, transparent 50%, rgba(255, 255, 255, 0.15) 50%, rgba(255, 255, 255, 0.15) 75%, transparent 75%, transparent); + background-size: 40px 40px; +} +.progress.active .progress-bar, +.progress-bar.active { + -webkit-animation: progress-bar-stripes 2s linear infinite; + -o-animation: progress-bar-stripes 2s linear infinite; + animation: progress-bar-stripes 2s linear infinite; +} +.progress-bar-success { + background-color: #3fb34f; +} +.progress-striped .progress-bar-success { + background-image: -webkit-linear-gradient(45deg, rgba(255, 255, 255, 0.15) 25%, transparent 25%, transparent 50%, rgba(255, 255, 255, 0.15) 50%, rgba(255, 255, 255, 0.15) 75%, transparent 75%, transparent); + background-image: -o-linear-gradient(45deg, rgba(255, 255, 255, 0.15) 25%, transparent 25%, transparent 50%, rgba(255, 255, 255, 0.15) 50%, rgba(255, 255, 255, 0.15) 75%, transparent 75%, transparent); + background-image: linear-gradient(45deg, rgba(255, 255, 255, 0.15) 25%, transparent 25%, transparent 50%, rgba(255, 255, 255, 0.15) 50%, rgba(255, 255, 255, 0.15) 75%, transparent 75%, transparent); +} +.progress-bar-info { + background-color: #00d1c1; +} +.progress-striped .progress-bar-info { + background-image: -webkit-linear-gradient(45deg, rgba(255, 255, 255, 0.15) 25%, transparent 25%, transparent 50%, rgba(255, 255, 255, 0.15) 50%, rgba(255, 255, 255, 0.15) 75%, transparent 75%, transparent); + background-image: -o-linear-gradient(45deg, rgba(255, 255, 255, 0.15) 25%, transparent 25%, transparent 50%, rgba(255, 255, 255, 0.15) 50%, rgba(255, 255, 255, 0.15) 75%, transparent 75%, transparent); + background-image: linear-gradient(45deg, rgba(255, 255, 255, 0.15) 25%, transparent 25%, transparent 50%, rgba(255, 255, 255, 0.15) 50%, rgba(255, 255, 255, 0.15) 75%, transparent 75%, transparent); +} +.progress-bar-warning { + background-color: #ffb400; +} +.progress-striped .progress-bar-warning { + background-image: -webkit-linear-gradient(45deg, rgba(255, 255, 255, 0.15) 25%, transparent 25%, transparent 50%, rgba(255, 255, 255, 0.15) 50%, rgba(255, 255, 255, 0.15) 75%, transparent 75%, transparent); + background-image: -o-linear-gradient(45deg, rgba(255, 255, 255, 0.15) 25%, transparent 25%, transparent 50%, rgba(255, 255, 255, 0.15) 50%, rgba(255, 255, 255, 0.15) 75%, transparent 75%, transparent); + background-image: linear-gradient(45deg, rgba(255, 255, 255, 0.15) 25%, transparent 25%, transparent 50%, rgba(255, 255, 255, 0.15) 50%, rgba(255, 255, 255, 0.15) 75%, transparent 75%, transparent); +} +.progress-bar-danger { + background-color: #ff5a5f; +} +.progress-striped .progress-bar-danger { + background-image: -webkit-linear-gradient(45deg, rgba(255, 255, 255, 0.15) 25%, transparent 25%, transparent 50%, rgba(255, 255, 255, 0.15) 50%, rgba(255, 255, 255, 0.15) 75%, transparent 75%, transparent); + background-image: -o-linear-gradient(45deg, rgba(255, 255, 255, 0.15) 25%, transparent 25%, transparent 50%, rgba(255, 255, 255, 0.15) 50%, rgba(255, 255, 255, 0.15) 75%, transparent 75%, transparent); + background-image: linear-gradient(45deg, rgba(255, 255, 255, 0.15) 25%, transparent 25%, transparent 50%, rgba(255, 255, 255, 0.15) 50%, rgba(255, 255, 255, 0.15) 75%, transparent 75%, transparent); +} +.media { + margin-top: 15px; +} +.media:first-child { + margin-top: 0; +} +.media, +.media-body { + zoom: 1; + overflow: hidden; +} +.media-object { + display: block; +} +.media-right, +.media > .pull-right { + padding-left: 10px; +} +.media-left, +.media > .pull-left { + padding-right: 10px; +} +.media-left, +.media-right, +.media-body { + display: table-cell; + vertical-align: top; +} +.media-middle { + vertical-align: middle; +} +.media-bottom { + vertical-align: bottom; +} +.media-heading { + margin-top: 0; + margin-bottom: 5px; +} +.media-list { + padding-left: 0; + list-style: none; +} +.list-group { + margin-bottom: 20px; + padding-left: 0; +} +.list-group-item { + position: relative; + display: block; + padding: 10px 15px; + margin-bottom: -1px; + background-color: #ffffff; + border: 1px solid #dddddd; +} +.list-group-item:first-child { + border-top-right-radius: 4px; + border-top-left-radius: 4px; +} +.list-group-item:last-child { + margin-bottom: 0; + border-bottom-right-radius: 4px; + border-bottom-left-radius: 4px; +} +a.list-group-item { + color: #555555; +} +a.list-group-item .list-group-item-heading { + color: #333333; +} +a.list-group-item:hover, +a.list-group-item:focus { + text-decoration: none; + color: #555555; + background-color: #f5f5f5; +} +.list-group-item.disabled, +.list-group-item.disabled:hover, +.list-group-item.disabled:focus { + background-color: #eeeeee; + color: #777777; + cursor: not-allowed; +} +.list-group-item.disabled .list-group-item-heading, +.list-group-item.disabled:hover .list-group-item-heading, +.list-group-item.disabled:focus .list-group-item-heading { + color: inherit; +} +.list-group-item.disabled .list-group-item-text, +.list-group-item.disabled:hover .list-group-item-text, +.list-group-item.disabled:focus .list-group-item-text { + color: #777777; +} +.list-group-item.active, +.list-group-item.active:hover, +.list-group-item.active:focus { + z-index: 2; + color: #ffffff; + background-color: #005c66; + border-color: #005c66; +} +.list-group-item.active .list-group-item-heading, +.list-group-item.active:hover .list-group-item-heading, +.list-group-item.active:focus .list-group-item-heading, +.list-group-item.active .list-group-item-heading > small, +.list-group-item.active:hover .list-group-item-heading > small, +.list-group-item.active:focus .list-group-item-heading > small, +.list-group-item.active .list-group-item-heading > .small, +.list-group-item.active:hover .list-group-item-heading > .small, +.list-group-item.active:focus .list-group-item-heading > .small { + color: inherit; +} +.list-group-item.active .list-group-item-text, +.list-group-item.active:hover .list-group-item-text, +.list-group-item.active:focus .list-group-item-text { + color: #33ebff; +} +.list-group-item-success { + color: #3c763d; + background-color: #dff0d8; +} +a.list-group-item-success { + color: #3c763d; +} +a.list-group-item-success .list-group-item-heading { + color: inherit; +} +a.list-group-item-success:hover, +a.list-group-item-success:focus { + color: #3c763d; + background-color: #d0e9c6; +} +a.list-group-item-success.active, +a.list-group-item-success.active:hover, +a.list-group-item-success.active:focus { + color: #fff; + background-color: #3c763d; + border-color: #3c763d; +} +.list-group-item-info { + color: #31708f; + background-color: #d9edf7; +} +a.list-group-item-info { + color: #31708f; +} +a.list-group-item-info .list-group-item-heading { + color: inherit; +} +a.list-group-item-info:hover, +a.list-group-item-info:focus { + color: #31708f; + background-color: #c4e3f3; +} +a.list-group-item-info.active, +a.list-group-item-info.active:hover, +a.list-group-item-info.active:focus { + color: #fff; + background-color: #31708f; + border-color: #31708f; +} +.list-group-item-warning { + color: #8a6d3b; + background-color: #fcf8e3; +} +a.list-group-item-warning { + color: #8a6d3b; +} +a.list-group-item-warning .list-group-item-heading { + color: inherit; +} +a.list-group-item-warning:hover, +a.list-group-item-warning:focus { + color: #8a6d3b; + background-color: #faf2cc; +} +a.list-group-item-warning.active, +a.list-group-item-warning.active:hover, +a.list-group-item-warning.active:focus { + color: #fff; + background-color: #8a6d3b; + border-color: #8a6d3b; +} +.list-group-item-danger { + color: #a94442; + background-color: #f2dede; +} +a.list-group-item-danger { + color: #a94442; +} +a.list-group-item-danger .list-group-item-heading { + color: inherit; +} +a.list-group-item-danger:hover, +a.list-group-item-danger:focus { + color: #a94442; + background-color: #ebcccc; +} +a.list-group-item-danger.active, +a.list-group-item-danger.active:hover, +a.list-group-item-danger.active:focus { + color: #fff; + background-color: #a94442; + border-color: #a94442; +} +.list-group-item-heading { + margin-top: 0; + margin-bottom: 5px; +} +.list-group-item-text { + margin-bottom: 0; + line-height: 1.3; +} +.panel { + margin-bottom: 20px; + background-color: #ffffff; + border: 1px solid transparent; + border-radius: 4px; + -webkit-box-shadow: 0 1px 1px rgba(0, 0, 0, 0.05); + box-shadow: 0 1px 1px rgba(0, 0, 0, 0.05); +} +.panel-body { + padding: 15px; +} +.panel-heading { + padding: 10px 15px; + border-bottom: 1px solid transparent; + border-top-right-radius: 3px; + border-top-left-radius: 3px; +} +.panel-heading > .dropdown .dropdown-toggle { + color: inherit; +} +.panel-title { + margin-top: 0; + margin-bottom: 0; + font-size: 16px; + color: inherit; +} +.panel-title > a { + color: inherit; +} +.panel-footer { + padding: 10px 15px; + background-color: #f5f5f5; + border-top: 1px solid #dddddd; + border-bottom-right-radius: 3px; + border-bottom-left-radius: 3px; +} +.panel > .list-group, +.panel > .panel-collapse > .list-group { + margin-bottom: 0; +} +.panel > .list-group .list-group-item, +.panel > .panel-collapse > .list-group .list-group-item { + border-width: 1px 0; + border-radius: 0; +} +.panel > .list-group:first-child .list-group-item:first-child, +.panel > .panel-collapse > .list-group:first-child .list-group-item:first-child { + border-top: 0; + border-top-right-radius: 3px; + border-top-left-radius: 3px; +} +.panel > .list-group:last-child .list-group-item:last-child, +.panel > .panel-collapse > .list-group:last-child .list-group-item:last-child { + border-bottom: 0; + border-bottom-right-radius: 3px; + border-bottom-left-radius: 3px; +} +.panel-heading + .list-group .list-group-item:first-child { + border-top-width: 0; +} +.list-group + .panel-footer { + border-top-width: 0; +} +.panel > .table, +.panel > .table-responsive > .table, +.panel > .panel-collapse > .table { + margin-bottom: 0; +} +.panel > .table caption, +.panel > .table-responsive > .table caption, +.panel > .panel-collapse > .table caption { + padding-left: 15px; + padding-right: 15px; +} +.panel > .table:first-child, +.panel > .table-responsive:first-child > .table:first-child { + border-top-right-radius: 3px; + border-top-left-radius: 3px; +} +.panel > .table:first-child > thead:first-child > tr:first-child, +.panel > .table-responsive:first-child > .table:first-child > thead:first-child > tr:first-child, +.panel > .table:first-child > tbody:first-child > tr:first-child, +.panel > .table-responsive:first-child > .table:first-child > tbody:first-child > tr:first-child { + border-top-left-radius: 3px; + border-top-right-radius: 3px; +} +.panel > .table:first-child > thead:first-child > tr:first-child td:first-child, +.panel > .table-responsive:first-child > .table:first-child > thead:first-child > tr:first-child td:first-child, +.panel > .table:first-child > tbody:first-child > tr:first-child td:first-child, +.panel > .table-responsive:first-child > .table:first-child > tbody:first-child > tr:first-child td:first-child, +.panel > .table:first-child > thead:first-child > tr:first-child th:first-child, +.panel > .table-responsive:first-child > .table:first-child > thead:first-child > tr:first-child th:first-child, +.panel > .table:first-child > tbody:first-child > tr:first-child th:first-child, +.panel > .table-responsive:first-child > .table:first-child > tbody:first-child > tr:first-child th:first-child { + border-top-left-radius: 3px; +} +.panel > .table:first-child > thead:first-child > tr:first-child td:last-child, +.panel > .table-responsive:first-child > .table:first-child > thead:first-child > tr:first-child td:last-child, +.panel > .table:first-child > tbody:first-child > tr:first-child td:last-child, +.panel > .table-responsive:first-child > .table:first-child > tbody:first-child > tr:first-child td:last-child, +.panel > .table:first-child > thead:first-child > tr:first-child th:last-child, +.panel > .table-responsive:first-child > .table:first-child > thead:first-child > tr:first-child th:last-child, +.panel > .table:first-child > tbody:first-child > tr:first-child th:last-child, +.panel > .table-responsive:first-child > .table:first-child > tbody:first-child > tr:first-child th:last-child { + border-top-right-radius: 3px; +} +.panel > .table:last-child, +.panel > .table-responsive:last-child > .table:last-child { + border-bottom-right-radius: 3px; + border-bottom-left-radius: 3px; +} +.panel > .table:last-child > tbody:last-child > tr:last-child, +.panel > .table-responsive:last-child > .table:last-child > tbody:last-child > tr:last-child, +.panel > .table:last-child > tfoot:last-child > tr:last-child, +.panel > .table-responsive:last-child > .table:last-child > tfoot:last-child > tr:last-child { + border-bottom-left-radius: 3px; + border-bottom-right-radius: 3px; +} +.panel > .table:last-child > tbody:last-child > tr:last-child td:first-child, +.panel > .table-responsive:last-child > .table:last-child > tbody:last-child > tr:last-child td:first-child, +.panel > .table:last-child > tfoot:last-child > tr:last-child td:first-child, +.panel > .table-responsive:last-child > .table:last-child > tfoot:last-child > tr:last-child td:first-child, +.panel > .table:last-child > tbody:last-child > tr:last-child th:first-child, +.panel > .table-responsive:last-child > .table:last-child > tbody:last-child > tr:last-child th:first-child, +.panel > .table:last-child > tfoot:last-child > tr:last-child th:first-child, +.panel > .table-responsive:last-child > .table:last-child > tfoot:last-child > tr:last-child th:first-child { + border-bottom-left-radius: 3px; +} +.panel > .table:last-child > tbody:last-child > tr:last-child td:last-child, +.panel > .table-responsive:last-child > .table:last-child > tbody:last-child > tr:last-child td:last-child, +.panel > .table:last-child > tfoot:last-child > tr:last-child td:last-child, +.panel > .table-responsive:last-child > .table:last-child > tfoot:last-child > tr:last-child td:last-child, +.panel > .table:last-child > tbody:last-child > tr:last-child th:last-child, +.panel > .table-responsive:last-child > .table:last-child > tbody:last-child > tr:last-child th:last-child, +.panel > .table:last-child > tfoot:last-child > tr:last-child th:last-child, +.panel > .table-responsive:last-child > .table:last-child > tfoot:last-child > tr:last-child th:last-child { + border-bottom-right-radius: 3px; +} +.panel > .panel-body + .table, +.panel > .panel-body + .table-responsive, +.panel > .table + .panel-body, +.panel > .table-responsive + .panel-body { + border-top: 1px solid #dddddd; +} +.panel > .table > tbody:first-child > tr:first-child th, +.panel > .table > tbody:first-child > tr:first-child td { + border-top: 0; +} +.panel > .table-bordered, +.panel > .table-responsive > .table-bordered { + border: 0; +} +.panel > .table-bordered > thead > tr > th:first-child, +.panel > .table-responsive > .table-bordered > thead > tr > th:first-child, +.panel > .table-bordered > tbody > tr > th:first-child, +.panel > .table-responsive > .table-bordered > tbody > tr > th:first-child, +.panel > .table-bordered > tfoot > tr > th:first-child, +.panel > .table-responsive > .table-bordered > tfoot > tr > th:first-child, +.panel > .table-bordered > thead > tr > td:first-child, +.panel > .table-responsive > .table-bordered > thead > tr > td:first-child, +.panel > .table-bordered > tbody > tr > td:first-child, +.panel > .table-responsive > .table-bordered > tbody > tr > td:first-child, +.panel > .table-bordered > tfoot > tr > td:first-child, +.panel > .table-responsive > .table-bordered > tfoot > tr > td:first-child { + border-left: 0; +} +.panel > .table-bordered > thead > tr > th:last-child, +.panel > .table-responsive > .table-bordered > thead > tr > th:last-child, +.panel > .table-bordered > tbody > tr > th:last-child, +.panel > .table-responsive > .table-bordered > tbody > tr > th:last-child, +.panel > .table-bordered > tfoot > tr > th:last-child, +.panel > .table-responsive > .table-bordered > tfoot > tr > th:last-child, +.panel > .table-bordered > thead > tr > td:last-child, +.panel > .table-responsive > .table-bordered > thead > tr > td:last-child, +.panel > .table-bordered > tbody > tr > td:last-child, +.panel > .table-responsive > .table-bordered > tbody > tr > td:last-child, +.panel > .table-bordered > tfoot > tr > td:last-child, +.panel > .table-responsive > .table-bordered > tfoot > tr > td:last-child { + border-right: 0; +} +.panel > .table-bordered > thead > tr:first-child > td, +.panel > .table-responsive > .table-bordered > thead > tr:first-child > td, +.panel > .table-bordered > tbody > tr:first-child > td, +.panel > .table-responsive > .table-bordered > tbody > tr:first-child > td, +.panel > .table-bordered > thead > tr:first-child > th, +.panel > .table-responsive > .table-bordered > thead > tr:first-child > th, +.panel > .table-bordered > tbody > tr:first-child > th, +.panel > .table-responsive > .table-bordered > tbody > tr:first-child > th { + border-bottom: 0; +} +.panel > .table-bordered > tbody > tr:last-child > td, +.panel > .table-responsive > .table-bordered > tbody > tr:last-child > td, +.panel > .table-bordered > tfoot > tr:last-child > td, +.panel > .table-responsive > .table-bordered > tfoot > tr:last-child > td, +.panel > .table-bordered > tbody > tr:last-child > th, +.panel > .table-responsive > .table-bordered > tbody > tr:last-child > th, +.panel > .table-bordered > tfoot > tr:last-child > th, +.panel > .table-responsive > .table-bordered > tfoot > tr:last-child > th { + border-bottom: 0; +} +.panel > .table-responsive { + border: 0; + margin-bottom: 0; +} +.panel-group { + margin-bottom: 20px; +} +.panel-group .panel { + margin-bottom: 0; + border-radius: 4px; +} +.panel-group .panel + .panel { + margin-top: 5px; +} +.panel-group .panel-heading { + border-bottom: 0; +} +.panel-group .panel-heading + .panel-collapse > .panel-body, +.panel-group .panel-heading + .panel-collapse > .list-group { + border-top: 1px solid #dddddd; +} +.panel-group .panel-footer { + border-top: 0; +} +.panel-group .panel-footer + .panel-collapse .panel-body { + border-bottom: 1px solid #dddddd; +} +.panel-default { + border-color: #dddddd; +} +.panel-default > .panel-heading { + color: #333333; + background-color: #f5f5f5; + border-color: #dddddd; +} +.panel-default > .panel-heading + .panel-collapse > .panel-body { + border-top-color: #dddddd; +} +.panel-default > .panel-heading .badge { + color: #f5f5f5; + background-color: #333333; +} +.panel-default > .panel-footer + .panel-collapse > .panel-body { + border-bottom-color: #dddddd; +} +.panel-primary { + border-color: #005c66; +} +.panel-primary > .panel-heading { + color: #ffffff; + background-color: #005c66; + border-color: #005c66; +} +.panel-primary > .panel-heading + .panel-collapse > .panel-body { + border-top-color: #005c66; +} +.panel-primary > .panel-heading .badge { + color: #005c66; + background-color: #ffffff; +} +.panel-primary > .panel-footer + .panel-collapse > .panel-body { + border-bottom-color: #005c66; +} +.panel-success { + border-color: #d6e9c6; +} +.panel-success > .panel-heading { + color: #3c763d; + background-color: #dff0d8; + border-color: #d6e9c6; +} +.panel-success > .panel-heading + .panel-collapse > .panel-body { + border-top-color: #d6e9c6; +} +.panel-success > .panel-heading .badge { + color: #dff0d8; + background-color: #3c763d; +} +.panel-success > .panel-footer + .panel-collapse > .panel-body { + border-bottom-color: #d6e9c6; +} +.panel-info { + border-color: #bce8f1; +} +.panel-info > .panel-heading { + color: #31708f; + background-color: #d9edf7; + border-color: #bce8f1; +} +.panel-info > .panel-heading + .panel-collapse > .panel-body { + border-top-color: #bce8f1; +} +.panel-info > .panel-heading .badge { + color: #d9edf7; + background-color: #31708f; +} +.panel-info > .panel-footer + .panel-collapse > .panel-body { + border-bottom-color: #bce8f1; +} +.panel-warning { + border-color: #faebcc; +} +.panel-warning > .panel-heading { + color: #8a6d3b; + background-color: #fcf8e3; + border-color: #faebcc; +} +.panel-warning > .panel-heading + .panel-collapse > .panel-body { + border-top-color: #faebcc; +} +.panel-warning > .panel-heading .badge { + color: #fcf8e3; + background-color: #8a6d3b; +} +.panel-warning > .panel-footer + .panel-collapse > .panel-body { + border-bottom-color: #faebcc; +} +.panel-danger { + border-color: #ebccd1; +} +.panel-danger > .panel-heading { + color: #a94442; + background-color: #f2dede; + border-color: #ebccd1; +} +.panel-danger > .panel-heading + .panel-collapse > .panel-body { + border-top-color: #ebccd1; +} +.panel-danger > .panel-heading .badge { + color: #f2dede; + background-color: #a94442; +} +.panel-danger > .panel-footer + .panel-collapse > .panel-body { + border-bottom-color: #ebccd1; +} +.well { + min-height: 20px; + padding: 19px; + margin-bottom: 20px; + background-color: #f5f5f5; + border: 1px solid #e3e3e3; + border-radius: 4px; + -webkit-box-shadow: inset 0 1px 1px rgba(0, 0, 0, 0.05); + box-shadow: inset 0 1px 1px rgba(0, 0, 0, 0.05); +} +.well blockquote { + border-color: #ddd; + border-color: rgba(0, 0, 0, 0.15); +} +.well-lg { + padding: 24px; + border-radius: 6px; +} +.well-sm { + padding: 9px; + border-radius: 3px; +} +.close { + float: right; + font-size: 21px; + font-weight: bold; + line-height: 1; + color: #000000; + text-shadow: 0 1px 0 #ffffff; + opacity: 0.2; + filter: alpha(opacity=20); +} +.close:hover, +.close:focus { + color: #000000; + text-decoration: none; + cursor: pointer; + opacity: 0.5; + filter: alpha(opacity=50); +} +button.close { + padding: 0; + cursor: pointer; + background: transparent; + border: 0; + -webkit-appearance: none; +} +.modal-open { + overflow: hidden; +} +.modal { + display: none; + overflow: hidden; + position: fixed; + top: 0; + right: 0; + bottom: 0; + left: 0; + z-index: 1040; + -webkit-overflow-scrolling: touch; + outline: 0; +} +.modal.fade .modal-dialog { + -webkit-transform: translate(0, -25%); + -ms-transform: translate(0, -25%); + -o-transform: translate(0, -25%); + transform: translate(0, -25%); + -webkit-transition: -webkit-transform 0.3s ease-out; + -moz-transition: -moz-transform 0.3s ease-out; + -o-transition: -o-transform 0.3s ease-out; + transition: transform 0.3s ease-out; +} +.modal.in .modal-dialog { + -webkit-transform: translate(0, 0); + -ms-transform: translate(0, 0); + -o-transform: translate(0, 0); + transform: translate(0, 0); +} +.modal-open .modal { + overflow-x: hidden; + overflow-y: auto; +} +.modal-dialog { + position: relative; + width: auto; + margin: 10px; +} +.modal-content { + position: relative; + background-color: #ffffff; + border: 1px solid #999999; + border: 1px solid rgba(0, 0, 0, 0.2); + border-radius: 6px; + -webkit-box-shadow: 0 3px 9px rgba(0, 0, 0, 0.5); + box-shadow: 0 3px 9px rgba(0, 0, 0, 0.5); + background-clip: padding-box; + outline: 0; +} +.modal-backdrop { + position: absolute; + top: 0; + right: 0; + left: 0; + background-color: #000000; +} +.modal-backdrop.fade { + opacity: 0; + filter: alpha(opacity=0); +} +.modal-backdrop.in { + opacity: 0.5; + filter: alpha(opacity=50); +} +.modal-header { + padding: 15px; + border-bottom: 1px solid #e5e5e5; + min-height: 16.428571429px; +} +.modal-header .close { + margin-top: -2px; +} +.modal-title { + margin: 0; + line-height: 1.428571429; +} +.modal-body { + position: relative; + padding: 15px; +} +.modal-footer { + padding: 15px; + text-align: right; + border-top: 1px solid #e5e5e5; +} +.modal-footer .btn + .btn { + margin-left: 5px; + margin-bottom: 0; +} +.modal-footer .btn-group .btn + .btn { + margin-left: -1px; +} +.modal-footer .btn-block + .btn-block { + margin-left: 0; +} +.modal-scrollbar-measure { + position: absolute; + top: -9999px; + width: 50px; + height: 50px; + overflow: scroll; +} +@media (min-width: 768px) { + .modal-dialog { + width: 600px; + margin: 30px auto; + } + .modal-content { + -webkit-box-shadow: 0 5px 15px rgba(0, 0, 0, 0.5); + box-shadow: 0 5px 15px rgba(0, 0, 0, 0.5); + } + .modal-sm { + width: 300px; + } +} +@media (min-width: 992px) { + .modal-lg { + width: 900px; + } +} +.tooltip { + position: absolute; + z-index: 1070; + display: block; + visibility: visible; + font-family: "Helvetica Neue", Helvetica, Arial, sans-serif; + font-size: 12px; + font-weight: normal; + line-height: 1.4; + opacity: 0; + filter: alpha(opacity=0); +} +.tooltip.in { + opacity: 0.9; + filter: alpha(opacity=90); +} +.tooltip.top { + margin-top: -3px; + padding: 5px 0; +} +.tooltip.right { + margin-left: 3px; + padding: 0 5px; +} +.tooltip.bottom { + margin-top: 3px; + padding: 5px 0; +} +.tooltip.left { + margin-left: -3px; + padding: 0 5px; +} +.tooltip-inner { + max-width: 200px; + padding: 3px 8px; + color: #ffffff; + text-align: center; + text-decoration: none; + background-color: #000000; + border-radius: 4px; +} +.tooltip-arrow { + position: absolute; + width: 0; + height: 0; + border-color: transparent; + border-style: solid; +} +.tooltip.top .tooltip-arrow { + bottom: 0; + left: 50%; + margin-left: -5px; + border-width: 5px 5px 0; + border-top-color: #000000; +} +.tooltip.top-left .tooltip-arrow { + bottom: 0; + right: 5px; + margin-bottom: -5px; + border-width: 5px 5px 0; + border-top-color: #000000; +} +.tooltip.top-right .tooltip-arrow { + bottom: 0; + left: 5px; + margin-bottom: -5px; + border-width: 5px 5px 0; + border-top-color: #000000; +} +.tooltip.right .tooltip-arrow { + top: 50%; + left: 0; + margin-top: -5px; + border-width: 5px 5px 5px 0; + border-right-color: #000000; +} +.tooltip.left .tooltip-arrow { + top: 50%; + right: 0; + margin-top: -5px; + border-width: 5px 0 5px 5px; + border-left-color: #000000; +} +.tooltip.bottom .tooltip-arrow { + top: 0; + left: 50%; + margin-left: -5px; + border-width: 0 5px 5px; + border-bottom-color: #000000; +} +.tooltip.bottom-left .tooltip-arrow { + top: 0; + right: 5px; + margin-top: -5px; + border-width: 0 5px 5px; + border-bottom-color: #000000; +} +.tooltip.bottom-right .tooltip-arrow { + top: 0; + left: 5px; + margin-top: -5px; + border-width: 0 5px 5px; + border-bottom-color: #000000; +} +.popover { + position: absolute; + top: 0; + left: 0; + z-index: 1060; + display: none; + max-width: 276px; + padding: 1px; + font-family: "Helvetica Neue", Helvetica, Arial, sans-serif; + font-size: 14px; + font-weight: normal; + line-height: 1.428571429; + text-align: left; + background-color: #ffffff; + background-clip: padding-box; + border: 1px solid #cccccc; + border: 1px solid rgba(0, 0, 0, 0.2); + border-radius: 6px; + -webkit-box-shadow: 0 5px 10px rgba(0, 0, 0, 0.2); + box-shadow: 0 5px 10px rgba(0, 0, 0, 0.2); + white-space: normal; +} +.popover.top { + margin-top: -10px; +} +.popover.right { + margin-left: 10px; +} +.popover.bottom { + margin-top: 10px; +} +.popover.left { + margin-left: -10px; +} +.popover-title { + margin: 0; + padding: 8px 14px; + font-size: 14px; + background-color: #f7f7f7; + border-bottom: 1px solid #ebebeb; + border-radius: 5px 5px 0 0; +} +.popover-content { + padding: 9px 14px; +} +.popover > .arrow, +.popover > .arrow:after { + position: absolute; + display: block; + width: 0; + height: 0; + border-color: transparent; + border-style: solid; +} +.popover > .arrow { + border-width: 11px; +} +.popover > .arrow:after { + border-width: 10px; + content: ""; +} +.popover.top > .arrow { + left: 50%; + margin-left: -11px; + border-bottom-width: 0; + border-top-color: #999999; + border-top-color: rgba(0, 0, 0, 0.25); + bottom: -11px; +} +.popover.top > .arrow:after { + content: " "; + bottom: 1px; + margin-left: -10px; + border-bottom-width: 0; + border-top-color: #ffffff; +} +.popover.right > .arrow { + top: 50%; + left: -11px; + margin-top: -11px; + border-left-width: 0; + border-right-color: #999999; + border-right-color: rgba(0, 0, 0, 0.25); +} +.popover.right > .arrow:after { + content: " "; + left: 1px; + bottom: -10px; + border-left-width: 0; + border-right-color: #ffffff; +} +.popover.bottom > .arrow { + left: 50%; + margin-left: -11px; + border-top-width: 0; + border-bottom-color: #999999; + border-bottom-color: rgba(0, 0, 0, 0.25); + top: -11px; +} +.popover.bottom > .arrow:after { + content: " "; + top: 1px; + margin-left: -10px; + border-top-width: 0; + border-bottom-color: #ffffff; +} +.popover.left > .arrow { + top: 50%; + right: -11px; + margin-top: -11px; + border-right-width: 0; + border-left-color: #999999; + border-left-color: rgba(0, 0, 0, 0.25); +} +.popover.left > .arrow:after { + content: " "; + right: 1px; + border-right-width: 0; + border-left-color: #ffffff; + bottom: -10px; +} +.carousel { + position: relative; +} +.carousel-inner { + position: relative; + overflow: hidden; + width: 100%; +} +.carousel-inner > .item { + display: none; + position: relative; + -webkit-transition: 0.6s ease-in-out left; + -o-transition: 0.6s ease-in-out left; + transition: 0.6s ease-in-out left; +} +.carousel-inner > .item > img, +.carousel-inner > .item > a > img { + line-height: 1; +} +@media all and (transform-3d), (-webkit-transform-3d) { + .carousel-inner > .item { + -webkit-transition: -webkit-transform 0.6s ease-in-out; + -moz-transition: -moz-transform 0.6s ease-in-out; + -o-transition: -o-transform 0.6s ease-in-out; + transition: transform 0.6s ease-in-out; + -webkit-backface-visibility: hidden; + -moz-backface-visibility: hidden; + backface-visibility: hidden; + -webkit-perspective: 1000; + -moz-perspective: 1000; + perspective: 1000; + } + .carousel-inner > .item.next, + .carousel-inner > .item.active.right { + -webkit-transform: translate3d(100%, 0, 0); + transform: translate3d(100%, 0, 0); + left: 0; + } + .carousel-inner > .item.prev, + .carousel-inner > .item.active.left { + -webkit-transform: translate3d(-100%, 0, 0); + transform: translate3d(-100%, 0, 0); + left: 0; + } + .carousel-inner > .item.next.left, + .carousel-inner > .item.prev.right, + .carousel-inner > .item.active { + -webkit-transform: translate3d(0, 0, 0); + transform: translate3d(0, 0, 0); + left: 0; + } +} +.carousel-inner > .active, +.carousel-inner > .next, +.carousel-inner > .prev { + display: block; +} +.carousel-inner > .active { + left: 0; +} +.carousel-inner > .next, +.carousel-inner > .prev { + position: absolute; + top: 0; + width: 100%; +} +.carousel-inner > .next { + left: 100%; +} +.carousel-inner > .prev { + left: -100%; +} +.carousel-inner > .next.left, +.carousel-inner > .prev.right { + left: 0; +} +.carousel-inner > .active.left { + left: -100%; +} +.carousel-inner > .active.right { + left: 100%; +} +.carousel-control { + position: absolute; + top: 0; + left: 0; + bottom: 0; + width: 15%; + opacity: 0.5; + filter: alpha(opacity=50); + font-size: 20px; + color: #ffffff; + text-align: center; + text-shadow: 0 1px 2px rgba(0, 0, 0, 0.6); +} +.carousel-control.left { + background-image: -webkit-linear-gradient(left, rgba(0, 0, 0, 0.5) 0%, rgba(0, 0, 0, 0.0001) 100%); + background-image: -o-linear-gradient(left, rgba(0, 0, 0, 0.5) 0%, rgba(0, 0, 0, 0.0001) 100%); + background-image: linear-gradient(to right, rgba(0, 0, 0, 0.5) 0%, rgba(0, 0, 0, 0.0001) 100%); + background-repeat: repeat-x; + filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#80000000', endColorstr='#00000000', GradientType=1); +} +.carousel-control.right { + left: auto; + right: 0; + background-image: -webkit-linear-gradient(left, rgba(0, 0, 0, 0.0001) 0%, rgba(0, 0, 0, 0.5) 100%); + background-image: -o-linear-gradient(left, rgba(0, 0, 0, 0.0001) 0%, rgba(0, 0, 0, 0.5) 100%); + background-image: linear-gradient(to right, rgba(0, 0, 0, 0.0001) 0%, rgba(0, 0, 0, 0.5) 100%); + background-repeat: repeat-x; + filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#00000000', endColorstr='#80000000', GradientType=1); +} +.carousel-control:hover, +.carousel-control:focus { + outline: 0; + color: #ffffff; + text-decoration: none; + opacity: 0.9; + filter: alpha(opacity=90); +} +.carousel-control .icon-prev, +.carousel-control .icon-next, +.carousel-control .glyphicon-chevron-left, +.carousel-control .glyphicon-chevron-right { + position: absolute; + top: 50%; + z-index: 5; + display: inline-block; +} +.carousel-control .icon-prev, +.carousel-control .glyphicon-chevron-left { + left: 50%; + margin-left: -10px; +} +.carousel-control .icon-next, +.carousel-control .glyphicon-chevron-right { + right: 50%; + margin-right: -10px; +} +.carousel-control .icon-prev, +.carousel-control .icon-next { + width: 20px; + height: 20px; + margin-top: -10px; + line-height: 1; + font-family: serif; +} +.carousel-control .icon-prev:before { + content: '\2039'; +} +.carousel-control .icon-next:before { + content: '\203a'; +} +.carousel-indicators { + position: absolute; + bottom: 10px; + left: 50%; + z-index: 15; + width: 60%; + margin-left: -30%; + padding-left: 0; + list-style: none; + text-align: center; +} +.carousel-indicators li { + display: inline-block; + width: 10px; + height: 10px; + margin: 1px; + text-indent: -999px; + border: 1px solid #ffffff; + border-radius: 10px; + cursor: pointer; + background-color: #000 \9; + background-color: rgba(0, 0, 0, 0); +} +.carousel-indicators .active { + margin: 0; + width: 12px; + height: 12px; + background-color: #ffffff; +} +.carousel-caption { + position: absolute; + left: 15%; + right: 15%; + bottom: 20px; + z-index: 10; + padding-top: 20px; + padding-bottom: 20px; + color: #ffffff; + text-align: center; + text-shadow: 0 1px 2px rgba(0, 0, 0, 0.6); +} +.carousel-caption .btn { + text-shadow: none; +} +@media screen and (min-width: 768px) { + .carousel-control .glyphicon-chevron-left, + .carousel-control .glyphicon-chevron-right, + .carousel-control .icon-prev, + .carousel-control .icon-next { + width: 30px; + height: 30px; + margin-top: -15px; + font-size: 30px; + } + .carousel-control .glyphicon-chevron-left, + .carousel-control .icon-prev { + margin-left: -15px; + } + .carousel-control .glyphicon-chevron-right, + .carousel-control .icon-next { + margin-right: -15px; + } + .carousel-caption { + left: 20%; + right: 20%; + padding-bottom: 30px; + } + .carousel-indicators { + bottom: 20px; + } +} +.clearfix:before, +.clearfix:after, +.dl-horizontal dd:before, +.dl-horizontal dd:after, +.container:before, +.container:after, +.container-fluid:before, +.container-fluid:after, +.row:before, +.row:after, +.form-horizontal .form-group:before, +.form-horizontal .form-group:after, +.btn-toolbar:before, +.btn-toolbar:after, +.btn-group-vertical > .btn-group:before, +.btn-group-vertical > .btn-group:after, +.nav:before, +.nav:after, +.navbar:before, +.navbar:after, +.navbar-header:before, +.navbar-header:after, +.navbar-collapse:before, +.navbar-collapse:after, +.pager:before, +.pager:after, +.panel-body:before, +.panel-body:after, +.modal-footer:before, +.modal-footer:after { + content: " "; + display: table; +} +.clearfix:after, +.dl-horizontal dd:after, +.container:after, +.container-fluid:after, +.row:after, +.form-horizontal .form-group:after, +.btn-toolbar:after, +.btn-group-vertical > .btn-group:after, +.nav:after, +.navbar:after, +.navbar-header:after, +.navbar-collapse:after, +.pager:after, +.panel-body:after, +.modal-footer:after { + clear: both; +} +.center-block { + display: block; + margin-left: auto; + margin-right: auto; +} +.pull-right { + float: right !important; +} +.pull-left { + float: left !important; +} +.hide { + display: none !important; +} +.show { + display: block !important; +} +.invisible { + visibility: hidden; +} +.text-hide { + font: 0/0 a; + color: transparent; + text-shadow: none; + background-color: transparent; + border: 0; +} +.hidden { + display: none !important; + visibility: hidden !important; +} +.affix { + position: fixed; +} +@-ms-viewport { + width: device-width; +} +.visible-xs, +.visible-sm, +.visible-md, +.visible-lg { + display: none !important; +} +.visible-xs-block, +.visible-xs-inline, +.visible-xs-inline-block, +.visible-sm-block, +.visible-sm-inline, +.visible-sm-inline-block, +.visible-md-block, +.visible-md-inline, +.visible-md-inline-block, +.visible-lg-block, +.visible-lg-inline, +.visible-lg-inline-block { + display: none !important; +} +@media (max-width: 767px) { + .visible-xs { + display: block !important; + } + table.visible-xs { + display: table; + } + tr.visible-xs { + display: table-row !important; + } + th.visible-xs, + td.visible-xs { + display: table-cell !important; + } +} +@media (max-width: 767px) { + .visible-xs-block { + display: block !important; + } +} +@media (max-width: 767px) { + .visible-xs-inline { + display: inline !important; + } +} +@media (max-width: 767px) { + .visible-xs-inline-block { + display: inline-block !important; + } +} +@media (min-width: 768px) and (max-width: 991px) { + .visible-sm { + display: block !important; + } + table.visible-sm { + display: table; + } + tr.visible-sm { + display: table-row !important; + } + th.visible-sm, + td.visible-sm { + display: table-cell !important; + } +} +@media (min-width: 768px) and (max-width: 991px) { + .visible-sm-block { + display: block !important; + } +} +@media (min-width: 768px) and (max-width: 991px) { + .visible-sm-inline { + display: inline !important; + } +} +@media (min-width: 768px) and (max-width: 991px) { + .visible-sm-inline-block { + display: inline-block !important; + } +} +@media (min-width: 992px) and (max-width: 1199px) { + .visible-md { + display: block !important; + } + table.visible-md { + display: table; + } + tr.visible-md { + display: table-row !important; + } + th.visible-md, + td.visible-md { + display: table-cell !important; + } +} +@media (min-width: 992px) and (max-width: 1199px) { + .visible-md-block { + display: block !important; + } +} +@media (min-width: 992px) and (max-width: 1199px) { + .visible-md-inline { + display: inline !important; + } +} +@media (min-width: 992px) and (max-width: 1199px) { + .visible-md-inline-block { + display: inline-block !important; + } +} +@media (min-width: 1200px) { + .visible-lg { + display: block !important; + } + table.visible-lg { + display: table; + } + tr.visible-lg { + display: table-row !important; + } + th.visible-lg, + td.visible-lg { + display: table-cell !important; + } +} +@media (min-width: 1200px) { + .visible-lg-block { + display: block !important; + } +} +@media (min-width: 1200px) { + .visible-lg-inline { + display: inline !important; + } +} +@media (min-width: 1200px) { + .visible-lg-inline-block { + display: inline-block !important; + } +} +@media (max-width: 767px) { + .hidden-xs { + display: none !important; + } +} +@media (min-width: 768px) and (max-width: 991px) { + .hidden-sm { + display: none !important; + } +} +@media (min-width: 992px) and (max-width: 1199px) { + .hidden-md { + display: none !important; + } +} +@media (min-width: 1200px) { + .hidden-lg { + display: none !important; + } +} +.visible-print { + display: none !important; +} +@media print { + .visible-print { + display: block !important; + } + table.visible-print { + display: table; + } + tr.visible-print { + display: table-row !important; + } + th.visible-print, + td.visible-print { + display: table-cell !important; + } +} +.visible-print-block { + display: none !important; +} +@media print { + .visible-print-block { + display: block !important; + } +} +.visible-print-inline { + display: none !important; +} +@media print { + .visible-print-inline { + display: inline !important; + } +} +.visible-print-inline-block { + display: none !important; +} +@media print { + .visible-print-inline-block { + display: inline-block !important; + } +} +@media print { + .hidden-print { + display: none !important; + } +} diff --git a/airflow/www/static/bootstrap-toggle.min.css b/airflow/www/static/bootstrap-toggle.min.css new file mode 100644 index 0000000000000..0d42ed09cdaa3 --- /dev/null +++ b/airflow/www/static/bootstrap-toggle.min.css @@ -0,0 +1,28 @@ +/*! ======================================================================== + * Bootstrap Toggle: bootstrap-toggle.css v2.2.0 + * http://www.bootstraptoggle.com + * ======================================================================== + * Copyright 2014 Min Hur, The New York Times Company + * Licensed under MIT + * ======================================================================== */ +.checkbox label .toggle,.checkbox-inline .toggle{margin-left:-20px;margin-right:5px} +.toggle{position:relative;overflow:hidden} +.toggle input[type=checkbox]{display:none} +.toggle-group{position:absolute;width:200%;top:0;bottom:0;left:0;transition:left .35s;-webkit-transition:left .35s;-moz-user-select:none;-webkit-user-select:none} +.toggle.off .toggle-group{left:-100%} +.toggle-on{position:absolute;top:0;bottom:0;left:0;right:50%;margin:0;border:0;border-radius:0} +.toggle-off{position:absolute;top:0;bottom:0;left:50%;right:0;margin:0;border:0;border-radius:0} +.toggle-handle{position:relative;margin:0 auto;padding-top:0;padding-bottom:0;height:100%;width:0;border-width:0 1px} +.toggle.btn{min-width:59px;min-height:34px} +.toggle-on.btn{padding-right:24px} +.toggle-off.btn{padding-left:24px} +.toggle.btn-lg{min-width:79px;min-height:45px} +.toggle-on.btn-lg{padding-right:31px} +.toggle-off.btn-lg{padding-left:31px} +.toggle-handle.btn-lg{width:40px} +.toggle.btn-sm{min-width:50px;min-height:30px} +.toggle-on.btn-sm{padding-right:20px} +.toggle-off.btn-sm{padding-left:20px} +.toggle.btn-xs{min-width:35px;min-height:22px} +.toggle-on.btn-xs{padding-right:12px} +.toggle-off.btn-xs{padding-left:12px} \ No newline at end of file diff --git a/airflow/www/static/bootstrap-toggle.min.js b/airflow/www/static/bootstrap-toggle.min.js new file mode 100644 index 0000000000000..37113200bff5c --- /dev/null +++ b/airflow/www/static/bootstrap-toggle.min.js @@ -0,0 +1,9 @@ +/*! ======================================================================== + * Bootstrap Toggle: bootstrap-toggle.js v2.2.0 + * http://www.bootstraptoggle.com + * ======================================================================== + * Copyright 2014 Min Hur, The New York Times Company + * Licensed under MIT + * ======================================================================== */ ++function(a){"use strict";function b(b){return this.each(function(){var d=a(this),e=d.data("bs.toggle"),f="object"==typeof b&&b;e||d.data("bs.toggle",e=new c(this,f)),"string"==typeof b&&e[b]&&e[b]()})}var c=function(b,c){this.$element=a(b),this.options=a.extend({},this.defaults(),c),this.render()};c.VERSION="2.2.0",c.DEFAULTS={on:"On",off:"Off",onstyle:"primary",offstyle:"default",size:"normal",style:"",width:null,height:null},c.prototype.defaults=function(){return{on:this.$element.attr("data-on")||c.DEFAULTS.on,off:this.$element.attr("data-off")||c.DEFAULTS.off,onstyle:this.$element.attr("data-onstyle")||c.DEFAULTS.onstyle,offstyle:this.$element.attr("data-offstyle")||c.DEFAULTS.offstyle,size:this.$element.attr("data-size")||c.DEFAULTS.size,style:this.$element.attr("data-style")||c.DEFAULTS.style,width:this.$element.attr("data-width")||c.DEFAULTS.width,height:this.$element.attr("data-height")||c.DEFAULTS.height}},c.prototype.render=function(){this._onstyle="btn-"+this.options.onstyle,this._offstyle="btn-"+this.options.offstyle;var b="large"===this.options.size?"btn-lg":"small"===this.options.size?"btn-sm":"mini"===this.options.size?"btn-xs":"",c=a('

').append(c,d,e),g=a('
').addClass(this.$element.prop("checked")?this._onstyle:this._offstyle+" off").addClass(b).addClass(this.options.style);this.$element.wrap(g),a.extend(this,{$toggle:this.$element.parent(),$toggleOn:c,$toggleOff:d,$toggleGroup:f}),this.$toggle.append(f);var h=this.options.width||Math.max(c.outerWidth(),d.outerWidth())+e.outerWidth()/2,i=this.options.height||Math.max(c.outerHeight(),d.outerHeight());c.addClass("toggle-on"),d.addClass("toggle-off"),this.$toggle.css({width:h,height:i}),this.options.height&&(c.css("line-height",c.height()+"px"),d.css("line-height",d.height()+"px")),this.update(!0),this.trigger(!0)},c.prototype.toggle=function(){this.$element.prop("checked")?this.off():this.on()},c.prototype.on=function(a){return this.$element.prop("disabled")?!1:(this.$toggle.removeClass(this._offstyle+" off").addClass(this._onstyle),this.$element.prop("checked",!0),void(a||this.trigger()))},c.prototype.off=function(a){return this.$element.prop("disabled")?!1:(this.$toggle.removeClass(this._onstyle).addClass(this._offstyle+" off"),this.$element.prop("checked",!1),void(a||this.trigger()))},c.prototype.enable=function(){this.$toggle.removeAttr("disabled"),this.$element.prop("disabled",!1)},c.prototype.disable=function(){this.$toggle.attr("disabled","disabled"),this.$element.prop("disabled",!0)},c.prototype.update=function(a){this.$element.prop("disabled")?this.disable():this.enable(),this.$element.prop("checked")?this.on(a):this.off(a)},c.prototype.trigger=function(b){this.$element.off("change.bs.toggle"),b||this.$element.change(),this.$element.on("change.bs.toggle",a.proxy(function(){this.update()},this))},c.prototype.destroy=function(){this.$element.off("change.bs.toggle"),this.$toggleGroup.remove(),this.$element.removeData("bs.toggle"),this.$element.unwrap()};var d=a.fn.bootstrapToggle;a.fn.bootstrapToggle=b,a.fn.bootstrapToggle.Constructor=c,a.fn.toggle.noConflict=function(){return a.fn.bootstrapToggle=d,this},a(function(){a("input[type=checkbox][data-toggle^=toggle]").bootstrapToggle()}),a(document).on("click.bs.toggle","div[data-toggle^=toggle]",function(b){var c=a(this).find("input[type=checkbox]");c.bootstrapToggle("toggle"),b.preventDefault()})}(jQuery); +//# sourceMappingURL=bootstrap-toggle.min.js.map \ No newline at end of file diff --git a/airflow/www/static/bootstrap3-typeahead.min.js b/airflow/www/static/bootstrap3-typeahead.min.js new file mode 100644 index 0000000000000..23aac4ef52a7d --- /dev/null +++ b/airflow/www/static/bootstrap3-typeahead.min.js @@ -0,0 +1,21 @@ +/* ============================================================= + * bootstrap3-typeahead.js v4.0.2 + * https://github.com/bassjobsen/Bootstrap-3-Typeahead + * ============================================================= + * Original written by @mdo and @fat + * ============================================================= + * Copyright 2014 Bass Jobsen @bassjobsen + * + * Licensed under the Apache License, Version 2.0 (the 'License'); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an 'AS IS' BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * ============================================================ */ +!function(a,b){"use strict";"undefined"!=typeof module&&module.exports?module.exports=b(require("jquery")):"function"==typeof define&&define.amd?define(["jquery"],function(a){return b(a)}):b(a.jQuery)}(this,function(a){"use strict";var b=function(c,d){this.$element=a(c),this.options=a.extend({},b.defaults,d),this.matcher=this.options.matcher||this.matcher,this.sorter=this.options.sorter||this.sorter,this.select=this.options.select||this.select,this.autoSelect="boolean"!=typeof this.options.autoSelect||this.options.autoSelect,this.highlighter=this.options.highlighter||this.highlighter,this.render=this.options.render||this.render,this.updater=this.options.updater||this.updater,this.displayText=this.options.displayText||this.displayText,this.source=this.options.source,this.delay=this.options.delay,this.$menu=a(this.options.menu),this.$appendTo=this.options.appendTo?a(this.options.appendTo):null,this.fitToElement="boolean"==typeof this.options.fitToElement&&this.options.fitToElement,this.shown=!1,this.listen(),this.showHintOnFocus=("boolean"==typeof this.options.showHintOnFocus||"all"===this.options.showHintOnFocus)&&this.options.showHintOnFocus,this.afterSelect=this.options.afterSelect,this.addItem=!1,this.value=this.$element.val()||this.$element.text()};b.prototype={constructor:b,select:function(){var a=this.$menu.find(".active").data("value");if(this.$element.data("active",a),this.autoSelect||a){var b=this.updater(a);b||(b=""),this.$element.val(this.displayText(b)||b).text(this.displayText(b)||b).change(),this.afterSelect(b)}return this.hide()},updater:function(a){return a},setSource:function(a){this.source=a},show:function(){var d,b=a.extend({},this.$element.position(),{height:this.$element[0].offsetHeight}),c="function"==typeof this.options.scrollHeight?this.options.scrollHeight.call():this.options.scrollHeight;if(this.shown?d=this.$menu:this.$appendTo?(d=this.$menu.appendTo(this.$appendTo),this.hasSameParent=this.$appendTo.is(this.$element.parent())):(d=this.$menu.insertAfter(this.$element),this.hasSameParent=!0),!this.hasSameParent){d.css("position","fixed");var e=this.$element.offset();b.top=e.top,b.left=e.left}var f=a(d).parent().hasClass("dropup"),g=f?"auto":b.top+b.height+c,h=a(d).hasClass("dropdown-menu-right"),i=h?"auto":b.left;return d.css({top:g,left:i}).show(),this.options.fitToElement===!0&&d.css("width",this.$element.outerWidth()+"px"),this.shown=!0,this},hide:function(){return this.$menu.hide(),this.shown=!1,this},lookup:function(b){if("undefined"!=typeof b&&null!==b?this.query=b:this.query=this.$element.val()||this.$element.text()||"",this.query.length0?this.$element.data("active",b[0]):this.$element.data("active",null),this.options.addItem&&b.push(this.options.addItem),"all"==this.options.items?this.render(b).show():this.render(b.slice(0,this.options.items)).show()):this.shown?this.hide():this},matcher:function(a){var b=this.displayText(a);return~b.toLowerCase().indexOf(this.query.toLowerCase())},sorter:function(a){for(var e,b=[],c=[],d=[];e=a.shift();){var f=this.displayText(e);f.toLowerCase().indexOf(this.query.toLowerCase())?~f.indexOf(this.query)?c.push(e):d.push(e):b.push(e)}return b.concat(c,d)},highlighter:function(a){var b=this.query;if(""===b)return a;var f,c=a.match(/(>)([^<]*)(<)/g),d=[],e=[];if(c&&c.length)for(f=0;f2&&d.push(c[f]);else d=[],d.push(a);b = b.replace((/[\(\)\/\.\*\+\?\[\]]/g), function(m) {return '\\'+m;});var h,g=new RegExp(b,"g");for(f=0;f0&&e.push(d[f]);for(f=0;f$&"));return a},render:function(b){var c=this,d=this,e=!1,f=[],g=c.options.separator;return a.each(b,function(a,c){a>0&&c[g]!==b[a-1][g]&&f.push({__type:"divider"}),!c[g]||0!==a&&c[g]===b[a-1][g]||f.push({__type:"category",name:c[g]}),f.push(c)}),b=a(f).map(function(b,f){if("category"==(f.__type||!1))return a(c.options.headerHtml).text(f.name)[0];if("divider"==(f.__type||!1))return a(c.options.headerDivider)[0];var g=d.displayText(f);return b=a(c.options.item).data("value",f),b.find("a").html(c.highlighter(g,f)),g==d.$element.val()&&(b.addClass("active"),d.$element.data("active",f),e=!0),b[0]}),this.autoSelect&&!e&&(b.filter(":not(.dropdown-header)").first().addClass("active"),this.$element.data("active",b.first().data("value"))),this.$menu.html(b),this},displayText:function(a){return"undefined"!=typeof a&&"undefined"!=typeof a.name?a.name:a},next:function(b){var c=this.$menu.find(".active").removeClass("active"),d=c.next();d.length||(d=a(this.$menu.find("li")[0])),d.addClass("active")},prev:function(a){var b=this.$menu.find(".active").removeClass("active"),c=b.prev();c.length||(c=this.$menu.find("li").last()),c.addClass("active")},listen:function(){this.$element.on("focus",a.proxy(this.focus,this)).on("blur",a.proxy(this.blur,this)).on("keypress",a.proxy(this.keypress,this)).on("input",a.proxy(this.input,this)).on("keyup",a.proxy(this.keyup,this)),this.eventSupported("keydown")&&this.$element.on("keydown",a.proxy(this.keydown,this)),this.$menu.on("click",a.proxy(this.click,this)).on("mouseenter","li",a.proxy(this.mouseenter,this)).on("mouseleave","li",a.proxy(this.mouseleave,this)).on("mousedown",a.proxy(this.mousedown,this))},destroy:function(){this.$element.data("typeahead",null),this.$element.data("active",null),this.$element.off("focus").off("blur").off("keypress").off("input").off("keyup"),this.eventSupported("keydown")&&this.$element.off("keydown"),this.$menu.remove(),this.destroyed=!0},eventSupported:function(a){var b=a in this.$element;return b||(this.$element.setAttribute(a,"return;"),b="function"==typeof this.$element[a]),b},move:function(a){if(this.shown)switch(a.keyCode){case 9:case 13:case 27:a.preventDefault();break;case 38:if(a.shiftKey)return;a.preventDefault(),this.prev();break;case 40:if(a.shiftKey)return;a.preventDefault(),this.next()}},keydown:function(b){this.suppressKeyPressRepeat=~a.inArray(b.keyCode,[40,38,9,13,27]),this.shown||40!=b.keyCode?this.move(b):this.lookup()},keypress:function(a){this.suppressKeyPressRepeat||this.move(a)},input:function(a){var b=this.$element.val()||this.$element.text();this.value!==b&&(this.value=b,this.lookup())},keyup:function(a){if(!this.destroyed)switch(a.keyCode){case 40:case 38:case 16:case 17:case 18:break;case 9:case 13:if(!this.shown)return;this.select();break;case 27:if(!this.shown)return;this.hide()}},focus:function(a){this.focused||(this.focused=!0,this.options.showHintOnFocus&&this.skipShowHintOnFocus!==!0&&("all"===this.options.showHintOnFocus?this.lookup(""):this.lookup())),this.skipShowHintOnFocus&&(this.skipShowHintOnFocus=!1)},blur:function(a){this.mousedover||this.mouseddown||!this.shown?this.mouseddown&&(this.skipShowHintOnFocus=!0,this.$element.focus(),this.mouseddown=!1):(this.hide(),this.focused=!1)},click:function(a){a.preventDefault(),this.skipShowHintOnFocus=!0,this.select(),this.$element.focus(),this.hide()},mouseenter:function(b){this.mousedover=!0,this.$menu.find(".active").removeClass("active"),a(b.currentTarget).addClass("active")},mouseleave:function(a){this.mousedover=!1,!this.focused&&this.shown&&this.hide()},mousedown:function(a){this.mouseddown=!0,this.$menu.one("mouseup",function(a){this.mouseddown=!1}.bind(this))}};var c=a.fn.typeahead;a.fn.typeahead=function(c){var d=arguments;return"string"==typeof c&&"getActive"==c?this.data("active"):this.each(function(){var e=a(this),f=e.data("typeahead"),g="object"==typeof c&&c;f||e.data("typeahead",f=new b(this,g)),"string"==typeof c&&f[c]&&(d.length>1?f[c].apply(f,Array.prototype.slice.call(d,1)):f[c]())})},b.defaults={source:[],items:8,menu:'',item:'
  • ',minLength:1,scrollHeight:0,autoSelect:!0,afterSelect:a.noop,addItem:!1,delay:0,separator:"category",headerHtml:'',headerDivider:''},a.fn.typeahead.Constructor=b,a.fn.typeahead.noConflict=function(){return a.fn.typeahead=c,this},a(document).on("focus.typeahead.data-api",'[data-provide="typeahead"]',function(b){var c=a(this);c.data("typeahead")||c.typeahead(c.data())})}); diff --git a/airflow/www/static/connection_form.js b/airflow/www/static/connection_form.js new file mode 100644 index 0000000000000..0324bcfffb2d2 --- /dev/null +++ b/airflow/www/static/connection_form.js @@ -0,0 +1,73 @@ +/** +* Licensed to the Apache Software Foundation (ASF) under one +* or more contributor license agreements. See the NOTICE file +* distributed with this work for additional information +* regarding copyright ownership. The ASF licenses this file +* to you under the Apache License, Version 2.0 (the +* "License"); you may not use this file except in compliance +* with the License. You may obtain a copy of the License at +* +* http://www.apache.org/licenses/LICENSE-2.0 +* +* Unless required by applicable law or agreed to in writing, +* software distributed under the License is distributed on an +* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +* KIND, either express or implied. See the License for the +* specific language governing permissions and limitations +* under the License. +*/ + +/** + * Created by janomar on 23/07/15. + */ + + $(document).ready(function() { + var config = { + jdbc: { + hidden_fields: ['port', 'schema', 'extra'], + relabeling: {'host': 'Connection URL'}, + }, + google_cloud_platform: { + hidden_fields: ['host', 'schema', 'login', 'password', 'port', 'extra'], + relabeling: {}, + }, + cloudant: { + hidden_fields: ['port', 'extra'], + relabeling: { + 'host': 'Account', + 'login': 'Username (or API Key)', + 'schema': 'Database' + } + } + } + function connTypeChange(connectionType) { + $("div.form-group").removeClass("hide"); + $.each($("[id^='extra__']"), function() { + $(this).parent().parent().addClass('hide') + }); + // Somehow the previous command doesn't honor __ + $("#extra").parent().parent().removeClass('hide') + $.each($("[id^='extra__"+connectionType+"']"), function() { + $(this).parent().parent().removeClass('hide') + }); + $("label[orig_text]").each(function(){ + $(this).text($(this).attr("orig_text")); + }); + if (config[connectionType] != undefined){ + $.each(config[connectionType].hidden_fields, function(i, field){ + $("#" + field).parent().parent().addClass('hide') + }); + $.each(config[connectionType].relabeling, function(k, v){ + lbl = $("label[for='" + k + "']") + lbl.attr("orig_text", lbl.text()); + $("label[for='" + k + "']").text(v); + }); + } + } + var connectionType=$("#conn_type").val(); + $("#conn_type").on('change', function(e) { + connectionType = $("#conn_type").val(); + connTypeChange(connectionType); + }); + connTypeChange(connectionType); +}); diff --git a/airflow/www/static/d3.tip.v0.6.3.js b/airflow/www/static/d3.tip.v0.6.3.js new file mode 100644 index 0000000000000..b2b48fbfeb0c0 --- /dev/null +++ b/airflow/www/static/d3.tip.v0.6.3.js @@ -0,0 +1,302 @@ +/** + * The MIT License (MIT) + * Copyright (c) 2013 Justin Palmer + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to + * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies + * of the Software, and to permit persons to whom the Software is furnished to do + * so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, + * INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A + * PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT + * HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION + * OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE + * SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +*/ + +// d3.tip +// Copyright (c) 2013 Justin Palmer +// +// Tooltips for d3.js SVG visualizations + +// Public - contructs a new tooltip +// +// Returns a tip +d3.tip = function() { + var direction = d3_tip_direction, + offset = d3_tip_offset, + html = d3_tip_html, + node = initNode(), + svg = null, + point = null, + target = null + + function tip(vis) { + svg = getSVGNode(vis) + point = svg.createSVGPoint() + document.body.appendChild(node) + } + + // Public - show the tooltip on the screen + // + // Returns a tip + tip.show = function() { + var args = Array.prototype.slice.call(arguments) + if(args[args.length - 1] instanceof SVGElement) target = args.pop() + + var content = html.apply(this, args), + poffset = offset.apply(this, args), + dir = direction.apply(this, args), + nodel = d3.select(node), i = 0, + coords + + nodel.html(content) + .style({ opacity: 1, 'pointer-events': 'all' }) + + while(i--) nodel.classed(directions[i], false) + coords = direction_callbacks.get(dir).apply(this) + nodel.classed(dir, true).style({ + top: (coords.top + poffset[0]) + 'px', + left: (coords.left + poffset[1]) + 'px' + }) + + return tip + } + + // Public - hide the tooltip + // + // Returns a tip + tip.hide = function() { + nodel = d3.select(node) + nodel.style({ opacity: 0, 'pointer-events': 'none' }) + return tip + } + + // Public: Proxy attr calls to the d3 tip container. Sets or gets attribute value. + // + // n - name of the attribute + // v - value of the attribute + // + // Returns tip or attribute value + tip.attr = function(n, v) { + if (arguments.length < 2 && typeof n === 'string') { + return d3.select(node).attr(n) + } else { + var args = Array.prototype.slice.call(arguments) + d3.selection.prototype.attr.apply(d3.select(node), args) + } + + return tip + } + + // Public: Proxy style calls to the d3 tip container. Sets or gets a style value. + // + // n - name of the property + // v - value of the property + // + // Returns tip or style property value + tip.style = function(n, v) { + if (arguments.length < 2 && typeof n === 'string') { + return d3.select(node).style(n) + } else { + var args = Array.prototype.slice.call(arguments) + d3.selection.prototype.style.apply(d3.select(node), args) + } + + return tip + } + + // Public: Set or get the direction of the tooltip + // + // v - One of n(north), s(south), e(east), or w(west), nw(northwest), + // sw(southwest), ne(northeast) or se(southeast) + // + // Returns tip or direction + tip.direction = function(v) { + if (!arguments.length) return direction + direction = v == null ? v : d3.functor(v) + + return tip + } + + // Public: Sets or gets the offset of the tip + // + // v - Array of [x, y] offset + // + // Returns offset or + tip.offset = function(v) { + if (!arguments.length) return offset + offset = v == null ? v : d3.functor(v) + + return tip + } + + // Public: sets or gets the html value of the tooltip + // + // v - String value of the tip + // + // Returns html value or tip + tip.html = function(v) { + if (!arguments.length) return html + html = v == null ? v : d3.functor(v) + + return tip + } + + function d3_tip_direction() { return 'n' } + function d3_tip_offset() { return [0, 0] } + function d3_tip_html() { return ' ' } + + var direction_callbacks = d3.map({ + n: direction_n, + s: direction_s, + e: direction_e, + w: direction_w, + nw: direction_nw, + ne: direction_ne, + sw: direction_sw, + se: direction_se + }), + + directions = direction_callbacks.keys() + + function direction_n() { + var bbox = getScreenBBox() + return { + top: bbox.n.y - node.offsetHeight, + left: bbox.n.x - node.offsetWidth / 2 + } + } + + function direction_s() { + var bbox = getScreenBBox() + return { + top: bbox.s.y, + left: bbox.s.x - node.offsetWidth / 2 + } + } + + function direction_e() { + var bbox = getScreenBBox() + return { + top: bbox.e.y - node.offsetHeight / 2, + left: bbox.e.x + } + } + + function direction_w() { + var bbox = getScreenBBox() + return { + top: bbox.w.y - node.offsetHeight / 2, + left: bbox.w.x - node.offsetWidth + } + } + + function direction_nw() { + var bbox = getScreenBBox() + return { + top: bbox.nw.y - node.offsetHeight, + left: bbox.nw.x - node.offsetWidth + } + } + + function direction_ne() { + var bbox = getScreenBBox() + return { + top: bbox.ne.y - node.offsetHeight, + left: bbox.ne.x + } + } + + function direction_sw() { + var bbox = getScreenBBox() + return { + top: bbox.sw.y, + left: bbox.sw.x - node.offsetWidth + } + } + + function direction_se() { + var bbox = getScreenBBox() + return { + top: bbox.se.y, + left: bbox.e.x + } + } + + function initNode() { + var node = d3.select(document.createElement('div')) + node.style({ + position: 'absolute', + opacity: 0, + pointerEvents: 'none', + boxSizing: 'border-box' + }) + + return node.node() + } + + function getSVGNode(el) { + el = el.node() + if(el.tagName.toLowerCase() == 'svg') + return el + + return el.ownerSVGElement + } + + // Private - gets the screen coordinates of a shape + // + // Given a shape on the screen, will return an SVGPoint for the directions + // n(north), s(south), e(east), w(west), ne(northeast), se(southeast), nw(northwest), + // sw(southwest). + // + // +-+-+ + // | | + // + + + // | | + // +-+-+ + // + // Returns an Object {n, s, e, w, nw, sw, ne, se} + function getScreenBBox() { + var targetel = target || d3.event.target, + bbox = {}, + matrix = targetel.getScreenCTM(), + tbbox = targetel.getBBox(), + width = tbbox.width, + height = tbbox.height, + x = tbbox.x, + y = tbbox.y, + scrollTop = document.documentElement.scrollTop || document.body.scrollTop, + scrollLeft = document.documentElement.scrollLeft || document.body.scrollLeft + + + point.x = x + scrollLeft + point.y = y + scrollTop + bbox.nw = point.matrixTransform(matrix) + point.x += width + bbox.ne = point.matrixTransform(matrix) + point.y += height + bbox.se = point.matrixTransform(matrix) + point.x -= width + bbox.sw = point.matrixTransform(matrix) + point.y -= height / 2 + bbox.w = point.matrixTransform(matrix) + point.x += width + bbox.e = point.matrixTransform(matrix) + point.x -= width / 2 + point.y -= height / 2 + bbox.n = point.matrixTransform(matrix) + point.y += height + bbox.s = point.matrixTransform(matrix) + + return bbox + } + + return tip +}; diff --git a/airflow/www/static/d3.v3.min.js b/airflow/www/static/d3.v3.min.js new file mode 100644 index 0000000000000..77615c0cf1c4c --- /dev/null +++ b/airflow/www/static/d3.v3.min.js @@ -0,0 +1,5 @@ +d3=function(){function n(n){return null!=n&&!isNaN(n)}function t(n){return n.length}function e(n){for(var t=1;n*t%1;)t*=10;return t}function r(n,t){try{for(var e in t)Object.defineProperty(n.prototype,e,{value:t[e],enumerable:!1})}catch(r){n.prototype=t}}function u(){}function i(){}function o(n,t,e){return function(){var r=e.apply(t,arguments);return r===t?n:r}}function a(n,t){if(t in n)return t;t=t.charAt(0).toUpperCase()+t.substring(1);for(var e=0,r=Do.length;r>e;++e){var u=Do[e]+t;if(u in n)return u}}function c(){}function l(){}function s(n){function t(){for(var t,r=e,u=-1,i=r.length;++ue;e++)for(var u,i=n[e],o=0,a=i.length;a>o;o++)(u=i[o])&&t(u,o,e);return n}function T(n){return Lo(n,Io),n}function q(n){var t,e;return function(r,u,i){var o,a=n[i].update,c=a.length;for(i!=e&&(e=i,t=0),u>=t&&(t=u+1);!(o=a[t])&&++t0&&(n=n.substring(0,a));var s=Zo.get(n);return s&&(n=s,l=j),a?t?u:r:t?c:i}function D(n,t){return function(e){var r=mo.event;mo.event=e,t[0]=this.__data__;try{n.apply(this,t)}finally{mo.event=r}}}function j(n,t){var e=D(n,t);return function(n){var t=this,r=n.relatedTarget;r&&(r===t||8&r.compareDocumentPosition(t))||e.call(t,n)}}function L(){var n=".dragsuppress-"+ ++Xo,t="touchmove"+n,e="selectstart"+n,r="dragstart"+n,u="click"+n,i=mo.select(_o).on(t,f).on(e,f).on(r,f),o=bo.style,a=o[Vo];return o[Vo]="none",function(t){function e(){i.on(u,null)}i.on(n,null),o[Vo]=a,t&&(i.on(u,function(){f(),e()},!0),setTimeout(e,0))}}function H(n,t){t.changedTouches&&(t=t.changedTouches[0]);var e=n.ownerSVGElement||n;if(e.createSVGPoint){var r=e.createSVGPoint();if(0>$o&&(_o.scrollX||_o.scrollY)){e=mo.select("body").append("svg").style({position:"absolute",top:0,left:0,margin:0,padding:0,border:"none"},"important");var u=e[0][0].getScreenCTM();$o=!(u.f||u.e),e.remove()}return $o?(r.x=t.pageX,r.y=t.pageY):(r.x=t.clientX,r.y=t.clientY),r=r.matrixTransform(n.getScreenCTM().inverse()),[r.x,r.y]}var i=n.getBoundingClientRect();return[t.clientX-i.left-n.clientLeft,t.clientY-i.top-n.clientTop]}function F(n){return n>0?1:0>n?-1:0}function P(n){return n>1?0:-1>n?Bo:Math.acos(n)}function O(n){return n>1?Jo:-1>n?-Jo:Math.asin(n)}function R(n){return((n=Math.exp(n))-1/n)/2}function Y(n){return((n=Math.exp(n))+1/n)/2}function I(n){return((n=Math.exp(2*n))-1)/(n+1)}function U(n){return(n=Math.sin(n/2))*n}function Z(){}function V(n,t,e){return new X(n,t,e)}function X(n,t,e){this.h=n,this.s=t,this.l=e}function $(n,t,e){function r(n){return n>360?n-=360:0>n&&(n+=360),60>n?i+(o-i)*n/60:180>n?o:240>n?i+(o-i)*(240-n)/60:i}function u(n){return Math.round(255*r(n))}var i,o;return n=isNaN(n)?0:(n%=360)<0?n+360:n,t=isNaN(t)?0:0>t?0:t>1?1:t,e=0>e?0:e>1?1:e,o=.5>=e?e*(1+t):e+t-e*t,i=2*e-o,ot(u(n+120),u(n),u(n-120))}function B(n,t,e){return new W(n,t,e)}function W(n,t,e){this.h=n,this.c=t,this.l=e}function J(n,t,e){return isNaN(n)&&(n=0),isNaN(t)&&(t=0),G(e,Math.cos(n*=Qo)*t,Math.sin(n)*t)}function G(n,t,e){return new K(n,t,e)}function K(n,t,e){this.l=n,this.a=t,this.b=e}function Q(n,t,e){var r=(n+16)/116,u=r+t/500,i=r-e/200;return u=tt(u)*sa,r=tt(r)*fa,i=tt(i)*ha,ot(rt(3.2404542*u-1.5371385*r-.4985314*i),rt(-.969266*u+1.8760108*r+.041556*i),rt(.0556434*u-.2040259*r+1.0572252*i))}function nt(n,t,e){return n>0?B(Math.atan2(e,t)*na,Math.sqrt(t*t+e*e),n):B(0/0,0/0,n)}function tt(n){return n>.206893034?n*n*n:(n-4/29)/7.787037}function et(n){return n>.008856?Math.pow(n,1/3):7.787037*n+4/29}function rt(n){return Math.round(255*(.00304>=n?12.92*n:1.055*Math.pow(n,1/2.4)-.055))}function ut(n){return ot(n>>16,255&n>>8,255&n)}function it(n){return ut(n)+""}function ot(n,t,e){return new at(n,t,e)}function at(n,t,e){this.r=n,this.g=t,this.b=e}function ct(n){return 16>n?"0"+Math.max(0,n).toString(16):Math.min(255,n).toString(16)}function lt(n,t,e){var r,u,i,o=0,a=0,c=0;if(r=/([a-z]+)\((.*)\)/i.exec(n))switch(u=r[2].split(","),r[1]){case"hsl":return e(parseFloat(u[0]),parseFloat(u[1])/100,parseFloat(u[2])/100);case"rgb":return t(gt(u[0]),gt(u[1]),gt(u[2]))}return(i=da.get(n))?t(i.r,i.g,i.b):(null!=n&&"#"===n.charAt(0)&&(4===n.length?(o=n.charAt(1),o+=o,a=n.charAt(2),a+=a,c=n.charAt(3),c+=c):7===n.length&&(o=n.substring(1,3),a=n.substring(3,5),c=n.substring(5,7)),o=parseInt(o,16),a=parseInt(a,16),c=parseInt(c,16)),t(o,a,c))}function st(n,t,e){var r,u,i=Math.min(n/=255,t/=255,e/=255),o=Math.max(n,t,e),a=o-i,c=(o+i)/2;return a?(u=.5>c?a/(o+i):a/(2-o-i),r=n==o?(t-e)/a+(e>t?6:0):t==o?(e-n)/a+2:(n-t)/a+4,r*=60):(r=0/0,u=c>0&&1>c?0:r),V(r,u,c)}function ft(n,t,e){n=ht(n),t=ht(t),e=ht(e);var r=et((.4124564*n+.3575761*t+.1804375*e)/sa),u=et((.2126729*n+.7151522*t+.072175*e)/fa),i=et((.0193339*n+.119192*t+.9503041*e)/ha);return G(116*u-16,500*(r-u),200*(u-i))}function ht(n){return(n/=255)<=.04045?n/12.92:Math.pow((n+.055)/1.055,2.4)}function gt(n){var t=parseFloat(n);return"%"===n.charAt(n.length-1)?Math.round(2.55*t):t}function pt(n){return"function"==typeof n?n:function(){return n}}function dt(n){return n}function vt(n){return function(t,e,r){return 2===arguments.length&&"function"==typeof e&&(r=e,e=null),mt(t,e,n,r)}}function mt(n,t,e,r){function u(){var n,t=c.status;if(!t&&c.responseText||t>=200&&300>t||304===t){try{n=e.call(i,c)}catch(r){return o.error.call(i,r),void 0}o.load.call(i,n)}else o.error.call(i,c)}var i={},o=mo.dispatch("beforesend","progress","load","error"),a={},c=new XMLHttpRequest,l=null;return!_o.XDomainRequest||"withCredentials"in c||!/^(http(s)?:)?\/\//.test(n)||(c=new XDomainRequest),"onload"in c?c.onload=c.onerror=u:c.onreadystatechange=function(){c.readyState>3&&u()},c.onprogress=function(n){var t=mo.event;mo.event=n;try{o.progress.call(i,c)}finally{mo.event=t}},i.header=function(n,t){return n=(n+"").toLowerCase(),arguments.length<2?a[n]:(null==t?delete a[n]:a[n]=t+"",i)},i.mimeType=function(n){return arguments.length?(t=null==n?null:n+"",i):t},i.responseType=function(n){return arguments.length?(l=n,i):l},i.response=function(n){return e=n,i},["get","post"].forEach(function(n){i[n]=function(){return i.send.apply(i,[n].concat(Mo(arguments)))}}),i.send=function(e,r,u){if(2===arguments.length&&"function"==typeof r&&(u=r,r=null),c.open(e,n,!0),null==t||"accept"in a||(a.accept=t+",*/*"),c.setRequestHeader)for(var s in a)c.setRequestHeader(s,a[s]);return null!=t&&c.overrideMimeType&&c.overrideMimeType(t),null!=l&&(c.responseType=l),null!=u&&i.on("error",u).on("load",function(n){u(null,n)}),o.beforesend.call(i,c),c.send(null==r?null:r),i},i.abort=function(){return c.abort(),i},mo.rebind(i,o,"on"),null==r?i:i.get(yt(r))}function yt(n){return 1===n.length?function(t,e){n(null==t?e:null)}:n}function Mt(){var n=bt(),t=_t()-n;t>24?(isFinite(t)&&(clearTimeout(Ma),Ma=setTimeout(Mt,t)),ya=0):(ya=1,ba(Mt))}function xt(n,t,e){var r=arguments.length;2>r&&(t=0),3>r&&(e=Date.now()),xa.callback=n,xa.time=e+t}function bt(){var n=Date.now();for(xa=va;xa;)n>=xa.time&&(xa.flush=xa.callback(n-xa.time)),xa=xa.next;return n}function _t(){for(var n,t=va,e=1/0;t;)t.flush?t=n?n.next=t.next:va=t.next:(t.time8?function(n){return n/e}:function(n){return n*e},symbol:n}}function St(n,t){return t-(n?Math.ceil(Math.log(n)/Math.LN10):1)}function Et(n){return n+""}function kt(){}function At(n,t,e){var r=e.s=n+t,u=r-n,i=r-u;e.t=n-i+(t-u)}function Nt(n,t){n&&Da.hasOwnProperty(n.type)&&Da[n.type](n,t)}function Tt(n,t,e){var r,u=-1,i=n.length-e;for(t.lineStart();++ua;++a)u.point((e=n[a])[0],e[1]);return u.lineEnd(),void 0}var c={point:e,points:n,other:null,visited:!1,entry:!0,subject:!0},l={point:e,points:[e],other:c,visited:!1,entry:!1,subject:!1};c.other=l,i.push(c),o.push(l),c={point:r,points:[r],other:null,visited:!1,entry:!1,subject:!0},l={point:r,points:[r],other:c,visited:!1,entry:!0,subject:!1},c.other=l,i.push(c),o.push(l)}}),o.sort(t),$t(i),$t(o),i.length){for(var a=0,c=e,l=o.length;l>a;++a)o[a].entry=c=!c;for(var s,f,h,g=i[0];;){for(s=g;s.visited;)if((s=s.next)===g)return;f=s.points,u.lineStart();do{if(s.visited=s.other.visited=!0,s.entry){if(s.subject)for(var a=0;a=0;)u.point((h=f[a])[0],h[1])}else r(s.point,s.prev.point,-1,u);s=s.prev}s=s.other,f=s.points}while(!s.visited);u.lineEnd()}}}function $t(n){if(t=n.length){for(var t,e,r=0,u=n[0];++r1&&2&t&&e.push(e.pop().concat(e.shift())),g.push(e.filter(Wt))}}var g,p,d,v=t(i),m=u.invert(r[0],r[1]),y={point:o,lineStart:c,lineEnd:l,polygonStart:function(){y.point=s,y.lineStart=f,y.lineEnd=h,g=[],p=[],i.polygonStart()},polygonEnd:function(){y.point=o,y.lineStart=c,y.lineEnd=l,g=mo.merge(g);var n=Kt(m,p);g.length?Xt(g,Gt,n,e,i):n&&(i.lineStart(),e(null,null,1,i),i.lineEnd()),i.polygonEnd(),g=p=null},sphere:function(){i.polygonStart(),i.lineStart(),e(null,null,1,i),i.lineEnd(),i.polygonEnd()}},M=Jt(),x=t(M);return y}}function Wt(n){return n.length>1}function Jt(){var n,t=[];return{lineStart:function(){t.push(n=[])},point:function(t,e){n.push([t,e])},lineEnd:c,buffer:function(){var e=t;return t=[],n=null,e},rejoin:function(){t.length>1&&t.push(t.pop().concat(t.shift()))}}}function Gt(n,t){return((n=n.point)[0]<0?n[1]-Jo-Go:Jo-n[1])-((t=t.point)[0]<0?t[1]-Jo-Go:Jo-t[1])}function Kt(n,t){var e=n[0],r=n[1],u=[Math.sin(e),-Math.cos(e),0],i=0,o=0;La.reset();for(var a=0,c=t.length;c>a;++a){var l=t[a],s=l.length;if(s)for(var f=l[0],h=f[0],g=f[1]/2+Bo/4,p=Math.sin(g),d=Math.cos(g),v=1;;){v===s&&(v=0),n=l[v];var m=n[0],y=n[1]/2+Bo/4,M=Math.sin(y),x=Math.cos(y),b=m-h,_=Math.abs(b)>Bo,w=p*M;if(La.add(Math.atan2(w*Math.sin(b),d*x+w*Math.cos(b))),i+=_?b+(b>=0?2:-2)*Bo:b,_^h>=e^m>=e){var S=jt(Ct(f),Ct(n));Ft(S);var E=jt(u,S);Ft(E);var k=(_^b>=0?-1:1)*O(E[2]);(r>k||r===k&&(S[0]||S[1]))&&(o+=_^b>=0?1:-1)}if(!v++)break;h=m,p=M,d=x,f=n}}return(-Go>i||Go>i&&0>La)^1&o}function Qt(n){var t,e=0/0,r=0/0,u=0/0;return{lineStart:function(){n.lineStart(),t=1},point:function(i,o){var a=i>0?Bo:-Bo,c=Math.abs(i-e);Math.abs(c-Bo)0?Jo:-Jo),n.point(u,r),n.lineEnd(),n.lineStart(),n.point(a,r),n.point(i,r),t=0):u!==a&&c>=Bo&&(Math.abs(e-u)Go?Math.atan((Math.sin(t)*(i=Math.cos(r))*Math.sin(e)-Math.sin(r)*(u=Math.cos(t))*Math.sin(n))/(u*i*o)):(t+r)/2}function te(n,t,e,r){var u;if(null==n)u=e*Jo,r.point(-Bo,u),r.point(0,u),r.point(Bo,u),r.point(Bo,0),r.point(Bo,-u),r.point(0,-u),r.point(-Bo,-u),r.point(-Bo,0),r.point(-Bo,u);else if(Math.abs(n[0]-t[0])>Go){var i=(n[0]i}function e(n){var e,i,c,l,s;return{lineStart:function(){l=c=!1,s=1},point:function(f,h){var g,p=[f,h],d=t(f,h),v=o?d?0:u(f,h):d?u(f+(0>f?Bo:-Bo),h):0;if(!e&&(l=c=d)&&n.lineStart(),d!==c&&(g=r(e,p),(Ot(e,g)||Ot(p,g))&&(p[0]+=Go,p[1]+=Go,d=t(p[0],p[1]))),d!==c)s=0,d?(n.lineStart(),g=r(p,e),n.point(g[0],g[1])):(g=r(e,p),n.point(g[0],g[1]),n.lineEnd()),e=g;else if(a&&e&&o^d){var m;v&i||!(m=r(p,e,!0))||(s=0,o?(n.lineStart(),n.point(m[0][0],m[0][1]),n.point(m[1][0],m[1][1]),n.lineEnd()):(n.point(m[1][0],m[1][1]),n.lineEnd(),n.lineStart(),n.point(m[0][0],m[0][1])))}!d||e&&Ot(e,p)||n.point(p[0],p[1]),e=p,c=d,i=v},lineEnd:function(){c&&n.lineEnd(),e=null},clean:function(){return s|(l&&c)<<1}}}function r(n,t,e){var r=Ct(n),u=Ct(t),o=[1,0,0],a=jt(r,u),c=Dt(a,a),l=a[0],s=c-l*l;if(!s)return!e&&n;var f=i*c/s,h=-i*l/s,g=jt(o,a),p=Ht(o,f),d=Ht(a,h);Lt(p,d);var v=g,m=Dt(p,v),y=Dt(v,v),M=m*m-y*(Dt(p,p)-1);if(!(0>M)){var x=Math.sqrt(M),b=Ht(v,(-m-x)/y);if(Lt(b,p),b=Pt(b),!e)return b;var _,w=n[0],S=t[0],E=n[1],k=t[1];w>S&&(_=w,w=S,S=_);var A=S-w,N=Math.abs(A-Bo)A;if(!N&&E>k&&(_=E,E=k,k=_),T?N?E+k>0^b[1]<(Math.abs(b[0]-w)Bo^(w<=b[0]&&b[0]<=S)){var q=Ht(v,(-m+x)/y);return Lt(q,p),[b,Pt(q)]}}}function u(t,e){var r=o?n:Bo-n,u=0;return-r>t?u|=1:t>r&&(u|=2),-r>e?u|=4:e>r&&(u|=8),u}var i=Math.cos(n),o=i>0,a=Math.abs(i)>Go,c=Te(n,6*Qo);return Bt(t,e,c,o?[0,-n]:[-Bo,n-Bo])}function re(n,t,e,r){function u(r,u){return Math.abs(r[0]-n)0?0:3:Math.abs(r[0]-e)0?2:1:Math.abs(r[1]-t)0?1:0:u>0?3:2}function i(n,t){return o(n.point,t.point)}function o(n,t){var e=u(n,1),r=u(t,1);return e!==r?e-r:0===e?t[1]-n[1]:1===e?n[0]-t[0]:2===e?n[1]-t[1]:t[0]-n[0]}function a(u,i){var o=i[0]-u[0],a=i[1]-u[1],c=[0,1];return Math.abs(o)0&&(u[0]+=c[0]*o,u[1]+=c[0]*a),!0):!1}return function(c){function l(n){for(var t=0,e=y.length,r=n[1],u=0;e>u;++u)for(var i,o=1,a=y[u],c=a.length,l=a[0];c>o;++o)i=a[o],l[1]<=r?i[1]>r&&s(l,i,n)>0&&++t:i[1]<=r&&s(l,i,n)<0&&--t,l=i;return 0!==t}function s(n,t,e){return(t[0]-n[0])*(e[1]-n[1])-(e[0]-n[0])*(t[1]-n[1])}function f(i,a,c,l){var s=0,f=0;if(null==i||(s=u(i,c))!==(f=u(a,c))||o(i,a)<0^c>0){do l.point(0===s||3===s?n:e,s>1?r:t);while((s=(s+c+4)%4)!==f)}else l.point(a[0],a[1])}function h(u,i){return u>=n&&e>=u&&i>=t&&r>=i}function g(n,t){h(n,t)&&c.point(n,t)}function p(){q.point=v,y&&y.push(M=[]),k=!0,E=!1,w=S=0/0}function d(){m&&(v(x,b),_&&E&&T.rejoin(),m.push(T.buffer())),q.point=g,E&&c.lineEnd()}function v(n,t){n=Math.max(-Ja,Math.min(Ja,n)),t=Math.max(-Ja,Math.min(Ja,t));var e=h(n,t);if(y&&M.push([n,t]),k)x=n,b=t,_=e,k=!1,e&&(c.lineStart(),c.point(n,t));else if(e&&E)c.point(n,t);else{var r=[w,S],u=[n,t];a(r,u)?(E||(c.lineStart(),c.point(r[0],r[1])),c.point(u[0],u[1]),e||c.lineEnd(),A=!1):e&&(c.lineStart(),c.point(n,t),A=!1)}w=n,S=t,E=e}var m,y,M,x,b,_,w,S,E,k,A,N=c,T=Jt(),q={point:g,lineStart:p,lineEnd:d,polygonStart:function(){c=T,m=[],y=[],A=!0},polygonEnd:function(){c=N,m=mo.merge(m);var t=l([n,r]),e=A&&t,u=m.length;(e||u)&&(c.polygonStart(),e&&(c.lineStart(),f(null,null,1,c),c.lineEnd()),u&&Xt(m,i,t,f,c),c.polygonEnd()),m=y=M=null}};return q}}function ue(n,t,e){if(Math.abs(t)=n;var r=n/t;if(t>0){if(r>e[1])return!1;r>e[0]&&(e[0]=r)}else{if(rn&&(Qa=n),n>tc&&(tc=n),nc>t&&(nc=t),t>ec&&(ec=t)}function se(){function n(n,t){o.push("M",n,",",t,i)}function t(n,t){o.push("M",n,",",t),a.point=e}function e(n,t){o.push("L",n,",",t)}function r(){a.point=n}function u(){o.push("Z")}var i=fe(4.5),o=[],a={point:n,lineStart:function(){a.point=t},lineEnd:r,polygonStart:function(){a.lineEnd=u},polygonEnd:function(){a.lineEnd=r,a.point=n},pointRadius:function(n){return i=fe(n),a},result:function(){if(o.length){var n=o.join("");return o=[],n}}};return a}function fe(n){return"m0,"+n+"a"+n+","+n+" 0 1,1 0,"+-2*n+"a"+n+","+n+" 0 1,1 0,"+2*n+"z"}function he(n,t){Oa+=n,Ra+=t,++Ya}function ge(){function n(n,r){var u=n-t,i=r-e,o=Math.sqrt(u*u+i*i);Ia+=o*(t+n)/2,Ua+=o*(e+r)/2,Za+=o,he(t=n,e=r)}var t,e;ic.point=function(r,u){ic.point=n,he(t=r,e=u)}}function pe(){ic.point=he}function de(){function n(n,t){var e=n-r,i=t-u,o=Math.sqrt(e*e+i*i);Ia+=o*(r+n)/2,Ua+=o*(u+t)/2,Za+=o,o=u*n-r*t,Va+=o*(r+n),Xa+=o*(u+t),$a+=3*o,he(r=n,u=t)}var t,e,r,u;ic.point=function(i,o){ic.point=n,he(t=r=i,e=u=o)},ic.lineEnd=function(){n(t,e)}}function ve(n){function t(t,e){n.moveTo(t,e),n.arc(t,e,o,0,Wo)}function e(t,e){n.moveTo(t,e),a.point=r}function r(t,e){n.lineTo(t,e)}function u(){a.point=t}function i(){n.closePath()}var o=4.5,a={point:t,lineStart:function(){a.point=e},lineEnd:u,polygonStart:function(){a.lineEnd=i},polygonEnd:function(){a.lineEnd=u,a.point=t},pointRadius:function(n){return o=n,a},result:c};return a}function me(n){function t(t){function r(e,r){e=n(e,r),t.point(e[0],e[1])}function u(){M=0/0,S.point=o,t.lineStart()}function o(r,u){var o=Ct([r,u]),a=n(r,u);e(M,x,y,b,_,w,M=a[0],x=a[1],y=r,b=o[0],_=o[1],w=o[2],i,t),t.point(M,x)}function a(){S.point=r,t.lineEnd()}function c(){u(),S.point=l,S.lineEnd=s}function l(n,t){o(f=n,h=t),g=M,p=x,d=b,v=_,m=w,S.point=o}function s(){e(M,x,y,b,_,w,g,p,f,d,v,m,i,t),S.lineEnd=a,a()}var f,h,g,p,d,v,m,y,M,x,b,_,w,S={point:r,lineStart:u,lineEnd:a,polygonStart:function(){t.polygonStart(),S.lineStart=c},polygonEnd:function(){t.polygonEnd(),S.lineStart=u}};return S}function e(t,i,o,a,c,l,s,f,h,g,p,d,v,m){var y=s-t,M=f-i,x=y*y+M*M;if(x>4*r&&v--){var b=a+g,_=c+p,w=l+d,S=Math.sqrt(b*b+_*_+w*w),E=Math.asin(w/=S),k=Math.abs(Math.abs(w)-1)r||Math.abs((y*q+M*z)/x-.5)>.3||u>a*g+c*p+l*d)&&(e(t,i,o,a,c,l,N,T,k,b/=S,_/=S,w,v,m),m.point(N,T),e(N,T,k,b,_,w,s,f,h,g,p,d,v,m))}}var r=.5,u=Math.cos(30*Qo),i=16;return t.precision=function(n){return arguments.length?(i=(r=n*n)>0&&16,t):Math.sqrt(r)},t}function ye(n){this.stream=n}function Me(n){var t=me(function(t,e){return n([t*na,e*na])});return function(n){var e=new ye(n=t(n));return e.point=function(t,e){n.point(t*Qo,e*Qo)},e}}function xe(n){return be(function(){return n})()}function be(n){function t(n){return n=a(n[0]*Qo,n[1]*Qo),[n[0]*h+c,l-n[1]*h]}function e(n){return n=a.invert((n[0]-c)/h,(l-n[1])/h),n&&[n[0]*na,n[1]*na]}function r(){a=ie(o=Ee(m,y,M),i);var n=i(d,v);return c=g-n[0]*h,l=p+n[1]*h,u()}function u(){return s&&(s.valid=!1,s=null),t}var i,o,a,c,l,s,f=me(function(n,t){return n=i(n,t),[n[0]*h+c,l-n[1]*h]}),h=150,g=480,p=250,d=0,v=0,m=0,y=0,M=0,x=Wa,b=dt,_=null,w=null;return t.stream=function(n){return s&&(s.valid=!1),s=_e(x(o,f(b(n)))),s.valid=!0,s},t.clipAngle=function(n){return arguments.length?(x=null==n?(_=n,Wa):ee((_=+n)*Qo),u()):_},t.clipExtent=function(n){return arguments.length?(w=n,b=n?re(n[0][0],n[0][1],n[1][0],n[1][1]):dt,u()):w},t.scale=function(n){return arguments.length?(h=+n,r()):h},t.translate=function(n){return arguments.length?(g=+n[0],p=+n[1],r()):[g,p]},t.center=function(n){return arguments.length?(d=n[0]%360*Qo,v=n[1]%360*Qo,r()):[d*na,v*na]},t.rotate=function(n){return arguments.length?(m=n[0]%360*Qo,y=n[1]%360*Qo,M=n.length>2?n[2]%360*Qo:0,r()):[m*na,y*na,M*na]},mo.rebind(t,f,"precision"),function(){return i=n.apply(this,arguments),t.invert=i.invert&&e,r()}}function _e(n){var t=new ye(n);return t.point=function(t,e){n.point(t*Qo,e*Qo)},t}function we(n,t){return[n,t]}function Se(n,t){return[n>Bo?n-Wo:-Bo>n?n+Wo:n,t]}function Ee(n,t,e){return n?t||e?ie(Ae(n),Ne(t,e)):Ae(n):t||e?Ne(t,e):Se}function ke(n){return function(t,e){return t+=n,[t>Bo?t-Wo:-Bo>t?t+Wo:t,e]}}function Ae(n){var t=ke(n);return t.invert=ke(-n),t}function Ne(n,t){function e(n,t){var e=Math.cos(t),a=Math.cos(n)*e,c=Math.sin(n)*e,l=Math.sin(t),s=l*r+a*u;return[Math.atan2(c*i-s*o,a*r-l*u),O(s*i+c*o)]}var r=Math.cos(n),u=Math.sin(n),i=Math.cos(t),o=Math.sin(t);return e.invert=function(n,t){var e=Math.cos(t),a=Math.cos(n)*e,c=Math.sin(n)*e,l=Math.sin(t),s=l*i-c*o;return[Math.atan2(c*i+l*o,a*r+s*u),O(s*r-a*u)]},e}function Te(n,t){var e=Math.cos(n),r=Math.sin(n);return function(u,i,o,a){var c=o*t;null!=u?(u=qe(e,u),i=qe(e,i),(o>0?i>u:u>i)&&(u+=o*Wo)):(u=n+o*Wo,i=n-.5*c);for(var l,s=u;o>0?s>i:i>s;s-=c)a.point((l=Pt([e,-r*Math.cos(s),-r*Math.sin(s)]))[0],l[1])}}function qe(n,t){var e=Ct(t);e[0]-=n,Ft(e);var r=P(-e[1]);return((-e[2]<0?-r:r)+2*Math.PI-Go)%(2*Math.PI)}function ze(n,t,e){var r=mo.range(n,t-Go,e).concat(t);return function(n){return r.map(function(t){return[n,t]})}}function Ce(n,t,e){var r=mo.range(n,t-Go,e).concat(t);return function(n){return r.map(function(t){return[t,n]})}}function De(n){return n.source}function je(n){return n.target}function Le(n,t,e,r){var u=Math.cos(t),i=Math.sin(t),o=Math.cos(r),a=Math.sin(r),c=u*Math.cos(n),l=u*Math.sin(n),s=o*Math.cos(e),f=o*Math.sin(e),h=2*Math.asin(Math.sqrt(U(r-t)+u*o*U(e-n))),g=1/Math.sin(h),p=h?function(n){var t=Math.sin(n*=h)*g,e=Math.sin(h-n)*g,r=e*c+t*s,u=e*l+t*f,o=e*i+t*a;return[Math.atan2(u,r)*na,Math.atan2(o,Math.sqrt(r*r+u*u))*na]}:function(){return[n*na,t*na]};return p.distance=h,p}function He(){function n(n,u){var i=Math.sin(u*=Qo),o=Math.cos(u),a=Math.abs((n*=Qo)-t),c=Math.cos(a);oc+=Math.atan2(Math.sqrt((a=o*Math.sin(a))*a+(a=r*i-e*o*c)*a),e*i+r*o*c),t=n,e=i,r=o}var t,e,r;ac.point=function(u,i){t=u*Qo,e=Math.sin(i*=Qo),r=Math.cos(i),ac.point=n},ac.lineEnd=function(){ac.point=ac.lineEnd=c}}function Fe(n,t){function e(t,e){var r=Math.cos(t),u=Math.cos(e),i=n(r*u);return[i*u*Math.sin(t),i*Math.sin(e)]}return e.invert=function(n,e){var r=Math.sqrt(n*n+e*e),u=t(r),i=Math.sin(u),o=Math.cos(u);return[Math.atan2(n*i,r*o),Math.asin(r&&e*i/r)]},e}function Pe(n,t){function e(n,t){var e=Math.abs(Math.abs(t)-Jo)1&&u.push("H",r[0]),u.join("")}function We(n){for(var t=0,e=n.length,r=n[0],u=[r[0],",",r[1]];++t1){a=t[1],i=n[c],c++,r+="C"+(u[0]+o[0])+","+(u[1]+o[1])+","+(i[0]-a[0])+","+(i[1]-a[1])+","+i[0]+","+i[1];for(var l=2;l9&&(u=3*t/Math.sqrt(u),o[a]=u*e,o[a+1]=u*r));for(a=-1;++a<=c;)u=(n[Math.min(c,a+1)][0]-n[Math.max(0,a-1)][0])/(6*(1+o[a]*o[a])),i.push([u||0,o[a]*u||0]);return i}function fr(n){return n.length<3?Xe(n):n[0]+nr(n,sr(n))}function hr(n,t,e,r){var u,i,o,a,c,l,s;return u=r[n],i=u[0],o=u[1],u=r[t],a=u[0],c=u[1],u=r[e],l=u[0],s=u[1],(s-o)*(a-i)-(c-o)*(l-i)>0}function gr(n,t,e){return(e[0]-t[0])*(n[1]-t[1])<(e[1]-t[1])*(n[0]-t[0])}function pr(n,t,e,r){var u=n[0],i=e[0],o=t[0]-u,a=r[0]-i,c=n[1],l=e[1],s=t[1]-c,f=r[1]-l,h=(a*(c-l)-f*(u-i))/(f*o-a*s);return[u+h*o,c+h*s]}function dr(n){var t=n[0],e=n[n.length-1];return!(t[0]-e[0]||t[1]-e[1])}function vr(n,t){var e={list:n.map(function(n,t){return{index:t,x:n[0],y:n[1]}}).sort(function(n,t){return n.yt.y?1:n.xt.x?1:0}),bottomSite:null},r={list:[],leftEnd:null,rightEnd:null,init:function(){r.leftEnd=r.createHalfEdge(null,"l"),r.rightEnd=r.createHalfEdge(null,"l"),r.leftEnd.r=r.rightEnd,r.rightEnd.l=r.leftEnd,r.list.unshift(r.leftEnd,r.rightEnd)},createHalfEdge:function(n,t){return{edge:n,side:t,vertex:null,l:null,r:null}},insert:function(n,t){t.l=n,t.r=n.r,n.r.l=t,n.r=t},leftBound:function(n){var t=r.leftEnd;do t=t.r;while(t!=r.rightEnd&&u.rightOf(t,n));return t=t.l},del:function(n){n.l.r=n.r,n.r.l=n.l,n.edge=null},right:function(n){return n.r},left:function(n){return n.l},leftRegion:function(n){return null==n.edge?e.bottomSite:n.edge.region[n.side]},rightRegion:function(n){return null==n.edge?e.bottomSite:n.edge.region[yc[n.side]]}},u={bisect:function(n,t){var e={region:{l:n,r:t},ep:{l:null,r:null}},r=t.x-n.x,u=t.y-n.y,i=r>0?r:-r,o=u>0?u:-u;return e.c=n.x*r+n.y*u+.5*(r*r+u*u),i>o?(e.a=1,e.b=u/r,e.c/=r):(e.b=1,e.a=r/u,e.c/=u),e},intersect:function(n,t){var e=n.edge,r=t.edge;if(!e||!r||e.region.r==r.region.r)return null;var u=e.a*r.b-e.b*r.a;if(Math.abs(u)<1e-10)return null;var i,o,a=(e.c*r.b-r.c*e.b)/u,c=(r.c*e.a-e.c*r.a)/u,l=e.region.r,s=r.region.r;l.y=o.region.r.x;return f&&"l"===i.side||!f&&"r"===i.side?null:{x:a,y:c}},rightOf:function(n,t){var e=n.edge,r=e.region.r,u=t.x>r.x;if(u&&"l"===n.side)return 1;if(!u&&"r"===n.side)return 0;if(1===e.a){var i=t.y-r.y,o=t.x-r.x,a=0,c=0;if(!u&&e.b<0||u&&e.b>=0?c=a=i>=e.b*o:(c=t.x+t.y*e.b>e.c,e.b<0&&(c=!c),c||(a=1)),!a){var l=r.x-e.region.l.x;c=e.b*(o*o-i*i)h*h+g*g}return"l"===n.side?c:!c},endPoint:function(n,e,r){n.ep[e]=r,n.ep[yc[e]]&&t(n)},distance:function(n,t){var e=n.x-t.x,r=n.y-t.y;return Math.sqrt(e*e+r*r)}},i={list:[],insert:function(n,t,e){n.vertex=t,n.ystar=t.y+e;for(var r=0,u=i.list,o=u.length;o>r;r++){var a=u[r];if(!(n.ystar>a.ystar||n.ystar==a.ystar&&t.x>a.vertex.x))break}u.splice(r,0,n)},del:function(n){for(var t=0,e=i.list,r=e.length;r>t&&e[t]!=n;++t);e.splice(t,1)},empty:function(){return 0===i.list.length},nextEvent:function(n){for(var t=0,e=i.list,r=e.length;r>t;++t)if(e[t]==n)return e[t+1];return null},min:function(){var n=i.list[0];return{x:n.vertex.x,y:n.ystar}},extractMin:function(){return i.list.shift()}};r.init(),e.bottomSite=e.list.shift();for(var o,a,c,l,s,f,h,g,p,d,v,m,y,M=e.list.shift();;)if(i.empty()||(o=i.min()),M&&(i.empty()||M.yg.y&&(p=h,h=g,g=p,y="r"),m=u.bisect(h,g),f=r.createHalfEdge(m,y),r.insert(l,f),u.endPoint(m,yc[y],v),d=u.intersect(l,f),d&&(i.del(l),i.insert(l,d,u.distance(d,h))),d=u.intersect(f,s),d&&i.insert(f,d,u.distance(d,h))}for(a=r.right(r.leftEnd);a!=r.rightEnd;a=r.right(a))t(a.edge)}function mr(n){return n.x}function yr(n){return n.y}function Mr(){return{leaf:!0,nodes:[],point:null,x:null,y:null}}function xr(n,t,e,r,u,i){if(!n(t,e,r,u,i)){var o=.5*(e+u),a=.5*(r+i),c=t.nodes;c[0]&&xr(n,c[0],e,r,o,a),c[1]&&xr(n,c[1],o,r,u,a),c[2]&&xr(n,c[2],e,a,o,i),c[3]&&xr(n,c[3],o,a,u,i)}}function br(n,t){n=mo.rgb(n),t=mo.rgb(t);var e=n.r,r=n.g,u=n.b,i=t.r-e,o=t.g-r,a=t.b-u;return function(n){return"#"+ct(Math.round(e+i*n))+ct(Math.round(r+o*n))+ct(Math.round(u+a*n))}}function _r(n,t){var e,r={},u={};for(e in n)e in t?r[e]=Er(n[e],t[e]):u[e]=n[e];for(e in t)e in n||(u[e]=t[e]);return function(n){for(e in r)u[e]=r[e](n);return u}}function wr(n,t){return t-=n=+n,function(e){return n+t*e}}function Sr(n,t){var e,r,u,i,o,a=0,c=0,l=[],s=[];for(n+="",t+="",Mc.lastIndex=0,r=0;e=Mc.exec(t);++r)e.index&&l.push(t.substring(a,c=e.index)),s.push({i:l.length,x:e[0]}),l.push(null),a=Mc.lastIndex;for(ar;++r)if(o=s[r],o.x==e[0]){if(o.i)if(null==l[o.i+1])for(l[o.i-1]+=o.x,l.splice(o.i,1),u=r+1;i>u;++u)s[u].i--;else for(l[o.i-1]+=o.x+l[o.i+1],l.splice(o.i,2),u=r+1;i>u;++u)s[u].i-=2;else if(null==l[o.i+1])l[o.i]=o.x;else for(l[o.i]=o.x+l[o.i+1],l.splice(o.i+1,1),u=r+1;i>u;++u)s[u].i--;s.splice(r,1),i--,r--}else o.x=wr(parseFloat(e[0]),parseFloat(o.x));for(;i>r;)o=s.pop(),null==l[o.i+1]?l[o.i]=o.x:(l[o.i]=o.x+l[o.i+1],l.splice(o.i+1,1)),i--;return 1===l.length?null==l[0]?(o=s[0].x,function(n){return o(n)+""}):function(){return t}:function(n){for(r=0;i>r;++r)l[(o=s[r]).i]=o.x(n);return l.join("")}}function Er(n,t){for(var e,r=mo.interpolators.length;--r>=0&&!(e=mo.interpolators[r](n,t)););return e}function kr(n,t){var e,r=[],u=[],i=n.length,o=t.length,a=Math.min(n.length,t.length);for(e=0;a>e;++e)r.push(Er(n[e],t[e]));for(;i>e;++e)u[e]=n[e];for(;o>e;++e)u[e]=t[e];return function(n){for(e=0;a>e;++e)u[e]=r[e](n);return u}}function Ar(n){return function(t){return 0>=t?0:t>=1?1:n(t)}}function Nr(n){return function(t){return 1-n(1-t)}}function Tr(n){return function(t){return.5*(.5>t?n(2*t):2-n(2-2*t))}}function qr(n){return n*n}function zr(n){return n*n*n}function Cr(n){if(0>=n)return 0;if(n>=1)return 1;var t=n*n,e=t*n;return 4*(.5>n?e:3*(n-t)+e-.75)}function Dr(n){return function(t){return Math.pow(t,n)}}function jr(n){return 1-Math.cos(n*Jo)}function Lr(n){return Math.pow(2,10*(n-1))}function Hr(n){return 1-Math.sqrt(1-n*n)}function Fr(n,t){var e;return arguments.length<2&&(t=.45),arguments.length?e=t/Wo*Math.asin(1/n):(n=1,e=t/4),function(r){return 1+n*Math.pow(2,-10*r)*Math.sin((r-e)*Wo/t)}}function Pr(n){return n||(n=1.70158),function(t){return t*t*((n+1)*t-n)}}function Or(n){return 1/2.75>n?7.5625*n*n:2/2.75>n?7.5625*(n-=1.5/2.75)*n+.75:2.5/2.75>n?7.5625*(n-=2.25/2.75)*n+.9375:7.5625*(n-=2.625/2.75)*n+.984375}function Rr(n,t){n=mo.hcl(n),t=mo.hcl(t);var e=n.h,r=n.c,u=n.l,i=t.h-e,o=t.c-r,a=t.l-u;return isNaN(o)&&(o=0,r=isNaN(r)?t.c:r),isNaN(i)?(i=0,e=isNaN(e)?t.h:e):i>180?i-=360:-180>i&&(i+=360),function(n){return J(e+i*n,r+o*n,u+a*n)+""}}function Yr(n,t){n=mo.hsl(n),t=mo.hsl(t);var e=n.h,r=n.s,u=n.l,i=t.h-e,o=t.s-r,a=t.l-u;return isNaN(o)&&(o=0,r=isNaN(r)?t.s:r),isNaN(i)?(i=0,e=isNaN(e)?t.h:e):i>180?i-=360:-180>i&&(i+=360),function(n){return $(e+i*n,r+o*n,u+a*n)+""}}function Ir(n,t){n=mo.lab(n),t=mo.lab(t);var e=n.l,r=n.a,u=n.b,i=t.l-e,o=t.a-r,a=t.b-u;return function(n){return Q(e+i*n,r+o*n,u+a*n)+""}}function Ur(n,t){return t-=n,function(e){return Math.round(n+t*e)}}function Zr(n){var t=[n.a,n.b],e=[n.c,n.d],r=Xr(t),u=Vr(t,e),i=Xr($r(e,t,-u))||0;t[0]*e[1]180?s+=360:s-l>180&&(l+=360),u.push({i:r.push(r.pop()+"rotate(",null,")")-2,x:wr(l,s)})):s&&r.push(r.pop()+"rotate("+s+")"),f!=h?u.push({i:r.push(r.pop()+"skewX(",null,")")-2,x:wr(f,h)}):h&&r.push(r.pop()+"skewX("+h+")"),g[0]!=p[0]||g[1]!=p[1]?(e=r.push(r.pop()+"scale(",null,",",null,")"),u.push({i:e-4,x:wr(g[0],p[0])},{i:e-2,x:wr(g[1],p[1])})):(1!=p[0]||1!=p[1])&&r.push(r.pop()+"scale("+p+")"),e=u.length,function(n){for(var t,i=-1;++ie;++e)(t=n[e][1])>u&&(r=e,u=t);return r}function vu(n){return n.reduce(mu,0)}function mu(n,t){return n+t[1]}function yu(n,t){return Mu(n,Math.ceil(Math.log(t.length)/Math.LN2+1))}function Mu(n,t){for(var e=-1,r=+n[0],u=(n[1]-r)/t,i=[];++e<=t;)i[e]=u*e+r;return i}function xu(n){return[mo.min(n),mo.max(n)]}function bu(n,t){return n.parent==t.parent?1:2}function _u(n){var t=n.children;return t&&t.length?t[0]:n._tree.thread}function wu(n){var t,e=n.children;return e&&(t=e.length)?e[t-1]:n._tree.thread}function Su(n,t){var e=n.children;if(e&&(u=e.length))for(var r,u,i=-1;++i0&&(n=r);return n}function Eu(n,t){return n.x-t.x}function ku(n,t){return t.x-n.x}function Au(n,t){return n.depth-t.depth}function Nu(n,t){function e(n,r){var u=n.children;if(u&&(o=u.length))for(var i,o,a=null,c=-1;++c=0;)t=u[i]._tree,t.prelim+=e,t.mod+=e,e+=t.shift+(r+=t.change)}function qu(n,t,e){n=n._tree,t=t._tree;var r=e/(t.number-n.number);n.change+=r,t.change-=r,t.shift+=e,t.prelim+=e,t.mod+=e}function zu(n,t,e){return n._tree.ancestor.parent==t.parent?n._tree.ancestor:e}function Cu(n,t){return n.value-t.value}function Du(n,t){var e=n._pack_next;n._pack_next=t,t._pack_prev=n,t._pack_next=e,e._pack_prev=t}function ju(n,t){n._pack_next=t,t._pack_prev=n}function Lu(n,t){var e=t.x-n.x,r=t.y-n.y,u=n.r+t.r;return.999*u*u>e*e+r*r}function Hu(n){function t(n){s=Math.min(n.x-n.r,s),f=Math.max(n.x+n.r,f),h=Math.min(n.y-n.r,h),g=Math.max(n.y+n.r,g)}if((e=n.children)&&(l=e.length)){var e,r,u,i,o,a,c,l,s=1/0,f=-1/0,h=1/0,g=-1/0;if(e.forEach(Fu),r=e[0],r.x=-r.r,r.y=0,t(r),l>1&&(u=e[1],u.x=u.r,u.y=0,t(u),l>2))for(i=e[2],Ru(r,u,i),t(i),Du(r,i),r._pack_prev=i,Du(i,u),u=r._pack_next,o=3;l>o;o++){Ru(r,u,i=e[o]);var p=0,d=1,v=1;for(a=u._pack_next;a!==u;a=a._pack_next,d++)if(Lu(a,i)){p=1;break}if(1==p)for(c=r._pack_prev;c!==a._pack_prev&&!Lu(c,i);c=c._pack_prev,v++);p?(v>d||d==v&&u.ro;o++)i=e[o],i.x-=m,i.y-=y,M=Math.max(M,i.r+Math.sqrt(i.x*i.x+i.y*i.y));n.r=M,e.forEach(Pu)}}function Fu(n){n._pack_next=n._pack_prev=n}function Pu(n){delete n._pack_next,delete n._pack_prev}function Ou(n,t,e,r){var u=n.children;if(n.x=t+=r*n.x,n.y=e+=r*n.y,n.r*=r,u)for(var i=-1,o=u.length;++iu&&(e+=u/2,u=0),0>i&&(r+=i/2,i=0),{x:e,y:r,dx:u,dy:i}}function $u(n){var t=n[0],e=n[n.length-1];return e>t?[t,e]:[e,t]}function Bu(n){return n.rangeExtent?n.rangeExtent():$u(n.range())}function Wu(n,t,e,r){var u=e(n[0],n[1]),i=r(t[0],t[1]);return function(n){return i(u(n))}}function Ju(n,t){var e,r=0,u=n.length-1,i=n[r],o=n[u];return i>o&&(e=r,r=u,u=e,e=i,i=o,o=e),n[r]=t.floor(i),n[u]=t.ceil(o),n}function Gu(n){return n?{floor:function(t){return Math.floor(t/n)*n},ceil:function(t){return Math.ceil(t/n)*n}}:Tc}function Ku(n,t,e,r){var u=[],i=[],o=0,a=Math.min(n.length,t.length)-1;for(n[a]2?Ku:Wu,c=r?Jr:Wr;return o=u(n,t,c,e),a=u(t,n,c,Er),i}function i(n){return o(n)}var o,a;return i.invert=function(n){return a(n)},i.domain=function(t){return arguments.length?(n=t.map(Number),u()):n},i.range=function(n){return arguments.length?(t=n,u()):t},i.rangeRound=function(n){return i.range(n).interpolate(Ur)},i.clamp=function(n){return arguments.length?(r=n,u()):r},i.interpolate=function(n){return arguments.length?(e=n,u()):e},i.ticks=function(t){return ri(n,t)},i.tickFormat=function(t,e){return ui(n,t,e)},i.nice=function(t){return ti(n,t),u()},i.copy=function(){return Qu(n,t,e,r)},u()}function ni(n,t){return mo.rebind(n,t,"range","rangeRound","interpolate","clamp")}function ti(n,t){return Ju(n,Gu(ei(n,t)[2]))}function ei(n,t){null==t&&(t=10);var e=$u(n),r=e[1]-e[0],u=Math.pow(10,Math.floor(Math.log(r/t)/Math.LN10)),i=t/r*u;return.15>=i?u*=10:.35>=i?u*=5:.75>=i&&(u*=2),e[0]=Math.ceil(e[0]/u)*u,e[1]=Math.floor(e[1]/u)*u+.5*u,e[2]=u,e}function ri(n,t){return mo.range.apply(mo,ei(n,t))}function ui(n,t,e){var r=-Math.floor(Math.log(ei(n,t)[2])/Math.LN10+.01);return mo.format(e?e.replace(Aa,function(n,t,e,u,i,o,a,c,l,s){return[t,e,u,i,o,a,c,l||"."+(r-2*("%"===s)),s].join("")}):",."+r+"f")}function ii(n,t,e,r){function u(n){return(e?Math.log(0>n?0:n):-Math.log(n>0?0:-n))/Math.log(t)}function i(n){return e?Math.pow(t,n):-Math.pow(t,-n)}function o(t){return n(u(t))}return o.invert=function(t){return i(n.invert(t))},o.domain=function(t){return arguments.length?(e=t[0]>=0,n.domain((r=t.map(Number)).map(u)),o):r},o.base=function(e){return arguments.length?(t=+e,n.domain(r.map(u)),o):t},o.nice=function(){var t=Ju(r.map(u),e?Math:zc);return n.domain(t),r=t.map(i),o},o.ticks=function(){var n=$u(r),o=[],a=n[0],c=n[1],l=Math.floor(u(a)),s=Math.ceil(u(c)),f=t%1?2:t;if(isFinite(s-l)){if(e){for(;s>l;l++)for(var h=1;f>h;h++)o.push(i(l)*h);o.push(i(l))}else for(o.push(i(l));l++0;h--)o.push(i(l)*h);for(l=0;o[l]c;s--);o=o.slice(l,s)}return o},o.tickFormat=function(n,t){if(!arguments.length)return qc;arguments.length<2?t=qc:"function"!=typeof t&&(t=mo.format(t));var r,a=Math.max(.1,n/o.ticks().length),c=e?(r=1e-12,Math.ceil):(r=-1e-12,Math.floor);return function(n){return n/i(c(u(n)+r))<=a?t(n):""}},o.copy=function(){return ii(n.copy(),t,e,r)},ni(o,n)}function oi(n,t,e){function r(t){return n(u(t))}var u=ai(t),i=ai(1/t);return r.invert=function(t){return i(n.invert(t))},r.domain=function(t){return arguments.length?(n.domain((e=t.map(Number)).map(u)),r):e},r.ticks=function(n){return ri(e,n)},r.tickFormat=function(n,t){return ui(e,n,t)},r.nice=function(n){return r.domain(ti(e,n))},r.exponent=function(o){return arguments.length?(u=ai(t=o),i=ai(1/t),n.domain(e.map(u)),r):t},r.copy=function(){return oi(n.copy(),t,e)},ni(r,n)}function ai(n){return function(t){return 0>t?-Math.pow(-t,n):Math.pow(t,n)}}function ci(n,t){function e(e){return o[((i.get(e)||"range"===t.t&&i.set(e,n.push(e)))-1)%o.length]}function r(t,e){return mo.range(n.length).map(function(n){return t+e*n})}var i,o,a;return e.domain=function(r){if(!arguments.length)return n;n=[],i=new u;for(var o,a=-1,c=r.length;++ae?[0/0,0/0]:[e>0?u[e-1]:n[0],et?0/0:t/i+n,[t,t+1/i]},r.copy=function(){return si(n,t,e)},u()}function fi(n,t){function e(e){return e>=e?t[mo.bisect(n,e)]:void 0}return e.domain=function(t){return arguments.length?(n=t,e):n},e.range=function(n){return arguments.length?(t=n,e):t},e.invertExtent=function(e){return e=t.indexOf(e),[n[e-1],n[e]]},e.copy=function(){return fi(n,t)},e}function hi(n){function t(n){return+n}return t.invert=t,t.domain=t.range=function(e){return arguments.length?(n=e.map(t),t):n},t.ticks=function(t){return ri(n,t)},t.tickFormat=function(t,e){return ui(n,t,e)},t.copy=function(){return hi(n)},t}function gi(n){return n.innerRadius}function pi(n){return n.outerRadius}function di(n){return n.startAngle}function vi(n){return n.endAngle}function mi(n){for(var t,e,r,u=-1,i=n.length;++ue?l():(i.active=e,o.event&&o.event.start.call(n,s,t),o.tween.forEach(function(e,r){(r=r.call(n,s,t))&&p.push(r)}),c(r||1)?1:(xt(c,h,a),void 0))}function c(r){if(i.active!==e)return l();for(var u=r/g,a=f(u),c=p.length;c>0;)p[--c].call(n,a);return u>=1?(o.event&&o.event.end.call(n,s,t),l()):void 0}function l(){return--i.count?delete i[e]:delete n.__transition__,1}var s=n.__data__,f=o.ease,h=o.delay,g=o.duration,p=[];return r>=h?u(r-h):(xt(u,h,a),void 0)},0,a)}}function Ti(n,t){n.attr("transform",function(n){return"translate("+t(n)+",0)"})}function qi(n,t){n.attr("transform",function(n){return"translate(0,"+t(n)+")"})}function zi(){this._=new Date(arguments.length>1?Date.UTC.apply(this,arguments):arguments[0])}function Ci(n,t,e){function r(t){var e=n(t),r=i(e,1);return r-t>t-e?e:r}function u(e){return t(e=n(new Jc(e-1)),1),e}function i(n,e){return t(n=new Jc(+n),e),n}function o(n,r,i){var o=u(n),a=[];if(i>1)for(;r>o;)e(o)%i||a.push(new Date(+o)),t(o,1);else for(;r>o;)a.push(new Date(+o)),t(o,1);return a}function a(n,t,e){try{Jc=zi;var r=new zi;return r._=n,o(r,t,e)}finally{Jc=Date}}n.floor=n,n.round=r,n.ceil=u,n.offset=i,n.range=o;var c=n.utc=Di(n);return c.floor=c,c.round=Di(r),c.ceil=Di(u),c.offset=Di(i),c.range=a,n}function Di(n){return function(t,e){try{Jc=zi;var r=new zi;return r._=t,n(r,e)._}finally{Jc=Date}}}function ji(n){function t(t){for(var r,u,i,o=[],a=-1,c=0;++aa;){if(r>=l)return-1;if(u=t.charCodeAt(a++),37===u){if(o=t.charAt(a++),i=ml[o in dl?t.charAt(a++):o],!i||(r=i(n,e,r))<0)return-1}else if(u!=e.charCodeAt(r++))return-1}return r}function Hi(n){return new RegExp("^(?:"+n.map(mo.requote).join("|")+")","i")}function Fi(n){for(var t=new u,e=-1,r=n.length;++en?"-":"",u=(r?-n:n)+"",i=u.length;return r+(e>i?new Array(e-i+1).join(t)+u:u)}function Oi(n,t,e){cl.lastIndex=0;var r=cl.exec(t.substring(e));return r?(n.w=ll.get(r[0].toLowerCase()),e+r[0].length):-1}function Ri(n,t,e){ol.lastIndex=0;var r=ol.exec(t.substring(e));return r?(n.w=al.get(r[0].toLowerCase()),e+r[0].length):-1}function Yi(n,t,e){yl.lastIndex=0;var r=yl.exec(t.substring(e,e+1));return r?(n.w=+r[0],e+r[0].length):-1}function Ii(n,t,e){yl.lastIndex=0;var r=yl.exec(t.substring(e));return r?(n.U=+r[0],e+r[0].length):-1}function Ui(n,t,e){yl.lastIndex=0;var r=yl.exec(t.substring(e));return r?(n.W=+r[0],e+r[0].length):-1}function Zi(n,t,e){hl.lastIndex=0;var r=hl.exec(t.substring(e));return r?(n.m=gl.get(r[0].toLowerCase()),e+r[0].length):-1}function Vi(n,t,e){sl.lastIndex=0;var r=sl.exec(t.substring(e));return r?(n.m=fl.get(r[0].toLowerCase()),e+r[0].length):-1}function Xi(n,t,e){return Li(n,vl.c.toString(),t,e)}function $i(n,t,e){return Li(n,vl.x.toString(),t,e)}function Bi(n,t,e){return Li(n,vl.X.toString(),t,e)}function Wi(n,t,e){yl.lastIndex=0;var r=yl.exec(t.substring(e,e+4));return r?(n.y=+r[0],e+r[0].length):-1}function Ji(n,t,e){yl.lastIndex=0;var r=yl.exec(t.substring(e,e+2));return r?(n.y=Ki(+r[0]),e+r[0].length):-1}function Gi(n,t,e){return/^[+-]\d{4}$/.test(t=t.substring(e,e+5))?(n.Z=+t,e+5):-1}function Ki(n){return n+(n>68?1900:2e3)}function Qi(n,t,e){yl.lastIndex=0;var r=yl.exec(t.substring(e,e+2));return r?(n.m=r[0]-1,e+r[0].length):-1}function no(n,t,e){yl.lastIndex=0;var r=yl.exec(t.substring(e,e+2));return r?(n.d=+r[0],e+r[0].length):-1}function to(n,t,e){yl.lastIndex=0;var r=yl.exec(t.substring(e,e+3));return r?(n.j=+r[0],e+r[0].length):-1}function eo(n,t,e){yl.lastIndex=0;var r=yl.exec(t.substring(e,e+2));return r?(n.H=+r[0],e+r[0].length):-1}function ro(n,t,e){yl.lastIndex=0;var r=yl.exec(t.substring(e,e+2));return r?(n.M=+r[0],e+r[0].length):-1}function uo(n,t,e){yl.lastIndex=0;var r=yl.exec(t.substring(e,e+2));return r?(n.S=+r[0],e+r[0].length):-1}function io(n,t,e){yl.lastIndex=0;var r=yl.exec(t.substring(e,e+3));return r?(n.L=+r[0],e+r[0].length):-1}function oo(n,t,e){var r=Ml.get(t.substring(e,e+=2).toLowerCase());return null==r?-1:(n.p=r,e)}function ao(n){var t=n.getTimezoneOffset(),e=t>0?"-":"+",r=~~(Math.abs(t)/60),u=Math.abs(t)%60;return e+Pi(r,"0",2)+Pi(u,"0",2)}function co(n,t,e){pl.lastIndex=0;var r=pl.exec(t.substring(e,e+1));return r?e+r[0].length:-1}function lo(n){function t(n){try{Jc=zi;var t=new Jc;return t._=n,e(t)}finally{Jc=Date}}var e=ji(n);return t.parse=function(n){try{Jc=zi;var t=e.parse(n);return t&&t._}finally{Jc=Date}},t.toString=e.toString,t}function so(n){return n.toISOString()}function fo(n,t,e){function r(t){return n(t)}function u(n,e){var r=n[1]-n[0],u=r/e,i=mo.bisect(bl,u);return i==bl.length?[t.year,ei(n.map(function(n){return n/31536e6}),e)[2]]:i?t[u/bl[i-1]1?{floor:function(t){for(;e(t=n.floor(t));)t=ho(t-1);return t},ceil:function(t){for(;e(t=n.ceil(t));)t=ho(+t+1);return t}}:n))},r.ticks=function(n,t){var e=$u(r.domain()),i=null==n?u(e,10):"number"==typeof n?u(e,n):!n.range&&[{range:n},t];return i&&(n=i[0],t=i[1]),n.range(e[0],ho(+e[1]+1),1>t?1:t)},r.tickFormat=function(){return e},r.copy=function(){return fo(n.copy(),t,e)},ni(r,n)}function ho(n){return new Date(n)}function go(n){return function(t){for(var e=n.length-1,r=n[e];!r[1](t);)r=n[--e];return r[0](t)}}function po(n){return JSON.parse(n.responseText)}function vo(n){var t=xo.createRange();return t.selectNode(xo.body),t.createContextualFragment(n.responseText)}var mo={version:"3.3.6"};Date.now||(Date.now=function(){return+new Date});var yo=[].slice,Mo=function(n){return yo.call(n)},xo=document,bo=xo.documentElement,_o=window;try{Mo(bo.childNodes)[0].nodeType}catch(wo){Mo=function(n){for(var t=n.length,e=new Array(t);t--;)e[t]=n[t];return e}}try{xo.createElement("div").style.setProperty("opacity",0,"")}catch(So){var Eo=_o.Element.prototype,ko=Eo.setAttribute,Ao=Eo.setAttributeNS,No=_o.CSSStyleDeclaration.prototype,To=No.setProperty;Eo.setAttribute=function(n,t){ko.call(this,n,t+"")},Eo.setAttributeNS=function(n,t,e){Ao.call(this,n,t,e+"")},No.setProperty=function(n,t,e){To.call(this,n,t+"",e)}}mo.ascending=function(n,t){return t>n?-1:n>t?1:n>=t?0:0/0},mo.descending=function(n,t){return n>t?-1:t>n?1:t>=n?0:0/0},mo.min=function(n,t){var e,r,u=-1,i=n.length;if(1===arguments.length){for(;++u=e);)e=void 0;for(;++ur&&(e=r)}else{for(;++u=e);)e=void 0;for(;++ur&&(e=r)}return e},mo.max=function(n,t){var e,r,u=-1,i=n.length;if(1===arguments.length){for(;++u=e);)e=void 0;for(;++ue&&(e=r)}else{for(;++u=e);)e=void 0;for(;++ue&&(e=r)}return e},mo.extent=function(n,t){var e,r,u,i=-1,o=n.length;if(1===arguments.length){for(;++i=e);)e=u=void 0;for(;++ir&&(e=r),r>u&&(u=r))}else{for(;++i=e);)e=void 0;for(;++ir&&(e=r),r>u&&(u=r))}return[e,u]},mo.sum=function(n,t){var e,r=0,u=n.length,i=-1;if(1===arguments.length)for(;++i1&&(t=t.map(e)),t=t.filter(n),t.length?mo.quantile(t.sort(mo.ascending),.5):void 0},mo.bisector=function(n){return{left:function(t,e,r,u){for(arguments.length<3&&(r=0),arguments.length<4&&(u=t.length);u>r;){var i=r+u>>>1;n.call(t,t[i],i)r;){var i=r+u>>>1;er?0:r);r>e;)i[e]=[t=u,u=n[++e]];return i},mo.zip=function(){if(!(u=arguments.length))return[];for(var n=-1,e=mo.min(arguments,t),r=new Array(e);++nr)for(;(u=n+r*++a)>t;)i.push(u/o);else for(;(u=n+r*++a)=o.length)return r?r.call(i,a):e?a.sort(e):a;for(var l,s,f,h,g=-1,p=a.length,d=o[c++],v=new u;++g=o.length)return n;var r=[],u=a[e++];return n.forEach(function(n,u){r.push({key:n,values:t(u,e)})}),u?r.sort(function(n,t){return u(n.key,t.key)}):r}var e,r,i={},o=[],a=[];return i.map=function(t,e){return n(e,t,0)},i.entries=function(e){return t(n(mo.map,e,0),0)},i.key=function(n){return o.push(n),i},i.sortKeys=function(n){return a[o.length-1]=n,i},i.sortValues=function(n){return e=n,i},i.rollup=function(n){return r=n,i},i},mo.set=function(n){var t=new i;if(n)for(var e=0,r=n.length;r>e;++e)t.add(n[e]);return t},r(i,{has:function(n){return zo+n in this},add:function(n){return this[zo+n]=!0,n},remove:function(n){return n=zo+n,n in this&&delete this[n]},values:function(){var n=[];return this.forEach(function(t){n.push(t)}),n},forEach:function(n){for(var t in this)t.charCodeAt(0)===Co&&n.call(this,t.substring(1))}}),mo.behavior={},mo.rebind=function(n,t){for(var e,r=1,u=arguments.length;++r=0&&(r=n.substring(e+1),n=n.substring(0,e)),n)return arguments.length<2?this[n].on(r):this[n].on(r,t);if(2===arguments.length){if(null==t)for(n in this)this.hasOwnProperty(n)&&this[n].on(r,null);return this}},mo.event=null,mo.requote=function(n){return n.replace(jo,"\\$&")};var jo=/[\\\^\$\*\+\?\|\[\]\(\)\.\{\}]/g,Lo={}.__proto__?function(n,t){n.__proto__=t}:function(n,t){for(var e in t)n[e]=t[e]},Ho=function(n,t){return t.querySelector(n)},Fo=function(n,t){return t.querySelectorAll(n)},Po=bo[a(bo,"matchesSelector")],Oo=function(n,t){return Po.call(n,t)};"function"==typeof Sizzle&&(Ho=function(n,t){return Sizzle(n,t)[0]||null},Fo=function(n,t){return Sizzle.uniqueSort(Sizzle(n,t))},Oo=Sizzle.matchesSelector),mo.selection=function(){return Uo};var Ro=mo.selection.prototype=[];Ro.select=function(n){var t,e,r,u,i=[];n=d(n);for(var o=-1,a=this.length;++o=0&&(e=n.substring(0,t),n=n.substring(t+1)),Yo.hasOwnProperty(e)?{space:Yo[e],local:n}:n}},Ro.attr=function(n,t){if(arguments.length<2){if("string"==typeof n){var e=this.node();return n=mo.ns.qualify(n),n.local?e.getAttributeNS(n.space,n.local):e.getAttribute(n)}for(t in n)this.each(m(t,n[t]));return this}return this.each(m(n,t))},Ro.classed=function(n,t){if(arguments.length<2){if("string"==typeof n){var e=this.node(),r=(n=n.trim().split(/^|\s+/g)).length,u=-1;if(t=e.classList){for(;++ur){if("string"!=typeof n){2>r&&(t="");for(e in n)this.each(_(e,n[e],t));return this}if(2>r)return _o.getComputedStyle(this.node(),null).getPropertyValue(n);e=""}return this.each(_(n,t,e))},Ro.property=function(n,t){if(arguments.length<2){if("string"==typeof n)return this.node()[n];for(t in n)this.each(w(t,n[t]));return this}return this.each(w(n,t))},Ro.text=function(n){return arguments.length?this.each("function"==typeof n?function(){var t=n.apply(this,arguments);this.textContent=null==t?"":t}:null==n?function(){this.textContent=""}:function(){this.textContent=n}):this.node().textContent},Ro.html=function(n){return arguments.length?this.each("function"==typeof n?function(){var t=n.apply(this,arguments);this.innerHTML=null==t?"":t}:null==n?function(){this.innerHTML=""}:function(){this.innerHTML=n}):this.node().innerHTML},Ro.append=function(n){return n=S(n),this.select(function(){return this.appendChild(n.apply(this,arguments))})},Ro.insert=function(n,t){return n=S(n),t=d(t),this.select(function(){return this.insertBefore(n.apply(this,arguments),t.apply(this,arguments))})},Ro.remove=function(){return this.each(function(){var n=this.parentNode;n&&n.removeChild(this)})},Ro.data=function(n,t){function e(n,e){var r,i,o,a=n.length,f=e.length,h=Math.min(a,f),g=new Array(f),p=new Array(f),d=new Array(a);if(t){var v,m=new u,y=new u,M=[];for(r=-1;++rr;++r)p[r]=E(e[r]);for(;a>r;++r)d[r]=n[r]}p.update=g,p.parentNode=g.parentNode=d.parentNode=n.parentNode,c.push(p),l.push(g),s.push(d)}var r,i,o=-1,a=this.length;if(!arguments.length){for(n=new Array(a=(r=this[0]).length);++oi;i++){u.push(t=[]),t.parentNode=(e=this[i]).parentNode;for(var a=0,c=e.length;c>a;a++)(r=e[a])&&n.call(r,r.__data__,a)&&t.push(r)}return p(u)},Ro.order=function(){for(var n=-1,t=this.length;++n=0;)(e=r[u])&&(i&&i!==e.nextSibling&&i.parentNode.insertBefore(e,i),i=e);return this},Ro.sort=function(n){n=A.apply(this,arguments);for(var t=-1,e=this.length;++tn;n++)for(var e=this[n],r=0,u=e.length;u>r;r++){var i=e[r];if(i)return i}return null},Ro.size=function(){var n=0;return this.each(function(){++n}),n};var Io=[];mo.selection.enter=T,mo.selection.enter.prototype=Io,Io.append=Ro.append,Io.empty=Ro.empty,Io.node=Ro.node,Io.call=Ro.call,Io.size=Ro.size,Io.select=function(n){for(var t,e,r,u,i,o=[],a=-1,c=this.length;++ar){if("string"!=typeof n){2>r&&(t=!1);for(e in n)this.each(C(e,n[e],t));return this}if(2>r)return(r=this.node()["__on"+n])&&r._;e=!1}return this.each(C(n,t,e))};var Zo=mo.map({mouseenter:"mouseover",mouseleave:"mouseout"});Zo.forEach(function(n){"on"+n in xo&&Zo.remove(n)});var Vo=a(bo.style,"userSelect"),Xo=0;mo.mouse=function(n){return H(n,h())};var $o=/WebKit/.test(_o.navigator.userAgent)?-1:0;mo.touches=function(n,t){return arguments.length<2&&(t=h().touches),t?Mo(t).map(function(t){var e=H(n,t);return e.identifier=t.identifier,e}):[]},mo.behavior.drag=function(){function n(){this.on("mousedown.drag",o).on("touchstart.drag",a)}function t(){return mo.event.changedTouches[0].identifier}function e(n,t){return mo.touches(n).filter(function(n){return n.identifier===t})[0]}function r(n,t,e,r){return function(){function o(){var n=t(s,g),e=n[0]-d[0],r=n[1]-d[1];v|=e|r,d=n,f({type:"drag",x:n[0]+c[0],y:n[1]+c[1],dx:e,dy:r})}function a(){m.on(e+"."+p,null).on(r+"."+p,null),y(v&&mo.event.target===h),f({type:"dragend"})}var c,l=this,s=l.parentNode,f=u.of(l,arguments),h=mo.event.target,g=n(),p=null==g?"drag":"drag-"+g,d=t(s,g),v=0,m=mo.select(_o).on(e+"."+p,o).on(r+"."+p,a),y=L();i?(c=i.apply(l,arguments),c=[c.x-d[0],c.y-d[1]]):c=[0,0],f({type:"dragstart"})}}var u=g(n,"drag","dragstart","dragend"),i=null,o=r(c,mo.mouse,"mousemove","mouseup"),a=r(t,e,"touchmove","touchend");return n.origin=function(t){return arguments.length?(i=t,n):i},mo.rebind(n,u,"on")};var Bo=Math.PI,Wo=2*Bo,Jo=Bo/2,Go=1e-6,Ko=Go*Go,Qo=Bo/180,na=180/Bo,ta=Math.SQRT2,ea=2,ra=4;mo.interpolateZoom=function(n,t){function e(n){var t=n*y;if(m){var e=Y(d),o=i/(ea*h)*(e*I(ta*t+d)-R(d));return[r+o*l,u+o*s,i*e/Y(ta*t+d)]}return[r+n*l,u+n*s,i*Math.exp(ta*t)]}var r=n[0],u=n[1],i=n[2],o=t[0],a=t[1],c=t[2],l=o-r,s=a-u,f=l*l+s*s,h=Math.sqrt(f),g=(c*c-i*i+ra*f)/(2*i*ea*h),p=(c*c-i*i-ra*f)/(2*c*ea*h),d=Math.log(Math.sqrt(g*g+1)-g),v=Math.log(Math.sqrt(p*p+1)-p),m=v-d,y=(m||Math.log(c/i))/ta;return e.duration=1e3*y,e},mo.behavior.zoom=function(){function n(n){n.on(A,l).on(oa+".zoom",h).on(N,p).on("dblclick.zoom",d).on(q,s)}function t(n){return[(n[0]-S.x)/S.k,(n[1]-S.y)/S.k]}function e(n){return[n[0]*S.k+S.x,n[1]*S.k+S.y]}function r(n){S.k=Math.max(k[0],Math.min(k[1],n))}function u(n,t){t=e(t),S.x+=n[0]-t[0],S.y+=n[1]-t[1]}function i(){b&&b.domain(x.range().map(function(n){return(n-S.x)/S.k}).map(x.invert)),w&&w.domain(_.range().map(function(n){return(n-S.y)/S.k}).map(_.invert))}function o(n){n({type:"zoomstart"})}function a(n){i(),n({type:"zoom",scale:S.k,translate:[S.x,S.y]})}function c(n){n({type:"zoomend"})}function l(){function n(){s=1,u(mo.mouse(r),h),a(i)}function e(){f.on(N,_o===r?p:null).on(T,null),g(s&&mo.event.target===l),c(i)}var r=this,i=C.of(r,arguments),l=mo.event.target,s=0,f=mo.select(_o).on(N,n).on(T,e),h=t(mo.mouse(r)),g=L();z.call(r),o(i)}function s(){function n(){var n=mo.touches(p);return g=S.k,n.forEach(function(n){n.identifier in v&&(v[n.identifier]=t(n))}),n}function e(){for(var t=mo.event.changedTouches,e=0,i=t.length;i>e;++e)v[t[e].identifier]=null;var o=n(),c=Date.now();if(1===o.length){if(500>c-M){var l=o[0],s=v[l.identifier];r(2*S.k),u(l,s),f(),a(d)}M=c}else if(o.length>1){var l=o[0],h=o[1],g=l[0]-h[0],p=l[1]-h[1];m=g*g+p*p}}function i(){for(var n,t,e,i,o=mo.touches(p),c=0,l=o.length;l>c;++c,i=null)if(e=o[c],i=v[e.identifier]){if(t)break;n=e,t=i}if(i){var s=(s=e[0]-n[0])*s+(s=e[1]-n[1])*s,f=m&&Math.sqrt(s/m);n=[(n[0]+e[0])/2,(n[1]+e[1])/2],t=[(t[0]+i[0])/2,(t[1]+i[1])/2],r(f*g)}M=null,u(n,t),a(d)}function h(){if(mo.event.touches.length){for(var t=mo.event.changedTouches,e=0,r=t.length;r>e;++e)delete v[t[e].identifier];for(var u in v)return void n()}_.on(x,null).on(b,null),w.on(A,l).on(q,s),E(),c(d)}var g,p=this,d=C.of(p,arguments),v={},m=0,y=mo.event.changedTouches[0].identifier,x="touchmove.zoom-"+y,b="touchend.zoom-"+y,_=mo.select(_o).on(x,i).on(b,h),w=mo.select(p).on(A,null).on(q,e),E=L();z.call(p),e(),o(d)}function h(){var n=C.of(this,arguments);y?clearTimeout(y):(z.call(this),o(n)),y=setTimeout(function(){y=null,c(n)},50),f();var e=m||mo.mouse(this);v||(v=t(e)),r(Math.pow(2,.002*ua())*S.k),u(e,v),a(n)}function p(){v=null}function d(){var n=C.of(this,arguments),e=mo.mouse(this),i=t(e),l=Math.log(S.k)/Math.LN2;o(n),r(Math.pow(2,mo.event.shiftKey?Math.ceil(l)-1:Math.floor(l)+1)),u(e,i),a(n),c(n)}var v,m,y,M,x,b,_,w,S={x:0,y:0,k:1},E=[960,500],k=ia,A="mousedown.zoom",N="mousemove.zoom",T="mouseup.zoom",q="touchstart.zoom",C=g(n,"zoomstart","zoom","zoomend");return n.event=function(n){n.each(function(){var n=C.of(this,arguments),t=S;Oc?mo.select(this).transition().each("start.zoom",function(){S=this.__chart__||{x:0,y:0,k:1},o(n)}).tween("zoom:zoom",function(){var e=E[0],r=E[1],u=e/2,i=r/2,o=mo.interpolateZoom([(u-S.x)/S.k,(i-S.y)/S.k,e/S.k],[(u-t.x)/t.k,(i-t.y)/t.k,e/t.k]);return function(t){var r=o(t),c=e/r[2];this.__chart__=S={x:u-r[0]*c,y:i-r[1]*c,k:c},a(n)}}).each("end.zoom",function(){c(n)}):(this.__chart__=S,o(n),a(n),c(n))})},n.translate=function(t){return arguments.length?(S={x:+t[0],y:+t[1],k:S.k},i(),n):[S.x,S.y]},n.scale=function(t){return arguments.length?(S={x:S.x,y:S.y,k:+t},i(),n):S.k},n.scaleExtent=function(t){return arguments.length?(k=null==t?ia:[+t[0],+t[1]],n):k},n.center=function(t){return arguments.length?(m=t&&[+t[0],+t[1]],n):m},n.size=function(t){return arguments.length?(E=t&&[+t[0],+t[1]],n):E},n.x=function(t){return arguments.length?(b=t,x=t.copy(),S={x:0,y:0,k:1},n):b},n.y=function(t){return arguments.length?(w=t,_=t.copy(),S={x:0,y:0,k:1},n):w},mo.rebind(n,C,"on")};var ua,ia=[0,1/0],oa="onwheel"in xo?(ua=function(){return-mo.event.deltaY*(mo.event.deltaMode?120:1)},"wheel"):"onmousewheel"in xo?(ua=function(){return mo.event.wheelDelta},"mousewheel"):(ua=function(){return-mo.event.detail},"MozMousePixelScroll");Z.prototype.toString=function(){return this.rgb()+""},mo.hsl=function(n,t,e){return 1===arguments.length?n instanceof X?V(n.h,n.s,n.l):lt(""+n,st,V):V(+n,+t,+e)};var aa=X.prototype=new Z;aa.brighter=function(n){return n=Math.pow(.7,arguments.length?n:1),V(this.h,this.s,this.l/n)},aa.darker=function(n){return n=Math.pow(.7,arguments.length?n:1),V(this.h,this.s,n*this.l)},aa.rgb=function(){return $(this.h,this.s,this.l)},mo.hcl=function(n,t,e){return 1===arguments.length?n instanceof W?B(n.h,n.c,n.l):n instanceof K?nt(n.l,n.a,n.b):nt((n=ft((n=mo.rgb(n)).r,n.g,n.b)).l,n.a,n.b):B(+n,+t,+e)};var ca=W.prototype=new Z;ca.brighter=function(n){return B(this.h,this.c,Math.min(100,this.l+la*(arguments.length?n:1)))},ca.darker=function(n){return B(this.h,this.c,Math.max(0,this.l-la*(arguments.length?n:1)))},ca.rgb=function(){return J(this.h,this.c,this.l).rgb()},mo.lab=function(n,t,e){return 1===arguments.length?n instanceof K?G(n.l,n.a,n.b):n instanceof W?J(n.l,n.c,n.h):ft((n=mo.rgb(n)).r,n.g,n.b):G(+n,+t,+e)};var la=18,sa=.95047,fa=1,ha=1.08883,ga=K.prototype=new Z;ga.brighter=function(n){return G(Math.min(100,this.l+la*(arguments.length?n:1)),this.a,this.b)},ga.darker=function(n){return G(Math.max(0,this.l-la*(arguments.length?n:1)),this.a,this.b)},ga.rgb=function(){return Q(this.l,this.a,this.b)},mo.rgb=function(n,t,e){return 1===arguments.length?n instanceof at?ot(n.r,n.g,n.b):lt(""+n,ot,$):ot(~~n,~~t,~~e)};var pa=at.prototype=new Z;pa.brighter=function(n){n=Math.pow(.7,arguments.length?n:1);var t=this.r,e=this.g,r=this.b,u=30;return t||e||r?(t&&u>t&&(t=u),e&&u>e&&(e=u),r&&u>r&&(r=u),ot(Math.min(255,~~(t/n)),Math.min(255,~~(e/n)),Math.min(255,~~(r/n)))):ot(u,u,u)},pa.darker=function(n){return n=Math.pow(.7,arguments.length?n:1),ot(~~(n*this.r),~~(n*this.g),~~(n*this.b))},pa.hsl=function(){return st(this.r,this.g,this.b)},pa.toString=function(){return"#"+ct(this.r)+ct(this.g)+ct(this.b)};var da=mo.map({aliceblue:15792383,antiquewhite:16444375,aqua:65535,aquamarine:8388564,azure:15794175,beige:16119260,bisque:16770244,black:0,blanchedalmond:16772045,blue:255,blueviolet:9055202,brown:10824234,burlywood:14596231,cadetblue:6266528,chartreuse:8388352,chocolate:13789470,coral:16744272,cornflowerblue:6591981,cornsilk:16775388,crimson:14423100,cyan:65535,darkblue:139,darkcyan:35723,darkgoldenrod:12092939,darkgray:11119017,darkgreen:25600,darkgrey:11119017,darkkhaki:12433259,darkmagenta:9109643,darkolivegreen:5597999,darkorange:16747520,darkorchid:10040012,darkred:9109504,darksalmon:15308410,darkseagreen:9419919,darkslateblue:4734347,darkslategray:3100495,darkslategrey:3100495,darkturquoise:52945,darkviolet:9699539,deeppink:16716947,deepskyblue:49151,dimgray:6908265,dimgrey:6908265,dodgerblue:2003199,firebrick:11674146,floralwhite:16775920,forestgreen:2263842,fuchsia:16711935,gainsboro:14474460,ghostwhite:16316671,gold:16766720,goldenrod:14329120,gray:8421504,green:32768,greenyellow:11403055,grey:8421504,honeydew:15794160,hotpink:16738740,indianred:13458524,indigo:4915330,ivory:16777200,khaki:15787660,lavender:15132410,lavenderblush:16773365,lawngreen:8190976,lemonchiffon:16775885,lightblue:11393254,lightcoral:15761536,lightcyan:14745599,lightgoldenrodyellow:16448210,lightgray:13882323,lightgreen:9498256,lightgrey:13882323,lightpink:16758465,lightsalmon:16752762,lightseagreen:2142890,lightskyblue:8900346,lightslategray:7833753,lightslategrey:7833753,lightsteelblue:11584734,lightyellow:16777184,lime:65280,limegreen:3329330,linen:16445670,magenta:16711935,maroon:8388608,mediumaquamarine:6737322,mediumblue:205,mediumorchid:12211667,mediumpurple:9662683,mediumseagreen:3978097,mediumslateblue:8087790,mediumspringgreen:64154,mediumturquoise:4772300,mediumvioletred:13047173,midnightblue:1644912,mintcream:16121850,mistyrose:16770273,moccasin:16770229,navajowhite:16768685,navy:128,oldlace:16643558,olive:8421376,olivedrab:7048739,orange:16753920,orangered:16729344,orchid:14315734,palegoldenrod:15657130,palegreen:10025880,paleturquoise:11529966,palevioletred:14381203,papayawhip:16773077,peachpuff:16767673,peru:13468991,pink:16761035,plum:14524637,powderblue:11591910,purple:8388736,red:16711680,rosybrown:12357519,royalblue:4286945,saddlebrown:9127187,salmon:16416882,sandybrown:16032864,seagreen:3050327,seashell:16774638,sienna:10506797,silver:12632256,skyblue:8900331,slateblue:6970061,slategray:7372944,slategrey:7372944,snow:16775930,springgreen:65407,steelblue:4620980,tan:13808780,teal:32896,thistle:14204888,tomato:16737095,turquoise:4251856,violet:15631086,wheat:16113331,white:16777215,whitesmoke:16119285,yellow:16776960,yellowgreen:10145074});da.forEach(function(n,t){da.set(n,ut(t))}),mo.functor=pt,mo.xhr=vt(dt),mo.dsv=function(n,t){function e(n,e,i){arguments.length<3&&(i=e,e=null);var o=mo.xhr(n,t,i);return o.row=function(n){return arguments.length?o.response(null==(e=n)?r:u(n)):e},o.row(e)}function r(n){return e.parse(n.responseText)}function u(n){return function(t){return e.parse(t.responseText,n)}}function o(t){return t.map(a).join(n)}function a(n){return c.test(n)?'"'+n.replace(/\"/g,'""')+'"':n}var c=new RegExp('["'+n+"\n]"),l=n.charCodeAt(0);return e.parse=function(n,t){var r;return e.parseRows(n,function(n,e){if(r)return r(n,e-1);var u=new Function("d","return {"+n.map(function(n,t){return JSON.stringify(n)+": d["+t+"]"}).join(",")+"}");r=t?function(n,e){return t(u(n),e)}:u})},e.parseRows=function(n,t){function e(){if(s>=c)return o;if(u)return u=!1,i;var t=s;if(34===n.charCodeAt(t)){for(var e=t;e++s;){var r=n.charCodeAt(s++),a=1;if(10===r)u=!0;else if(13===r)u=!0,10===n.charCodeAt(s)&&(++s,++a);else if(r!==l)continue;return n.substring(t,s-a)}return n.substring(t)}for(var r,u,i={},o={},a=[],c=n.length,s=0,f=0;(r=e())!==o;){for(var h=[];r!==i&&r!==o;)h.push(r),r=e();(!t||(h=t(h,f++)))&&a.push(h)}return a},e.format=function(t){if(Array.isArray(t[0]))return e.formatRows(t);var r=new i,u=[];return t.forEach(function(n){for(var t in n)r.has(t)||u.push(r.add(t))}),[u.map(a).join(n)].concat(t.map(function(t){return u.map(function(n){return a(t[n])}).join(n)})).join("\n")},e.formatRows=function(n){return n.map(o).join("\n")},e},mo.csv=mo.dsv(",","text/csv"),mo.tsv=mo.dsv(" ","text/tab-separated-values");var va,ma,ya,Ma,xa,ba=_o[a(_o,"requestAnimationFrame")]||function(n){setTimeout(n,17)};mo.timer=function(n,t,e){var r=arguments.length;2>r&&(t=0),3>r&&(e=Date.now());var u=e+t,i={callback:n,time:u,next:null};ma?ma.next=i:va=i,ma=i,ya||(Ma=clearTimeout(Ma),ya=1,ba(Mt))},mo.timer.flush=function(){bt(),_t()};var _a=".",wa=",",Sa=[3,3],Ea="$",ka=["y","z","a","f","p","n","\xb5","m","","k","M","G","T","P","E","Z","Y"].map(wt);mo.formatPrefix=function(n,t){var e=0;return n&&(0>n&&(n*=-1),t&&(n=mo.round(n,St(n,t))),e=1+Math.floor(1e-12+Math.log(n)/Math.LN10),e=Math.max(-24,Math.min(24,3*Math.floor((0>=e?e+1:e-1)/3)))),ka[8+e/3]},mo.round=function(n,t){return t?Math.round(n*(t=Math.pow(10,t)))/t:Math.round(n)},mo.format=function(n){var t=Aa.exec(n),e=t[1]||" ",r=t[2]||">",u=t[3]||"",i=t[4]||"",o=t[5],a=+t[6],c=t[7],l=t[8],s=t[9],f=1,h="",g=!1;switch(l&&(l=+l.substring(1)),(o||"0"===e&&"="===r)&&(o=e="0",r="=",c&&(a-=Math.floor((a-1)/4))),s){case"n":c=!0,s="g";break;case"%":f=100,h="%",s="f";break;case"p":f=100,h="%",s="r";break;case"b":case"o":case"x":case"X":"#"===i&&(i="0"+s.toLowerCase());case"c":case"d":g=!0,l=0;break;case"s":f=-1,s="r"}"#"===i?i="":"$"===i&&(i=Ea),"r"!=s||l||(s="g"),null!=l&&("g"==s?l=Math.max(1,Math.min(21,l)):("e"==s||"f"==s)&&(l=Math.max(0,Math.min(20,l)))),s=Na.get(s)||Et;var p=o&&c;return function(n){if(g&&n%1)return"";var t=0>n||0===n&&0>1/n?(n=-n,"-"):u;if(0>f){var d=mo.formatPrefix(n,l);n=d.scale(n),h=d.symbol}else n*=f;n=s(n,l);var v=n.lastIndexOf("."),m=0>v?n:n.substring(0,v),y=0>v?"":_a+n.substring(v+1);!o&&c&&(m=Ta(m));var M=i.length+m.length+y.length+(p?0:t.length),x=a>M?new Array(M=a-M+1).join(e):"";return p&&(m=Ta(x+m)),t+=i,n=m+y,("<"===r?t+n+x:">"===r?x+t+n:"^"===r?x.substring(0,M>>=1)+t+n+x.substring(M):t+(p?n:x+n))+h}};var Aa=/(?:([^{])?([<>=^]))?([+\- ])?([$#])?(0)?(\d+)?(,)?(\.-?\d+)?([a-z%])?/i,Na=mo.map({b:function(n){return n.toString(2)},c:function(n){return String.fromCharCode(n)},o:function(n){return n.toString(8)},x:function(n){return n.toString(16)},X:function(n){return n.toString(16).toUpperCase()},g:function(n,t){return n.toPrecision(t)},e:function(n,t){return n.toExponential(t)},f:function(n,t){return n.toFixed(t)},r:function(n,t){return(n=mo.round(n,St(n,t))).toFixed(Math.max(0,Math.min(20,St(n*(1+1e-15),t))))}}),Ta=dt;if(Sa){var qa=Sa.length;Ta=function(n){for(var t=n.length,e=[],r=0,u=Sa[0];t>0&&u>0;)e.push(n.substring(t-=u,t+u)),u=Sa[r=(r+1)%qa];return e.reverse().join(wa)}}mo.geo={},kt.prototype={s:0,t:0,add:function(n){At(n,this.t,za),At(za.s,this.s,this),this.s?this.t+=za.t:this.s=za.t},reset:function(){this.s=this.t=0},valueOf:function(){return this.s}};var za=new kt;mo.geo.stream=function(n,t){n&&Ca.hasOwnProperty(n.type)?Ca[n.type](n,t):Nt(n,t)};var Ca={Feature:function(n,t){Nt(n.geometry,t)},FeatureCollection:function(n,t){for(var e=n.features,r=-1,u=e.length;++rn?4*Bo+n:n,Ha.lineStart=Ha.lineEnd=Ha.point=c}};mo.geo.bounds=function(){function n(n,t){M.push(x=[s=n,h=n]),f>t&&(f=t),t>g&&(g=t)}function t(t,e){var r=Ct([t*Qo,e*Qo]);if(m){var u=jt(m,r),i=[u[1],-u[0],0],o=jt(i,u);Ft(o),o=Pt(o);var c=t-p,l=c>0?1:-1,d=o[0]*na*l,v=Math.abs(c)>180;if(v^(d>l*p&&l*t>d)){var y=o[1]*na;y>g&&(g=y)}else if(d=(d+360)%360-180,v^(d>l*p&&l*t>d)){var y=-o[1]*na;f>y&&(f=y)}else f>e&&(f=e),e>g&&(g=e);v?p>t?a(s,t)>a(s,h)&&(h=t):a(t,h)>a(s,h)&&(s=t):h>=s?(s>t&&(s=t),t>h&&(h=t)):t>p?a(s,t)>a(s,h)&&(h=t):a(t,h)>a(s,h)&&(s=t)}else n(t,e);m=r,p=t}function e(){b.point=t}function r(){x[0]=s,x[1]=h,b.point=n,m=null}function u(n,e){if(m){var r=n-p;y+=Math.abs(r)>180?r+(r>0?360:-360):r}else d=n,v=e;Ha.point(n,e),t(n,e)}function i(){Ha.lineStart()}function o(){u(d,v),Ha.lineEnd(),Math.abs(y)>Go&&(s=-(h=180)),x[0]=s,x[1]=h,m=null}function a(n,t){return(t-=n)<0?t+360:t}function c(n,t){return n[0]-t[0]}function l(n,t){return t[0]<=t[1]?t[0]<=n&&n<=t[1]:nLa?(s=-(h=180),f=-(g=90)):y>Go?g=90:-Go>y&&(f=-90),x[0]=s,x[1]=h}};return function(n){g=h=-(s=f=1/0),M=[],mo.geo.stream(n,b);var t=M.length;if(t){M.sort(c);for(var e,r=1,u=M[0],i=[u];t>r;++r)e=M[r],l(e[0],u)||l(e[1],u)?(a(u[0],e[1])>a(u[0],u[1])&&(u[1]=e[1]),a(e[0],u[1])>a(u[0],u[1])&&(u[0]=e[0])):i.push(u=e);for(var o,e,p=-1/0,t=i.length-1,r=0,u=i[t];t>=r;u=e,++r)e=i[r],(o=a(u[1],e[0]))>p&&(p=o,s=e[0],h=u[1])}return M=x=null,1/0===s||1/0===f?[[0/0,0/0],[0/0,0/0]]:[[s,f],[h,g]]}}(),mo.geo.centroid=function(n){Fa=Pa=Oa=Ra=Ya=Ia=Ua=Za=Va=Xa=$a=0,mo.geo.stream(n,Ba);var t=Va,e=Xa,r=$a,u=t*t+e*e+r*r;return Ko>u&&(t=Ia,e=Ua,r=Za,Go>Pa&&(t=Oa,e=Ra,r=Ya),u=t*t+e*e+r*r,Ko>u)?[0/0,0/0]:[Math.atan2(e,t)*na,O(r/Math.sqrt(u))*na]};var Fa,Pa,Oa,Ra,Ya,Ia,Ua,Za,Va,Xa,$a,Ba={sphere:c,point:Rt,lineStart:It,lineEnd:Ut,polygonStart:function(){Ba.lineStart=Zt},polygonEnd:function(){Ba.lineStart=It}},Wa=Bt(Vt,Qt,te,[-Bo,-Bo/2]),Ja=1e9;mo.geo.clipExtent=function(){var n,t,e,r,u,i,o={stream:function(n){return u&&(u.valid=!1),u=i(n),u.valid=!0,u},extent:function(a){return arguments.length?(i=re(n=+a[0][0],t=+a[0][1],e=+a[1][0],r=+a[1][1]),u&&(u.valid=!1,u=null),o):[[n,t],[e,r]]}};return o.extent([[0,0],[960,500]])},(mo.geo.conicEqualArea=function(){return oe(ae)}).raw=ae,mo.geo.albers=function(){return mo.geo.conicEqualArea().rotate([96,0]).center([-.6,38.7]).parallels([29.5,45.5]).scale(1070)},mo.geo.albersUsa=function(){function n(n){var i=n[0],o=n[1];return t=null,e(i,o),t||(r(i,o),t)||u(i,o),t}var t,e,r,u,i=mo.geo.albers(),o=mo.geo.conicEqualArea().rotate([154,0]).center([-2,58.5]).parallels([55,65]),a=mo.geo.conicEqualArea().rotate([157,0]).center([-3,19.9]).parallels([8,18]),c={point:function(n,e){t=[n,e]}};return n.invert=function(n){var t=i.scale(),e=i.translate(),r=(n[0]-e[0])/t,u=(n[1]-e[1])/t;return(u>=.12&&.234>u&&r>=-.425&&-.214>r?o:u>=.166&&.234>u&&r>=-.214&&-.115>r?a:i).invert(n)},n.stream=function(n){var t=i.stream(n),e=o.stream(n),r=a.stream(n);return{point:function(n,u){t.point(n,u),e.point(n,u),r.point(n,u)},sphere:function(){t.sphere(),e.sphere(),r.sphere()},lineStart:function(){t.lineStart(),e.lineStart(),r.lineStart()},lineEnd:function(){t.lineEnd(),e.lineEnd(),r.lineEnd()},polygonStart:function(){t.polygonStart(),e.polygonStart(),r.polygonStart()},polygonEnd:function(){t.polygonEnd(),e.polygonEnd(),r.polygonEnd()}}},n.precision=function(t){return arguments.length?(i.precision(t),o.precision(t),a.precision(t),n):i.precision()},n.scale=function(t){return arguments.length?(i.scale(t),o.scale(.35*t),a.scale(t),n.translate(i.translate())):i.scale()},n.translate=function(t){if(!arguments.length)return i.translate();var l=i.scale(),s=+t[0],f=+t[1];return e=i.translate(t).clipExtent([[s-.455*l,f-.238*l],[s+.455*l,f+.238*l]]).stream(c).point,r=o.translate([s-.307*l,f+.201*l]).clipExtent([[s-.425*l+Go,f+.12*l+Go],[s-.214*l-Go,f+.234*l-Go]]).stream(c).point,u=a.translate([s-.205*l,f+.212*l]).clipExtent([[s-.214*l+Go,f+.166*l+Go],[s-.115*l-Go,f+.234*l-Go]]).stream(c).point,n},n.scale(1070)};var Ga,Ka,Qa,nc,tc,ec,rc={point:c,lineStart:c,lineEnd:c,polygonStart:function(){Ka=0,rc.lineStart=ce},polygonEnd:function(){rc.lineStart=rc.lineEnd=rc.point=c,Ga+=Math.abs(Ka/2)}},uc={point:le,lineStart:c,lineEnd:c,polygonStart:c,polygonEnd:c},ic={point:he,lineStart:ge,lineEnd:pe,polygonStart:function(){ic.lineStart=de},polygonEnd:function(){ic.point=he,ic.lineStart=ge,ic.lineEnd=pe}};mo.geo.transform=function(n){return{stream:function(t){var e=new ye(t);for(var r in n)e[r]=n[r];return e}}},ye.prototype={point:function(n,t){this.stream.point(n,t)},sphere:function(){this.stream.sphere()},lineStart:function(){this.stream.lineStart()},lineEnd:function(){this.stream.lineEnd()},polygonStart:function(){this.stream.polygonStart()},polygonEnd:function(){this.stream.polygonEnd()}},mo.geo.path=function(){function n(n){return n&&("function"==typeof a&&i.pointRadius(+a.apply(this,arguments)),o&&o.valid||(o=u(i)),mo.geo.stream(n,o)),i.result()}function t(){return o=null,n}var e,r,u,i,o,a=4.5;return n.area=function(n){return Ga=0,mo.geo.stream(n,u(rc)),Ga},n.centroid=function(n){return Oa=Ra=Ya=Ia=Ua=Za=Va=Xa=$a=0,mo.geo.stream(n,u(ic)),$a?[Va/$a,Xa/$a]:Za?[Ia/Za,Ua/Za]:Ya?[Oa/Ya,Ra/Ya]:[0/0,0/0]},n.bounds=function(n){return tc=ec=-(Qa=nc=1/0),mo.geo.stream(n,u(uc)),[[Qa,nc],[tc,ec]]},n.projection=function(n){return arguments.length?(u=(e=n)?n.stream||Me(n):dt,t()):e},n.context=function(n){return arguments.length?(i=null==(r=n)?new se:new ve(n),"function"!=typeof a&&i.pointRadius(a),t()):r},n.pointRadius=function(t){return arguments.length?(a="function"==typeof t?t:(i.pointRadius(+t),+t),n):a},n.projection(mo.geo.albersUsa()).context(null)},mo.geo.projection=xe,mo.geo.projectionMutator=be,(mo.geo.equirectangular=function(){return xe(we)}).raw=we.invert=we,mo.geo.rotation=function(n){function t(t){return t=n(t[0]*Qo,t[1]*Qo),t[0]*=na,t[1]*=na,t}return n=Ee(n[0]%360*Qo,n[1]*Qo,n.length>2?n[2]*Qo:0),t.invert=function(t){return t=n.invert(t[0]*Qo,t[1]*Qo),t[0]*=na,t[1]*=na,t},t},Se.invert=we,mo.geo.circle=function(){function n(){var n="function"==typeof r?r.apply(this,arguments):r,t=Ee(-n[0]*Qo,-n[1]*Qo,0).invert,u=[];return e(null,null,1,{point:function(n,e){u.push(n=t(n,e)),n[0]*=na,n[1]*=na}}),{type:"Polygon",coordinates:[u]}}var t,e,r=[0,0],u=6;return n.origin=function(t){return arguments.length?(r=t,n):r},n.angle=function(r){return arguments.length?(e=Te((t=+r)*Qo,u*Qo),n):t},n.precision=function(r){return arguments.length?(e=Te(t*Qo,(u=+r)*Qo),n):u},n.angle(90)},mo.geo.distance=function(n,t){var e,r=(t[0]-n[0])*Qo,u=n[1]*Qo,i=t[1]*Qo,o=Math.sin(r),a=Math.cos(r),c=Math.sin(u),l=Math.cos(u),s=Math.sin(i),f=Math.cos(i);return Math.atan2(Math.sqrt((e=f*o)*e+(e=l*s-c*f*a)*e),c*s+l*f*a)},mo.geo.graticule=function(){function n(){return{type:"MultiLineString",coordinates:t()}}function t(){return mo.range(Math.ceil(i/v)*v,u,v).map(h).concat(mo.range(Math.ceil(l/m)*m,c,m).map(g)).concat(mo.range(Math.ceil(r/p)*p,e,p).filter(function(n){return Math.abs(n%v)>Go +}).map(s)).concat(mo.range(Math.ceil(a/d)*d,o,d).filter(function(n){return Math.abs(n%m)>Go}).map(f))}var e,r,u,i,o,a,c,l,s,f,h,g,p=10,d=p,v=90,m=360,y=2.5;return n.lines=function(){return t().map(function(n){return{type:"LineString",coordinates:n}})},n.outline=function(){return{type:"Polygon",coordinates:[h(i).concat(g(c).slice(1),h(u).reverse().slice(1),g(l).reverse().slice(1))]}},n.extent=function(t){return arguments.length?n.majorExtent(t).minorExtent(t):n.minorExtent()},n.majorExtent=function(t){return arguments.length?(i=+t[0][0],u=+t[1][0],l=+t[0][1],c=+t[1][1],i>u&&(t=i,i=u,u=t),l>c&&(t=l,l=c,c=t),n.precision(y)):[[i,l],[u,c]]},n.minorExtent=function(t){return arguments.length?(r=+t[0][0],e=+t[1][0],a=+t[0][1],o=+t[1][1],r>e&&(t=r,r=e,e=t),a>o&&(t=a,a=o,o=t),n.precision(y)):[[r,a],[e,o]]},n.step=function(t){return arguments.length?n.majorStep(t).minorStep(t):n.minorStep()},n.majorStep=function(t){return arguments.length?(v=+t[0],m=+t[1],n):[v,m]},n.minorStep=function(t){return arguments.length?(p=+t[0],d=+t[1],n):[p,d]},n.precision=function(t){return arguments.length?(y=+t,s=ze(a,o,90),f=Ce(r,e,y),h=ze(l,c,90),g=Ce(i,u,y),n):y},n.majorExtent([[-180,-90+Go],[180,90-Go]]).minorExtent([[-180,-80-Go],[180,80+Go]])},mo.geo.greatArc=function(){function n(){return{type:"LineString",coordinates:[t||r.apply(this,arguments),e||u.apply(this,arguments)]}}var t,e,r=De,u=je;return n.distance=function(){return mo.geo.distance(t||r.apply(this,arguments),e||u.apply(this,arguments))},n.source=function(e){return arguments.length?(r=e,t="function"==typeof e?null:e,n):r},n.target=function(t){return arguments.length?(u=t,e="function"==typeof t?null:t,n):u},n.precision=function(){return arguments.length?n:0},n},mo.geo.interpolate=function(n,t){return Le(n[0]*Qo,n[1]*Qo,t[0]*Qo,t[1]*Qo)},mo.geo.length=function(n){return oc=0,mo.geo.stream(n,ac),oc};var oc,ac={sphere:c,point:c,lineStart:He,lineEnd:c,polygonStart:c,polygonEnd:c},cc=Fe(function(n){return Math.sqrt(2/(1+n))},function(n){return 2*Math.asin(n/2)});(mo.geo.azimuthalEqualArea=function(){return xe(cc)}).raw=cc;var lc=Fe(function(n){var t=Math.acos(n);return t&&t/Math.sin(t)},dt);(mo.geo.azimuthalEquidistant=function(){return xe(lc)}).raw=lc,(mo.geo.conicConformal=function(){return oe(Pe)}).raw=Pe,(mo.geo.conicEquidistant=function(){return oe(Oe)}).raw=Oe;var sc=Fe(function(n){return 1/n},Math.atan);(mo.geo.gnomonic=function(){return xe(sc)}).raw=sc,Re.invert=function(n,t){return[n,2*Math.atan(Math.exp(t))-Jo]},(mo.geo.mercator=function(){return Ye(Re)}).raw=Re;var fc=Fe(function(){return 1},Math.asin);(mo.geo.orthographic=function(){return xe(fc)}).raw=fc;var hc=Fe(function(n){return 1/(1+n)},function(n){return 2*Math.atan(n)});(mo.geo.stereographic=function(){return xe(hc)}).raw=hc,Ie.invert=function(n,t){return[Math.atan2(R(n),Math.cos(t)),O(Math.sin(t)/Y(n))]},(mo.geo.transverseMercator=function(){return Ye(Ie)}).raw=Ie,mo.geom={},mo.svg={},mo.svg.line=function(){return Ue(dt)};var gc=mo.map({linear:Xe,"linear-closed":$e,step:Be,"step-before":We,"step-after":Je,basis:er,"basis-open":rr,"basis-closed":ur,bundle:ir,cardinal:Qe,"cardinal-open":Ge,"cardinal-closed":Ke,monotone:fr});gc.forEach(function(n,t){t.key=n,t.closed=/-closed$/.test(n)});var pc=[0,2/3,1/3,0],dc=[0,1/3,2/3,0],vc=[0,1/6,2/3,1/6];mo.geom.hull=function(n){function t(n){if(n.length<3)return[];var t,u,i,o,a,c,l,s,f,h,g,p,d=pt(e),v=pt(r),m=n.length,y=m-1,M=[],x=[],b=0;if(d===Ze&&r===Ve)t=n;else for(i=0,t=[];m>i;++i)t.push([+d.call(this,u=n[i],i),+v.call(this,u,i)]);for(i=1;m>i;++i)(t[i][1]i;++i)i!==b&&(c=t[i][1]-t[b][1],a=t[i][0]-t[b][0],M.push({angle:Math.atan2(c,a),index:i}));for(M.sort(function(n,t){return n.angle-t.angle}),g=M[0].angle,h=M[0].index,f=0,i=1;y>i;++i){if(o=M[i].index,g==M[i].angle){if(a=t[h][0]-t[b][0],c=t[h][1]-t[b][1],l=t[o][0]-t[b][0],s=t[o][1]-t[b][1],a*a+c*c>=l*l+s*s){M[i].index=-1;continue}M[f].index=-1}g=M[i].angle,f=i,h=o}for(x.push(b),i=0,o=0;2>i;++o)M[o].index>-1&&(x.push(M[o].index),i++);for(p=x.length;y>o;++o)if(!(M[o].index<0)){for(;!hr(x[p-2],x[p-1],M[o].index,t);)--p;x[p++]=M[o].index}var _=[];for(i=p-1;i>=0;--i)_.push(n[x[i]]);return _}var e=Ze,r=Ve;return arguments.length?t(n):(t.x=function(n){return arguments.length?(e=n,t):e},t.y=function(n){return arguments.length?(r=n,t):r},t)},mo.geom.polygon=function(n){return Lo(n,mc),n};var mc=mo.geom.polygon.prototype=[];mc.area=function(){for(var n,t=-1,e=this.length,r=this[e-1],u=0;++ta;a++)e.push([u,t[a],t[a+1]])}),e},mo.geom.voronoi=function(n){function t(n){var t,i,o,a=n.map(function(){return[]}),c=pt(e),l=pt(r),s=n.length,f=1e6;if(c===Ze&&l===Ve)t=n;else for(t=new Array(s),o=0;s>o;++o)t[o]=[+c.call(this,i=n[o],o),+l.call(this,i,o)];if(vr(t,function(n){var t,e,r,u,i,o;1===n.a&&n.b>=0?(t=n.ep.r,e=n.ep.l):(t=n.ep.l,e=n.ep.r),1===n.a?(i=t?t.y:-f,r=n.c-n.b*i,o=e?e.y:f,u=n.c-n.b*o):(r=t?t.x:-f,i=n.c-n.a*r,u=e?e.x:f,o=n.c-n.a*u);var c=[r,i],l=[u,o];a[n.region.l.index].push(c,l),a[n.region.r.index].push(c,l)}),a=a.map(function(n,e){var r=t[e][0],u=t[e][1],i=n.map(function(n){return Math.atan2(n[0]-r,n[1]-u)}),o=mo.range(n.length).sort(function(n,t){return i[n]-i[t]});return o.filter(function(n,t){return!t||i[n]-i[o[t-1]]>Go}).map(function(t){return n[t]})}),a.forEach(function(n,e){var r=n.length;if(!r)return n.push([-f,-f],[-f,f],[f,f],[f,-f]);if(!(r>2)){var u=t[e],i=n[0],o=n[1],a=u[0],c=u[1],l=i[0],s=i[1],h=o[0],g=o[1],p=Math.abs(h-l),d=g-s;if(Math.abs(d)c?-f:f;n.push([-f,v],[f,v])}else if(Go>p){var m=l>a?-f:f;n.push([m,-f],[m,f])}else{var v=(l-a)*(g-s)>(h-l)*(s-c)?f:-f,y=Math.abs(d)-p;Math.abs(y)d?v:-v,v]):(y>0&&(v*=-1),n.push([-f,v],[f,v]))}}}),u)for(o=0;s>o;++o)u.clip(a[o]);for(o=0;s>o;++o)a[o].point=n[o];return a}var e=Ze,r=Ve,u=null;return arguments.length?t(n):(t.x=function(n){return arguments.length?(e=n,t):e},t.y=function(n){return arguments.length?(r=n,t):r},t.clipExtent=function(n){if(!arguments.length)return u&&[u[0],u[2]];if(null==n)u=null;else{var e=+n[0][0],r=+n[0][1],i=+n[1][0],o=+n[1][1];u=mo.geom.polygon([[e,r],[e,o],[i,o],[i,r]])}return t},t.size=function(n){return arguments.length?t.clipExtent(n&&[[0,0],n]):u&&u[2]},t.links=function(n){var t,u,i,o=n.map(function(){return[]}),a=[],c=pt(e),l=pt(r),s=n.length;if(c===Ze&&l===Ve)t=n;else for(t=new Array(s),i=0;s>i;++i)t[i]=[+c.call(this,u=n[i],i),+l.call(this,u,i)];return vr(t,function(t){var e=t.region.l.index,r=t.region.r.index;o[e][r]||(o[e][r]=o[r][e]=!0,a.push({source:n[e],target:n[r]}))}),a},t.triangles=function(n){if(e===Ze&&r===Ve)return mo.geom.delaunay(n);for(var t,u=new Array(c),i=pt(e),o=pt(r),a=-1,c=n.length;++a=l,h=r>=s,g=(h<<1)+f;n.leaf=!1,n=n.nodes[g]||(n.nodes[g]=Mr()),f?u=l:a=l,h?o=s:c=s,i(n,t,e,r,u,o,a,c)}var s,f,h,g,p,d,v,m,y,M=pt(a),x=pt(c);if(null!=t)d=t,v=e,m=r,y=u;else if(m=y=-(d=v=1/0),f=[],h=[],p=n.length,o)for(g=0;p>g;++g)s=n[g],s.xm&&(m=s.x),s.y>y&&(y=s.y),f.push(s.x),h.push(s.y);else for(g=0;p>g;++g){var b=+M(s=n[g],g),_=+x(s,g);d>b&&(d=b),v>_&&(v=_),b>m&&(m=b),_>y&&(y=_),f.push(b),h.push(_)}var w=m-d,S=y-v;w>S?y=v+w:m=d+S;var E=Mr();if(E.add=function(n){i(E,n,+M(n,++g),+x(n,g),d,v,m,y)},E.visit=function(n){xr(n,E,d,v,m,y)},g=-1,null==t){for(;++g=0?n.substring(0,t):n,r=t>=0?n.substring(t+1):"in";return e=bc.get(e)||xc,r=_c.get(r)||dt,Ar(r(e.apply(null,Array.prototype.slice.call(arguments,1))))},mo.interpolateHcl=Rr,mo.interpolateHsl=Yr,mo.interpolateLab=Ir,mo.interpolateRound=Ur,mo.transform=function(n){var t=xo.createElementNS(mo.ns.prefix.svg,"g");return(mo.transform=function(n){if(null!=n){t.setAttribute("transform",n);var e=t.transform.baseVal.consolidate()}return new Zr(e?e.matrix:wc)})(n)},Zr.prototype.toString=function(){return"translate("+this.translate+")rotate("+this.rotate+")skewX("+this.skew+")scale("+this.scale+")"};var wc={a:1,b:0,c:0,d:1,e:0,f:0};mo.interpolateTransform=Br,mo.layout={},mo.layout.bundle=function(){return function(n){for(var t=[],e=-1,r=n.length;++e(u-e)*a){var c=t.charge*a*a;return n.px-=i*c,n.py-=o*c,!0}if(t.point&&isFinite(a)){var c=t.pointCharge*a*a;n.px-=i*c,n.py-=o*c}}return!t.charge}}function t(n){n.px=mo.event.x,n.py=mo.event.y,a.resume()}var e,r,u,i,o,a={},c=mo.dispatch("start","tick","end"),l=[1,1],s=.9,f=Sc,h=Ec,g=-30,p=.1,d=.8,v=[],m=[];return a.tick=function(){if((r*=.99)<.005)return c.end({type:"end",alpha:r=0}),!0;var t,e,a,f,h,d,y,M,x,b=v.length,_=m.length;for(e=0;_>e;++e)a=m[e],f=a.source,h=a.target,M=h.x-f.x,x=h.y-f.y,(d=M*M+x*x)&&(d=r*i[e]*((d=Math.sqrt(d))-u[e])/d,M*=d,x*=d,h.x-=M*(y=f.weight/(h.weight+f.weight)),h.y-=x*y,f.x+=M*(y=1-y),f.y+=x*y);if((y=r*p)&&(M=l[0]/2,x=l[1]/2,e=-1,y))for(;++e0?n:0:n>0&&(c.start({type:"start",alpha:r=n}),mo.timer(a.tick)),a):r},a.start=function(){function n(n,r){for(var u,i=t(e),o=-1,a=i.length;++or;++r)c[r]=[];for(r=0;d>r;++r){var n=m[r];c[n.source.index].push(n.target),c[n.target.index].push(n.source)}}return c[e]}var e,r,c,s,p=v.length,d=m.length,y=l[0],M=l[1];for(e=0;p>e;++e)(s=v[e]).index=e,s.weight=0;for(e=0;d>e;++e)s=m[e],"number"==typeof s.source&&(s.source=v[s.source]),"number"==typeof s.target&&(s.target=v[s.target]),++s.source.weight,++s.target.weight;for(e=0;p>e;++e)s=v[e],isNaN(s.x)&&(s.x=n("x",y)),isNaN(s.y)&&(s.y=n("y",M)),isNaN(s.px)&&(s.px=s.x),isNaN(s.py)&&(s.py=s.y);if(u=[],"function"==typeof f)for(e=0;d>e;++e)u[e]=+f.call(this,m[e],e);else for(e=0;d>e;++e)u[e]=f;if(i=[],"function"==typeof h)for(e=0;d>e;++e)i[e]=+h.call(this,m[e],e);else for(e=0;d>e;++e)i[e]=h;if(o=[],"function"==typeof g)for(e=0;p>e;++e)o[e]=+g.call(this,v[e],e);else for(e=0;p>e;++e)o[e]=g;return a.resume()},a.resume=function(){return a.alpha(.1)},a.stop=function(){return a.alpha(0)},a.drag=function(){return e||(e=mo.behavior.drag().origin(dt).on("dragstart.force",nu).on("drag.force",t).on("dragend.force",tu)),arguments.length?(this.on("mouseover.force",eu).on("mouseout.force",ru).call(e),void 0):e},mo.rebind(a,c,"on")};var Sc=20,Ec=1;mo.layout.hierarchy=function(){function n(t,o,a){var c=u.call(e,t,o);if(t.depth=o,a.push(t),c&&(l=c.length)){for(var l,s,f=-1,h=t.children=[],g=0,p=o+1;++fg;++g)for(u.call(n,l[0][g],p=d[g],s[0][g][1]),h=1;v>h;++h)u.call(n,l[h][g],p+=s[h-1][g][1],s[h][g][1]);return a}var t=dt,e=gu,r=pu,u=hu,i=su,o=fu;return n.values=function(e){return arguments.length?(t=e,n):t},n.order=function(t){return arguments.length?(e="function"==typeof t?t:Ac.get(t)||gu,n):e},n.offset=function(t){return arguments.length?(r="function"==typeof t?t:Nc.get(t)||pu,n):r},n.x=function(t){return arguments.length?(i=t,n):i},n.y=function(t){return arguments.length?(o=t,n):o},n.out=function(t){return arguments.length?(u=t,n):u},n};var Ac=mo.map({"inside-out":function(n){var t,e,r=n.length,u=n.map(du),i=n.map(vu),o=mo.range(r).sort(function(n,t){return u[n]-u[t]}),a=0,c=0,l=[],s=[];for(t=0;r>t;++t)e=o[t],c>a?(a+=i[e],l.push(e)):(c+=i[e],s.push(e));return s.reverse().concat(l)},reverse:function(n){return mo.range(n.length).reverse()},"default":gu}),Nc=mo.map({silhouette:function(n){var t,e,r,u=n.length,i=n[0].length,o=[],a=0,c=[];for(e=0;i>e;++e){for(t=0,r=0;u>t;t++)r+=n[t][e][1];r>a&&(a=r),o.push(r)}for(e=0;i>e;++e)c[e]=(a-o[e])/2;return c},wiggle:function(n){var t,e,r,u,i,o,a,c,l,s=n.length,f=n[0],h=f.length,g=[];for(g[0]=c=l=0,e=1;h>e;++e){for(t=0,u=0;s>t;++t)u+=n[t][e][1];for(t=0,i=0,a=f[e][0]-f[e-1][0];s>t;++t){for(r=0,o=(n[t][e][1]-n[t][e-1][1])/(2*a);t>r;++r)o+=(n[r][e][1]-n[r][e-1][1])/a;i+=o*n[t][e][1]}g[e]=c-=u?i/u*a:0,l>c&&(l=c)}for(e=0;h>e;++e)g[e]-=l;return g},expand:function(n){var t,e,r,u=n.length,i=n[0].length,o=1/u,a=[];for(e=0;i>e;++e){for(t=0,r=0;u>t;t++)r+=n[t][e][1];if(r)for(t=0;u>t;t++)n[t][e][1]/=r;else for(t=0;u>t;t++)n[t][e][1]=o}for(e=0;i>e;++e)a[e]=0;return a},zero:pu});mo.layout.histogram=function(){function n(n,i){for(var o,a,c=[],l=n.map(e,this),s=r.call(this,l,i),f=u.call(this,s,l,i),i=-1,h=l.length,g=f.length-1,p=t?1:1/h;++i0)for(i=-1;++i=s[0]&&a<=s[1]&&(o=c[mo.bisect(f,a,1,g)-1],o.y+=p,o.push(n[i]));return c}var t=!0,e=Number,r=xu,u=yu;return n.value=function(t){return arguments.length?(e=t,n):e},n.range=function(t){return arguments.length?(r=pt(t),n):r},n.bins=function(t){return arguments.length?(u="number"==typeof t?function(n){return Mu(n,t)}:pt(t),n):u},n.frequency=function(e){return arguments.length?(t=!!e,n):t},n},mo.layout.tree=function(){function n(n,i){function o(n,t){var r=n.children,u=n._tree;if(r&&(i=r.length)){for(var i,a,l,s=r[0],f=s,h=-1;++h0&&(qu(zu(a,n,r),n,u),l+=u,s+=u),f+=a._tree.mod,l+=i._tree.mod,h+=c._tree.mod,s+=o._tree.mod;a&&!wu(o)&&(o._tree.thread=a,o._tree.mod+=f-s),i&&!_u(c)&&(c._tree.thread=i,c._tree.mod+=l-h,r=n)}return r}var l=t.call(this,n,i),s=l[0];Nu(s,function(n,t){n._tree={ancestor:n,prelim:0,mod:0,change:0,shift:0,number:t?t._tree.number+1:0}}),o(s),a(s,-s._tree.prelim);var f=Su(s,ku),h=Su(s,Eu),g=Su(s,Au),p=f.x-e(f,h)/2,d=h.x+e(h,f)/2,v=g.depth||1;return Nu(s,u?function(n){n.x*=r[0],n.y=n.depth*r[1],delete n._tree}:function(n){n.x=(n.x-p)/(d-p)*r[0],n.y=n.depth/v*r[1],delete n._tree}),l}var t=mo.layout.hierarchy().sort(null).value(null),e=bu,r=[1,1],u=!1;return n.separation=function(t){return arguments.length?(e=t,n):e},n.size=function(t){return arguments.length?(u=null==(r=t),n):u?null:r},n.nodeSize=function(t){return arguments.length?(u=null!=(r=t),n):u?r:null},iu(n,t)},mo.layout.pack=function(){function n(n,i){var o=e.call(this,n,i),a=o[0],c=u[0],l=u[1],s=null==t?Math.sqrt:"function"==typeof t?t:function(){return t};if(a.x=a.y=0,Nu(a,function(n){n.r=+s(n.value)}),Nu(a,Hu),r){var f=r*(t?1:Math.max(2*a.r/c,2*a.r/l))/2;Nu(a,function(n){n.r+=f}),Nu(a,Hu),Nu(a,function(n){n.r-=f})}return Ou(a,c/2,l/2,t?1:1/Math.max(2*a.r/c,2*a.r/l)),o}var t,e=mo.layout.hierarchy().sort(Cu),r=0,u=[1,1];return n.size=function(t){return arguments.length?(u=t,n):u},n.radius=function(e){return arguments.length?(t=null==e||"function"==typeof e?e:+e,n):t},n.padding=function(t){return arguments.length?(r=+t,n):r},iu(n,e)},mo.layout.cluster=function(){function n(n,i){var o,a=t.call(this,n,i),c=a[0],l=0;Nu(c,function(n){var t=n.children;t&&t.length?(n.x=Iu(t),n.y=Yu(t)):(n.x=o?l+=e(n,o):0,n.y=0,o=n)});var s=Uu(c),f=Zu(c),h=s.x-e(s,f)/2,g=f.x+e(f,s)/2;return Nu(c,u?function(n){n.x=(n.x-c.x)*r[0],n.y=(c.y-n.y)*r[1]}:function(n){n.x=(n.x-h)/(g-h)*r[0],n.y=(1-(c.y?n.y/c.y:1))*r[1]}),a}var t=mo.layout.hierarchy().sort(null).value(null),e=bu,r=[1,1],u=!1;return n.separation=function(t){return arguments.length?(e=t,n):e},n.size=function(t){return arguments.length?(u=null==(r=t),n):u?null:r},n.nodeSize=function(t){return arguments.length?(u=null!=(r=t),n):u?r:null},iu(n,t)},mo.layout.treemap=function(){function n(n,t){for(var e,r,u=-1,i=n.length;++ut?0:t),e.area=isNaN(r)||0>=r?0:r}function t(e){var i=e.children;if(i&&i.length){var o,a,c,l=f(e),s=[],h=i.slice(),p=1/0,d="slice"===g?l.dx:"dice"===g?l.dy:"slice-dice"===g?1&e.depth?l.dy:l.dx:Math.min(l.dx,l.dy);for(n(h,l.dx*l.dy/e.value),s.area=0;(c=h.length)>0;)s.push(o=h[c-1]),s.area+=o.area,"squarify"!==g||(a=r(s,d))<=p?(h.pop(),p=a):(s.area-=s.pop().area,u(s,d,l,!1),d=Math.min(l.dx,l.dy),s.length=s.area=0,p=1/0);s.length&&(u(s,d,l,!0),s.length=s.area=0),i.forEach(t)}}function e(t){var r=t.children;if(r&&r.length){var i,o=f(t),a=r.slice(),c=[];for(n(a,o.dx*o.dy/t.value),c.area=0;i=a.pop();)c.push(i),c.area+=i.area,null!=i.z&&(u(c,i.z?o.dx:o.dy,o,!a.length),c.length=c.area=0);r.forEach(e)}}function r(n,t){for(var e,r=n.area,u=0,i=1/0,o=-1,a=n.length;++oe&&(i=e),e>u&&(u=e));return r*=r,t*=t,r?Math.max(t*u*p/r,r/(t*i*p)):1/0}function u(n,t,e,r){var u,i=-1,o=n.length,a=e.x,l=e.y,s=t?c(n.area/t):0;if(t==e.dx){for((r||s>e.dy)&&(s=e.dy);++ie.dx)&&(s=e.dx);++ie&&(t=1),1>e&&(n=0),function(){var e,r,u;do e=2*Math.random()-1,r=2*Math.random()-1,u=e*e+r*r;while(!u||u>1);return n+t*e*Math.sqrt(-2*Math.log(u)/u)}},logNormal:function(){var n=mo.random.normal.apply(mo,arguments);return function(){return Math.exp(n())}},irwinHall:function(n){return function(){for(var t=0,e=0;n>e;e++)t+=Math.random();return t/n}}},mo.scale={};var Tc={floor:dt,ceil:dt};mo.scale.linear=function(){return Qu([0,1],[0,1],Er,!1)},mo.scale.log=function(){return ii(mo.scale.linear().domain([0,1]),10,!0,[1,10])};var qc=mo.format(".0e"),zc={floor:function(n){return-Math.ceil(-n)},ceil:function(n){return-Math.floor(-n)}};mo.scale.pow=function(){return oi(mo.scale.linear(),1,[0,1])},mo.scale.sqrt=function(){return mo.scale.pow().exponent(.5)},mo.scale.ordinal=function(){return ci([],{t:"range",a:[[]]})},mo.scale.category10=function(){return mo.scale.ordinal().range(Cc)},mo.scale.category20=function(){return mo.scale.ordinal().range(Dc)},mo.scale.category20b=function(){return mo.scale.ordinal().range(jc)},mo.scale.category20c=function(){return mo.scale.ordinal().range(Lc)};var Cc=[2062260,16744206,2924588,14034728,9725885,9197131,14907330,8355711,12369186,1556175].map(it),Dc=[2062260,11454440,16744206,16759672,2924588,10018698,14034728,16750742,9725885,12955861,9197131,12885140,14907330,16234194,8355711,13092807,12369186,14408589,1556175,10410725].map(it),jc=[3750777,5395619,7040719,10264286,6519097,9216594,11915115,13556636,9202993,12426809,15186514,15190932,8666169,11356490,14049643,15177372,8077683,10834324,13528509,14589654].map(it),Lc=[3244733,7057110,10406625,13032431,15095053,16616764,16625259,16634018,3253076,7652470,10607003,13101504,7695281,10394312,12369372,14342891,6513507,9868950,12434877,14277081].map(it);mo.scale.quantile=function(){return li([],[])},mo.scale.quantize=function(){return si(0,1,[0,1])},mo.scale.threshold=function(){return fi([.5],[0,1])},mo.scale.identity=function(){return hi([0,1])},mo.svg.arc=function(){function n(){var n=t.apply(this,arguments),i=e.apply(this,arguments),o=r.apply(this,arguments)+Hc,a=u.apply(this,arguments)+Hc,c=(o>a&&(c=o,o=a,a=c),a-o),l=Bo>c?"0":"1",s=Math.cos(o),f=Math.sin(o),h=Math.cos(a),g=Math.sin(a);return c>=Fc?n?"M0,"+i+"A"+i+","+i+" 0 1,1 0,"+-i+"A"+i+","+i+" 0 1,1 0,"+i+"M0,"+n+"A"+n+","+n+" 0 1,0 0,"+-n+"A"+n+","+n+" 0 1,0 0,"+n+"Z":"M0,"+i+"A"+i+","+i+" 0 1,1 0,"+-i+"A"+i+","+i+" 0 1,1 0,"+i+"Z":n?"M"+i*s+","+i*f+"A"+i+","+i+" 0 "+l+",1 "+i*h+","+i*g+"L"+n*h+","+n*g+"A"+n+","+n+" 0 "+l+",0 "+n*s+","+n*f+"Z":"M"+i*s+","+i*f+"A"+i+","+i+" 0 "+l+",1 "+i*h+","+i*g+"L0,0"+"Z"}var t=gi,e=pi,r=di,u=vi;return n.innerRadius=function(e){return arguments.length?(t=pt(e),n):t},n.outerRadius=function(t){return arguments.length?(e=pt(t),n):e},n.startAngle=function(t){return arguments.length?(r=pt(t),n):r},n.endAngle=function(t){return arguments.length?(u=pt(t),n):u},n.centroid=function(){var n=(t.apply(this,arguments)+e.apply(this,arguments))/2,i=(r.apply(this,arguments)+u.apply(this,arguments))/2+Hc;return[Math.cos(i)*n,Math.sin(i)*n]},n};var Hc=-Jo,Fc=Wo-Go;mo.svg.line.radial=function(){var n=Ue(mi);return n.radius=n.x,delete n.x,n.angle=n.y,delete n.y,n},We.reverse=Je,Je.reverse=We,mo.svg.area=function(){return yi(dt)},mo.svg.area.radial=function(){var n=yi(mi);return n.radius=n.x,delete n.x,n.innerRadius=n.x0,delete n.x0,n.outerRadius=n.x1,delete n.x1,n.angle=n.y,delete n.y,n.startAngle=n.y0,delete n.y0,n.endAngle=n.y1,delete n.y1,n},mo.svg.chord=function(){function n(n,a){var c=t(this,i,n,a),l=t(this,o,n,a);return"M"+c.p0+r(c.r,c.p1,c.a1-c.a0)+(e(c,l)?u(c.r,c.p1,c.r,c.p0):u(c.r,c.p1,l.r,l.p0)+r(l.r,l.p1,l.a1-l.a0)+u(l.r,l.p1,c.r,c.p0))+"Z"}function t(n,t,e,r){var u=t.call(n,e,r),i=a.call(n,u,r),o=c.call(n,u,r)+Hc,s=l.call(n,u,r)+Hc;return{r:i,a0:o,a1:s,p0:[i*Math.cos(o),i*Math.sin(o)],p1:[i*Math.cos(s),i*Math.sin(s)]}}function e(n,t){return n.a0==t.a0&&n.a1==t.a1}function r(n,t,e){return"A"+n+","+n+" 0 "+ +(e>Bo)+",1 "+t}function u(n,t,e,r){return"Q 0,0 "+r}var i=De,o=je,a=Mi,c=di,l=vi;return n.radius=function(t){return arguments.length?(a=pt(t),n):a},n.source=function(t){return arguments.length?(i=pt(t),n):i},n.target=function(t){return arguments.length?(o=pt(t),n):o},n.startAngle=function(t){return arguments.length?(c=pt(t),n):c},n.endAngle=function(t){return arguments.length?(l=pt(t),n):l},n},mo.svg.diagonal=function(){function n(n,u){var i=t.call(this,n,u),o=e.call(this,n,u),a=(i.y+o.y)/2,c=[i,{x:i.x,y:a},{x:o.x,y:a},o];return c=c.map(r),"M"+c[0]+"C"+c[1]+" "+c[2]+" "+c[3]}var t=De,e=je,r=xi;return n.source=function(e){return arguments.length?(t=pt(e),n):t},n.target=function(t){return arguments.length?(e=pt(t),n):e},n.projection=function(t){return arguments.length?(r=t,n):r},n},mo.svg.diagonal.radial=function(){var n=mo.svg.diagonal(),t=xi,e=n.projection;return n.projection=function(n){return arguments.length?e(bi(t=n)):t},n},mo.svg.symbol=function(){function n(n,r){return(Pc.get(t.call(this,n,r))||Si)(e.call(this,n,r))}var t=wi,e=_i;return n.type=function(e){return arguments.length?(t=pt(e),n):t},n.size=function(t){return arguments.length?(e=pt(t),n):e},n};var Pc=mo.map({circle:Si,cross:function(n){var t=Math.sqrt(n/5)/2;return"M"+-3*t+","+-t+"H"+-t+"V"+-3*t+"H"+t+"V"+-t+"H"+3*t+"V"+t+"H"+t+"V"+3*t+"H"+-t+"V"+t+"H"+-3*t+"Z"},diamond:function(n){var t=Math.sqrt(n/(2*Ic)),e=t*Ic;return"M0,"+-t+"L"+e+",0"+" 0,"+t+" "+-e+",0"+"Z"},square:function(n){var t=Math.sqrt(n)/2;return"M"+-t+","+-t+"L"+t+","+-t+" "+t+","+t+" "+-t+","+t+"Z"},"triangle-down":function(n){var t=Math.sqrt(n/Yc),e=t*Yc/2;return"M0,"+e+"L"+t+","+-e+" "+-t+","+-e+"Z"},"triangle-up":function(n){var t=Math.sqrt(n/Yc),e=t*Yc/2;return"M0,"+-e+"L"+t+","+e+" "+-t+","+e+"Z"}});mo.svg.symbolTypes=Pc.keys();var Oc,Rc,Yc=Math.sqrt(3),Ic=Math.tan(30*Qo),Uc=[],Zc=0;Uc.call=Ro.call,Uc.empty=Ro.empty,Uc.node=Ro.node,Uc.size=Ro.size,mo.transition=function(n){return arguments.length?Oc?n.transition():n:Uo.transition()},mo.transition.prototype=Uc,Uc.select=function(n){var t,e,r,u=this.id,i=[];n=d(n);for(var o=-1,a=this.length;++oi;i++){u.push(t=[]);for(var e=this[i],a=0,c=e.length;c>a;a++)(r=e[a])&&n.call(r,r.__data__,a)&&t.push(r)}return Ei(u,this.id)},Uc.tween=function(n,t){var e=this.id;return arguments.length<2?this.node().__transition__[e].tween.get(n):N(this,null==t?function(t){t.__transition__[e].tween.remove(n)}:function(r){r.__transition__[e].tween.set(n,t)})},Uc.attr=function(n,t){function e(){this.removeAttribute(a)}function r(){this.removeAttributeNS(a.space,a.local)}function u(n){return null==n?e:(n+="",function(){var t,e=this.getAttribute(a);return e!==n&&(t=o(e,n),function(n){this.setAttribute(a,t(n))})})}function i(n){return null==n?r:(n+="",function(){var t,e=this.getAttributeNS(a.space,a.local);return e!==n&&(t=o(e,n),function(n){this.setAttributeNS(a.space,a.local,t(n)) +})})}if(arguments.length<2){for(t in n)this.attr(t,n[t]);return this}var o="transform"==n?Br:Er,a=mo.ns.qualify(n);return ki(this,"attr."+n,t,a.local?i:u)},Uc.attrTween=function(n,t){function e(n,e){var r=t.call(this,n,e,this.getAttribute(u));return r&&function(n){this.setAttribute(u,r(n))}}function r(n,e){var r=t.call(this,n,e,this.getAttributeNS(u.space,u.local));return r&&function(n){this.setAttributeNS(u.space,u.local,r(n))}}var u=mo.ns.qualify(n);return this.tween("attr."+n,u.local?r:e)},Uc.style=function(n,t,e){function r(){this.style.removeProperty(n)}function u(t){return null==t?r:(t+="",function(){var r,u=_o.getComputedStyle(this,null).getPropertyValue(n);return u!==t&&(r=Er(u,t),function(t){this.style.setProperty(n,r(t),e)})})}var i=arguments.length;if(3>i){if("string"!=typeof n){2>i&&(t="");for(e in n)this.style(e,n[e],t);return this}e=""}return ki(this,"style."+n,t,u)},Uc.styleTween=function(n,t,e){function r(r,u){var i=t.call(this,r,u,_o.getComputedStyle(this,null).getPropertyValue(n));return i&&function(t){this.style.setProperty(n,i(t),e)}}return arguments.length<3&&(e=""),this.tween("style."+n,r)},Uc.text=function(n){return ki(this,"text",n,Ai)},Uc.remove=function(){return this.each("end.transition",function(){var n;this.__transition__.count<2&&(n=this.parentNode)&&n.removeChild(this)})},Uc.ease=function(n){var t=this.id;return arguments.length<1?this.node().__transition__[t].ease:("function"!=typeof n&&(n=mo.ease.apply(mo,arguments)),N(this,function(e){e.__transition__[t].ease=n}))},Uc.delay=function(n){var t=this.id;return N(this,"function"==typeof n?function(e,r,u){e.__transition__[t].delay=+n.call(e,e.__data__,r,u)}:(n=+n,function(e){e.__transition__[t].delay=n}))},Uc.duration=function(n){var t=this.id;return N(this,"function"==typeof n?function(e,r,u){e.__transition__[t].duration=Math.max(1,n.call(e,e.__data__,r,u))}:(n=Math.max(1,n),function(e){e.__transition__[t].duration=n}))},Uc.each=function(n,t){var e=this.id;if(arguments.length<2){var r=Rc,u=Oc;Oc=e,N(this,function(t,r,u){Rc=t.__transition__[e],n.call(t,t.__data__,r,u)}),Rc=r,Oc=u}else N(this,function(r){var u=r.__transition__[e];(u.event||(u.event=mo.dispatch("start","end"))).on(n,t)});return this},Uc.transition=function(){for(var n,t,e,r,u=this.id,i=++Zc,o=[],a=0,c=this.length;c>a;a++){o.push(n=[]);for(var t=this[a],l=0,s=t.length;s>l;l++)(e=t[l])&&(r=Object.create(e.__transition__[u]),r.delay+=r.duration,Ni(e,l,i,r)),n.push(e)}return Ei(o,i)},mo.svg.axis=function(){function n(n){n.each(function(){var n,l=mo.select(this),s=this.__chart__||e,f=this.__chart__=e.copy(),h=null==c?f.ticks?f.ticks.apply(f,a):f.domain():c,g=null==t?f.tickFormat?f.tickFormat.apply(f,a):dt:t,p=l.selectAll(".tick").data(h,f),d=p.enter().insert("g",".domain").attr("class","tick").style("opacity",Go),v=mo.transition(p.exit()).style("opacity",Go).remove(),m=mo.transition(p).style("opacity",1),y=Bu(f),M=l.selectAll(".domain").data([0]),x=(M.enter().append("path").attr("class","domain"),mo.transition(M));d.append("line"),d.append("text");var b=d.select("line"),_=m.select("line"),w=p.select("text").text(g),S=d.select("text"),E=m.select("text");switch(r){case"bottom":n=Ti,b.attr("y2",u),S.attr("y",Math.max(u,0)+o),_.attr("x2",0).attr("y2",u),E.attr("x",0).attr("y",Math.max(u,0)+o),w.attr("dy",".71em").style("text-anchor","middle"),x.attr("d","M"+y[0]+","+i+"V0H"+y[1]+"V"+i);break;case"top":n=Ti,b.attr("y2",-u),S.attr("y",-(Math.max(u,0)+o)),_.attr("x2",0).attr("y2",-u),E.attr("x",0).attr("y",-(Math.max(u,0)+o)),w.attr("dy","0em").style("text-anchor","middle"),x.attr("d","M"+y[0]+","+-i+"V0H"+y[1]+"V"+-i);break;case"left":n=qi,b.attr("x2",-u),S.attr("x",-(Math.max(u,0)+o)),_.attr("x2",-u).attr("y2",0),E.attr("x",-(Math.max(u,0)+o)).attr("y",0),w.attr("dy",".32em").style("text-anchor","end"),x.attr("d","M"+-i+","+y[0]+"H0V"+y[1]+"H"+-i);break;case"right":n=qi,b.attr("x2",u),S.attr("x",Math.max(u,0)+o),_.attr("x2",u).attr("y2",0),E.attr("x",Math.max(u,0)+o).attr("y",0),w.attr("dy",".32em").style("text-anchor","start"),x.attr("d","M"+i+","+y[0]+"H0V"+y[1]+"H"+i)}if(f.rangeBand){var k=f.rangeBand()/2,A=function(n){return f(n)+k};d.call(n,A),m.call(n,A)}else d.call(n,s),m.call(n,f),v.call(n,f)})}var t,e=mo.scale.linear(),r=Vc,u=6,i=6,o=3,a=[10],c=null;return n.scale=function(t){return arguments.length?(e=t,n):e},n.orient=function(t){return arguments.length?(r=t in Xc?t+"":Vc,n):r},n.ticks=function(){return arguments.length?(a=arguments,n):a},n.tickValues=function(t){return arguments.length?(c=t,n):c},n.tickFormat=function(e){return arguments.length?(t=e,n):t},n.tickSize=function(t){var e=arguments.length;return e?(u=+t,i=+arguments[e-1],n):u},n.innerTickSize=function(t){return arguments.length?(u=+t,n):u},n.outerTickSize=function(t){return arguments.length?(i=+t,n):i},n.tickPadding=function(t){return arguments.length?(o=+t,n):o},n.tickSubdivide=function(){return arguments.length&&n},n};var Vc="bottom",Xc={top:1,right:1,bottom:1,left:1};mo.svg.brush=function(){function n(i){i.each(function(){var i=mo.select(this).style("pointer-events","all").style("-webkit-tap-highlight-color","rgba(0,0,0,0)").on("mousedown.brush",u).on("touchstart.brush",u),o=i.selectAll(".background").data([0]);o.enter().append("rect").attr("class","background").style("visibility","hidden").style("cursor","crosshair"),i.selectAll(".extent").data([0]).enter().append("rect").attr("class","extent").style("cursor","move");var a=i.selectAll(".resize").data(v,dt);a.exit().remove(),a.enter().append("g").attr("class",function(n){return"resize "+n}).style("cursor",function(n){return $c[n]}).append("rect").attr("x",function(n){return/[ew]$/.test(n)?-3:null}).attr("y",function(n){return/^[ns]/.test(n)?-3:null}).attr("width",6).attr("height",6).style("visibility","hidden"),a.style("display",n.empty()?"none":null);var s,f=mo.transition(i),h=mo.transition(o);c&&(s=Bu(c),h.attr("x",s[0]).attr("width",s[1]-s[0]),e(f)),l&&(s=Bu(l),h.attr("y",s[0]).attr("height",s[1]-s[0]),r(f)),t(f)})}function t(n){n.selectAll(".resize").attr("transform",function(n){return"translate("+s[+/e$/.test(n)]+","+h[+/^s/.test(n)]+")"})}function e(n){n.select(".extent").attr("x",s[0]),n.selectAll(".extent,.n>rect,.s>rect").attr("width",s[1]-s[0])}function r(n){n.select(".extent").attr("y",h[0]),n.selectAll(".extent,.e>rect,.w>rect").attr("height",h[1]-h[0])}function u(){function u(){32==mo.event.keyCode&&(N||(M=null,q[0]-=s[1],q[1]-=h[1],N=2),f())}function g(){32==mo.event.keyCode&&2==N&&(q[0]+=s[1],q[1]+=h[1],N=0,f())}function v(){var n=mo.mouse(b),u=!1;x&&(n[0]+=x[0],n[1]+=x[1]),N||(mo.event.altKey?(M||(M=[(s[0]+s[1])/2,(h[0]+h[1])/2]),q[0]=s[+(n[0]f?(u=r,r=f):u=f),g[0]!=r||g[1]!=u?(e?o=null:i=null,g[0]=r,g[1]=u,!0):void 0}function y(){v(),S.style("pointer-events","all").selectAll(".resize").style("display",n.empty()?"none":null),mo.select("body").style("cursor",null),z.on("mousemove.brush",null).on("mouseup.brush",null).on("touchmove.brush",null).on("touchend.brush",null).on("keydown.brush",null).on("keyup.brush",null),T(),w({type:"brushend"})}var M,x,b=this,_=mo.select(mo.event.target),w=a.of(b,arguments),S=mo.select(b),E=_.datum(),k=!/^(n|s)$/.test(E)&&c,A=!/^(e|w)$/.test(E)&&l,N=_.classed("extent"),T=L(),q=mo.mouse(b),z=mo.select(_o).on("keydown.brush",u).on("keyup.brush",g);if(mo.event.changedTouches?z.on("touchmove.brush",v).on("touchend.brush",y):z.on("mousemove.brush",v).on("mouseup.brush",y),S.interrupt().selectAll("*").interrupt(),N)q[0]=s[0]-q[0],q[1]=h[0]-q[1];else if(E){var C=+/w$/.test(E),D=+/^n/.test(E);x=[s[1-C]-q[0],h[1-D]-q[1]],q[0]=s[C],q[1]=h[D]}else mo.event.altKey&&(M=q.slice());S.style("pointer-events","none").selectAll(".resize").style("display",null),mo.select("body").style("cursor",_.style("cursor")),w({type:"brushstart"}),v()}var i,o,a=g(n,"brushstart","brush","brushend"),c=null,l=null,s=[0,0],h=[0,0],p=!0,d=!0,v=Bc[0];return n.event=function(n){n.each(function(){var n=a.of(this,arguments),t={x:s,y:h,i:i,j:o},e=this.__chart__||t;this.__chart__=t,Oc?mo.select(this).transition().each("start.brush",function(){i=e.i,o=e.j,s=e.x,h=e.y,n({type:"brushstart"})}).tween("brush:brush",function(){var e=kr(s,t.x),r=kr(h,t.y);return i=o=null,function(u){s=t.x=e(u),h=t.y=r(u),n({type:"brush",mode:"resize"})}}).each("end.brush",function(){i=t.i,o=t.j,n({type:"brush",mode:"resize"}),n({type:"brushend"})}):(n({type:"brushstart"}),n({type:"brush",mode:"resize"}),n({type:"brushend"}))})},n.x=function(t){return arguments.length?(c=t,v=Bc[!c<<1|!l],n):c},n.y=function(t){return arguments.length?(l=t,v=Bc[!c<<1|!l],n):l},n.clamp=function(t){return arguments.length?(c&&l?(p=!!t[0],d=!!t[1]):c?p=!!t:l&&(d=!!t),n):c&&l?[p,d]:c?p:l?d:null},n.extent=function(t){var e,r,u,a,f;return arguments.length?(c&&(e=t[0],r=t[1],l&&(e=e[0],r=r[0]),i=[e,r],c.invert&&(e=c(e),r=c(r)),e>r&&(f=e,e=r,r=f),(e!=s[0]||r!=s[1])&&(s=[e,r])),l&&(u=t[0],a=t[1],c&&(u=u[1],a=a[1]),o=[u,a],l.invert&&(u=l(u),a=l(a)),u>a&&(f=u,u=a,a=f),(u!=h[0]||a!=h[1])&&(h=[u,a])),n):(c&&(i?(e=i[0],r=i[1]):(e=s[0],r=s[1],c.invert&&(e=c.invert(e),r=c.invert(r)),e>r&&(f=e,e=r,r=f))),l&&(o?(u=o[0],a=o[1]):(u=h[0],a=h[1],l.invert&&(u=l.invert(u),a=l.invert(a)),u>a&&(f=u,u=a,a=f))),c&&l?[[e,u],[r,a]]:c?[e,r]:l&&[u,a])},n.clear=function(){return n.empty()||(s=[0,0],h=[0,0],i=o=null),n},n.empty=function(){return!!c&&s[0]==s[1]||!!l&&h[0]==h[1]},mo.rebind(n,a,"on")};var $c={n:"ns-resize",e:"ew-resize",s:"ns-resize",w:"ew-resize",nw:"nwse-resize",ne:"nesw-resize",se:"nwse-resize",sw:"nesw-resize"},Bc=[["n","e","s","w","nw","ne","se","sw"],["e","w"],["n","s"],[]],Wc=mo.time={},Jc=Date,Gc=["Sunday","Monday","Tuesday","Wednesday","Thursday","Friday","Saturday"];zi.prototype={getDate:function(){return this._.getUTCDate()},getDay:function(){return this._.getUTCDay()},getFullYear:function(){return this._.getUTCFullYear()},getHours:function(){return this._.getUTCHours()},getMilliseconds:function(){return this._.getUTCMilliseconds()},getMinutes:function(){return this._.getUTCMinutes()},getMonth:function(){return this._.getUTCMonth()},getSeconds:function(){return this._.getUTCSeconds()},getTime:function(){return this._.getTime()},getTimezoneOffset:function(){return 0},valueOf:function(){return this._.valueOf()},setDate:function(){Kc.setUTCDate.apply(this._,arguments)},setDay:function(){Kc.setUTCDay.apply(this._,arguments)},setFullYear:function(){Kc.setUTCFullYear.apply(this._,arguments)},setHours:function(){Kc.setUTCHours.apply(this._,arguments)},setMilliseconds:function(){Kc.setUTCMilliseconds.apply(this._,arguments)},setMinutes:function(){Kc.setUTCMinutes.apply(this._,arguments)},setMonth:function(){Kc.setUTCMonth.apply(this._,arguments)},setSeconds:function(){Kc.setUTCSeconds.apply(this._,arguments)},setTime:function(){Kc.setTime.apply(this._,arguments)}};var Kc=Date.prototype,Qc="%a %b %e %X %Y",nl="%m/%d/%Y",tl="%H:%M:%S",el=["Sunday","Monday","Tuesday","Wednesday","Thursday","Friday","Saturday"],rl=["Sun","Mon","Tue","Wed","Thu","Fri","Sat"],ul=["January","February","March","April","May","June","July","August","September","October","November","December"],il=["Jan","Feb","Mar","Apr","May","Jun","Jul","Aug","Sep","Oct","Nov","Dec"];Wc.year=Ci(function(n){return n=Wc.day(n),n.setMonth(0,1),n},function(n,t){n.setFullYear(n.getFullYear()+t)},function(n){return n.getFullYear()}),Wc.years=Wc.year.range,Wc.years.utc=Wc.year.utc.range,Wc.day=Ci(function(n){var t=new Jc(2e3,0);return t.setFullYear(n.getFullYear(),n.getMonth(),n.getDate()),t},function(n,t){n.setDate(n.getDate()+t)},function(n){return n.getDate()-1}),Wc.days=Wc.day.range,Wc.days.utc=Wc.day.utc.range,Wc.dayOfYear=function(n){var t=Wc.year(n);return Math.floor((n-t-6e4*(n.getTimezoneOffset()-t.getTimezoneOffset()))/864e5)},Gc.forEach(function(n,t){n=n.toLowerCase(),t=7-t;var e=Wc[n]=Ci(function(n){return(n=Wc.day(n)).setDate(n.getDate()-(n.getDay()+t)%7),n},function(n,t){n.setDate(n.getDate()+7*Math.floor(t))},function(n){var e=Wc.year(n).getDay();return Math.floor((Wc.dayOfYear(n)+(e+t)%7)/7)-(e!==t)});Wc[n+"s"]=e.range,Wc[n+"s"].utc=e.utc.range,Wc[n+"OfYear"]=function(n){var e=Wc.year(n).getDay();return Math.floor((Wc.dayOfYear(n)+(e+t)%7)/7)}}),Wc.week=Wc.sunday,Wc.weeks=Wc.sunday.range,Wc.weeks.utc=Wc.sunday.utc.range,Wc.weekOfYear=Wc.sundayOfYear,Wc.format=ji;var ol=Hi(el),al=Fi(el),cl=Hi(rl),ll=Fi(rl),sl=Hi(ul),fl=Fi(ul),hl=Hi(il),gl=Fi(il),pl=/^%/,dl={"-":"",_:" ",0:"0"},vl={a:function(n){return rl[n.getDay()]},A:function(n){return el[n.getDay()]},b:function(n){return il[n.getMonth()]},B:function(n){return ul[n.getMonth()]},c:ji(Qc),d:function(n,t){return Pi(n.getDate(),t,2)},e:function(n,t){return Pi(n.getDate(),t,2)},H:function(n,t){return Pi(n.getHours(),t,2)},I:function(n,t){return Pi(n.getHours()%12||12,t,2)},j:function(n,t){return Pi(1+Wc.dayOfYear(n),t,3)},L:function(n,t){return Pi(n.getMilliseconds(),t,3)},m:function(n,t){return Pi(n.getMonth()+1,t,2)},M:function(n,t){return Pi(n.getMinutes(),t,2)},p:function(n){return n.getHours()>=12?"PM":"AM"},S:function(n,t){return Pi(n.getSeconds(),t,2)},U:function(n,t){return Pi(Wc.sundayOfYear(n),t,2)},w:function(n){return n.getDay()},W:function(n,t){return Pi(Wc.mondayOfYear(n),t,2)},x:ji(nl),X:ji(tl),y:function(n,t){return Pi(n.getFullYear()%100,t,2)},Y:function(n,t){return Pi(n.getFullYear()%1e4,t,4)},Z:ao,"%":function(){return"%"}},ml={a:Oi,A:Ri,b:Zi,B:Vi,c:Xi,d:no,e:no,H:eo,I:eo,j:to,L:io,m:Qi,M:ro,p:oo,S:uo,U:Ii,w:Yi,W:Ui,x:$i,X:Bi,y:Ji,Y:Wi,Z:Gi,"%":co},yl=/^\s*\d+/,Ml=mo.map({am:0,pm:1});ji.utc=lo;var xl=lo("%Y-%m-%dT%H:%M:%S.%LZ");ji.iso=Date.prototype.toISOString&&+new Date("2000-01-01T00:00:00.000Z")?so:xl,so.parse=function(n){var t=new Date(n);return isNaN(t)?null:t},so.toString=xl.toString,Wc.second=Ci(function(n){return new Jc(1e3*Math.floor(n/1e3))},function(n,t){n.setTime(n.getTime()+1e3*Math.floor(t))},function(n){return n.getSeconds()}),Wc.seconds=Wc.second.range,Wc.seconds.utc=Wc.second.utc.range,Wc.minute=Ci(function(n){return new Jc(6e4*Math.floor(n/6e4))},function(n,t){n.setTime(n.getTime()+6e4*Math.floor(t))},function(n){return n.getMinutes()}),Wc.minutes=Wc.minute.range,Wc.minutes.utc=Wc.minute.utc.range,Wc.hour=Ci(function(n){var t=n.getTimezoneOffset()/60;return new Jc(36e5*(Math.floor(n/36e5-t)+t))},function(n,t){n.setTime(n.getTime()+36e5*Math.floor(t))},function(n){return n.getHours()}),Wc.hours=Wc.hour.range,Wc.hours.utc=Wc.hour.utc.range,Wc.month=Ci(function(n){return n=Wc.day(n),n.setDate(1),n},function(n,t){n.setMonth(n.getMonth()+t)},function(n){return n.getMonth()}),Wc.months=Wc.month.range,Wc.months.utc=Wc.month.utc.range;var bl=[1e3,5e3,15e3,3e4,6e4,3e5,9e5,18e5,36e5,108e5,216e5,432e5,864e5,1728e5,6048e5,2592e6,7776e6,31536e6],_l=[[Wc.second,1],[Wc.second,5],[Wc.second,15],[Wc.second,30],[Wc.minute,1],[Wc.minute,5],[Wc.minute,15],[Wc.minute,30],[Wc.hour,1],[Wc.hour,3],[Wc.hour,6],[Wc.hour,12],[Wc.day,1],[Wc.day,2],[Wc.week,1],[Wc.month,1],[Wc.month,3],[Wc.year,1]],wl=[[ji("%Y"),Vt],[ji("%B"),function(n){return n.getMonth()}],[ji("%b %d"),function(n){return 1!=n.getDate()}],[ji("%a %d"),function(n){return n.getDay()&&1!=n.getDate()}],[ji("%I %p"),function(n){return n.getHours()}],[ji("%I:%M"),function(n){return n.getMinutes()}],[ji(":%S"),function(n){return n.getSeconds()}],[ji(".%L"),function(n){return n.getMilliseconds()}]],Sl=go(wl);_l.year=Wc.year,Wc.scale=function(){return fo(mo.scale.linear(),_l,Sl)};var El={range:function(n,t,e){return mo.range(+n,+t,e).map(ho)}},kl=_l.map(function(n){return[n[0].utc,n[1]]}),Al=[[lo("%Y"),Vt],[lo("%B"),function(n){return n.getUTCMonth()}],[lo("%b %d"),function(n){return 1!=n.getUTCDate()}],[lo("%a %d"),function(n){return n.getUTCDay()&&1!=n.getUTCDate()}],[lo("%I %p"),function(n){return n.getUTCHours()}],[lo("%I:%M"),function(n){return n.getUTCMinutes()}],[lo(":%S"),function(n){return n.getUTCSeconds()}],[lo(".%L"),function(n){return n.getUTCMilliseconds()}]],Nl=go(Al);return kl.year=Wc.year.utc,Wc.scale.utc=function(){return fo(mo.scale.linear(),kl,Nl)},mo.text=vt(function(n){return n.responseText}),mo.json=function(n,t){return mt(n,"application/json",po,t)},mo.html=function(n,t){return mt(n,"text/html",vo,t)},mo.xml=vt(function(n){return n.responseXML}),mo}(); \ No newline at end of file diff --git a/airflow/www/static/dagre-d3.js b/airflow/www/static/dagre-d3.js new file mode 100644 index 0000000000000..2da7cdda08275 --- /dev/null +++ b/airflow/www/static/dagre-d3.js @@ -0,0 +1,5007 @@ +;(function e(t,n,r){function s(o,u){if(!n[o]){if(!t[o]){var a=typeof require=="function"&&require;if(!u&&a)return a(o,!0);if(i)return i(o,!0);throw new Error("Cannot find module '"+o+"'")}var f=n[o]={exports:{}};t[o][0].call(f.exports,function(e){var n=t[o][1][e];return s(n?n:e)},f,f.exports,e,t,n,r)}return n[o].exports}var i=typeof require=="function"&&require;for(var o=0;o Math.abs(dx) * h) { + // Intersection is top or bottom of rect. + if (dy < 0) { + h = -h; + } + sx = dy === 0 ? 0 : h * dx / dy; + sy = h; + } else { + // Intersection is left or right of rect. + if (dx < 0) { + w = -w; + } + sx = w; + sy = dx === 0 ? 0 : w * dy / dx; + } + + return {x: x + sx, y: y + sy}; +} + +function intersectEllipse(node, ellipseOrCircle, point) { + // Formulae from: http://mathworld.wolfram.com/Ellipse-LineIntersection.html + + var cx = node.x; + var cy = node.y; + var rx, ry; + + if (isCircle(ellipseOrCircle)) { + rx = ry = ellipseOrCircle.r.baseVal.value; + } else { + rx = ellipseOrCircle.rx.baseVal.value; + ry = ellipseOrCircle.ry.baseVal.value; + } + + var px = cx - point.x; + var py = cy - point.y; + + var det = Math.sqrt(rx * rx * py * py + ry * ry * px * px); + + var dx = Math.abs(rx * ry * px / det); + if (point.x < cx) { + dx = -dx; + } + var dy = Math.abs(rx * ry * py / det); + if (point.y < cy) { + dy = -dy; + } + + return {x: cx + dx, y: cy + dy}; +} + +function sameSign(r1, r2) { + return r1 * r2 > 0; +} + +// Add point to the found intersections, but check first that it is unique. +function addPoint(x, y, intersections) { + if (!intersections.some(function (elm) { return elm[0] === x && elm[1] === y; })) { + intersections.push([x, y]); + } +} + +function intersectLine(x1, y1, x2, y2, x3, y3, x4, y4, intersections) { + // Algorithm from J. Avro, (ed.) Graphics Gems, No 2, Morgan Kaufmann, 1994, p7 and p473. + + var a1, a2, b1, b2, c1, c2; + var r1, r2 , r3, r4; + var denom, offset, num; + var x, y; + + // Compute a1, b1, c1, where line joining points 1 and 2 is F(x,y) = a1 x + b1 y + c1 = 0. + a1 = y2 - y1; + b1 = x1 - x2; + c1 = (x2 * y1) - (x1 * y2); + + // Compute r3 and r4. + r3 = ((a1 * x3) + (b1 * y3) + c1); + r4 = ((a1 * x4) + (b1 * y4) + c1); + + // Check signs of r3 and r4. If both point 3 and point 4 lie on + // same side of line 1, the line segments do not intersect. + if ((r3 !== 0) && (r4 !== 0) && sameSign(r3, r4)) { + return /*DONT_INTERSECT*/; + } + + // Compute a2, b2, c2 where line joining points 3 and 4 is G(x,y) = a2 x + b2 y + c2 = 0 + a2 = y4 - y3; + b2 = x3 - x4; + c2 = (x4 * y3) - (x3 * y4); + + // Compute r1 and r2 + r1 = (a2 * x1) + (b2 * y1) + c2; + r2 = (a2 * x2) + (b2 * y2) + c2; + + // Check signs of r1 and r2. If both point 1 and point 2 lie + // on same side of second line segment, the line segments do + // not intersect. + if ((r1 !== 0) && (r2 !== 0) && (sameSign(r1, r2))) { + return /*DONT_INTERSECT*/; + } + + // Line segments intersect: compute intersection point. + denom = (a1 * b2) - (a2 * b1); + if (denom === 0) { + return /*COLLINEAR*/; + } + + offset = Math.abs(denom / 2); + + // The denom/2 is to get rounding instead of truncating. It + // is added or subtracted to the numerator, depending upon the + // sign of the numerator. + num = (b1 * c2) - (b2 * c1); + x = (num < 0) ? ((num - offset) / denom) : ((num + offset) / denom); + + num = (a2 * c1) - (a1 * c2); + y = (num < 0) ? ((num - offset) / denom) : ((num + offset) / denom); + + // lines_intersect + addPoint(x, y, intersections); +} + +function intersectPolygon(node, polygon, point) { + var x1 = node.x; + var y1 = node.y; + var x2 = point.x; + var y2 = point.y; + + var intersections = []; + var points = polygon.points; + + var minx = 100000, miny = 100000; + for (var j = 0; j < points.numberOfItems; j++) { + var p = points.getItem(j); + minx = Math.min(minx, p.x); + miny = Math.min(miny, p.y); + } + + var left = x1 - node.width / 2 - minx; + var top = y1 - node.height / 2 - miny; + + for (var i = 0; i < points.numberOfItems; i++) { + var p1 = points.getItem(i); + var p2 = points.getItem(i < points.numberOfItems - 1 ? i + 1 : 0); + intersectLine(x1, y1, x2, y2, left + p1.x, top + p1.y, left + p2.x, top + p2.y, intersections); + } + + if (intersections.length === 1) { + return {x: intersections[0][0], y: intersections[0][1]}; + } + + if (intersections.length > 1) { + // More intersections, find the one nearest to edge end point + intersections.sort(function(p, q) { + var pdx = p[0] - point.x, + pdy = p[1] - point.y, + distp = Math.sqrt(pdx * pdx + pdy * pdy), + + qdx = q[0] - point.x, + qdy = q[1] - point.y, + distq = Math.sqrt(qdx * qdx + qdy * qdy); + + return (distp < distq) ? -1 : (distp === distq ? 0 : 1); + }); + return {x: intersections[0][0], y: intersections[0][1]}; + } else { + console.log('NO INTERSECTION FOUND, RETURN NODE CENTER', node); + return node; + } +} + +function isComposite(g, u) { + return 'children' in g && g.children(u).length; +} + +function bind(func, thisArg) { + // For some reason PhantomJS occassionally fails when using the builtin bind, + // so we check if it is available and if not, use a degenerate polyfill. + if (func.bind) { + return func.bind(thisArg); + } + + return function() { + return func.apply(thisArg, arguments); + }; +} + +function applyStyle(style, domNode) { + if (style) { + var currStyle = domNode.attr('style') || ''; + domNode.attr('style', currStyle + '; ' + style); + } +} + +},{"d3":10,"dagre":11}],4:[function(require,module,exports){ +module.exports = '0.2.9'; + +},{}],5:[function(require,module,exports){ +exports.Set = require('./lib/Set'); +exports.PriorityQueue = require('./lib/PriorityQueue'); +exports.version = require('./lib/version'); + +},{"./lib/PriorityQueue":6,"./lib/Set":7,"./lib/version":9}],6:[function(require,module,exports){ +module.exports = PriorityQueue; + +/** + * A min-priority queue data structure. This algorithm is derived from Cormen, + * et al., "Introduction to Algorithms". The basic idea of a min-priority + * queue is that you can efficiently (in O(1) time) get the smallest key in + * the queue. Adding and removing elements takes O(log n) time. A key can + * have its priority decreased in O(log n) time. + */ +function PriorityQueue() { + this._arr = []; + this._keyIndices = {}; +} + +/** + * Returns the number of elements in the queue. Takes `O(1)` time. + */ +PriorityQueue.prototype.size = function() { + return this._arr.length; +}; + +/** + * Returns the keys that are in the queue. Takes `O(n)` time. + */ +PriorityQueue.prototype.keys = function() { + return this._arr.map(function(x) { return x.key; }); +}; + +/** + * Returns `true` if **key** is in the queue and `false` if not. + */ +PriorityQueue.prototype.has = function(key) { + return key in this._keyIndices; +}; + +/** + * Returns the priority for **key**. If **key** is not present in the queue + * then this function returns `undefined`. Takes `O(1)` time. + * + * @param {Object} key + */ +PriorityQueue.prototype.priority = function(key) { + var index = this._keyIndices[key]; + if (index !== undefined) { + return this._arr[index].priority; + } +}; + +/** + * Returns the key for the minimum element in this queue. If the queue is + * empty this function throws an Error. Takes `O(1)` time. + */ +PriorityQueue.prototype.min = function() { + if (this.size() === 0) { + throw new Error("Queue underflow"); + } + return this._arr[0].key; +}; + +/** + * Inserts a new key into the priority queue. If the key already exists in + * the queue this function returns `false`; otherwise it will return `true`. + * Takes `O(n)` time. + * + * @param {Object} key the key to add + * @param {Number} priority the initial priority for the key + */ +PriorityQueue.prototype.add = function(key, priority) { + var keyIndices = this._keyIndices; + if (!(key in keyIndices)) { + var arr = this._arr; + var index = arr.length; + keyIndices[key] = index; + arr.push({key: key, priority: priority}); + this._decrease(index); + return true; + } + return false; +}; + +/** + * Removes and returns the smallest key in the queue. Takes `O(log n)` time. + */ +PriorityQueue.prototype.removeMin = function() { + this._swap(0, this._arr.length - 1); + var min = this._arr.pop(); + delete this._keyIndices[min.key]; + this._heapify(0); + return min.key; +}; + +/** + * Decreases the priority for **key** to **priority**. If the new priority is + * greater than the previous priority, this function will throw an Error. + * + * @param {Object} key the key for which to raise priority + * @param {Number} priority the new priority for the key + */ +PriorityQueue.prototype.decrease = function(key, priority) { + var index = this._keyIndices[key]; + if (priority > this._arr[index].priority) { + throw new Error("New priority is greater than current priority. " + + "Key: " + key + " Old: " + this._arr[index].priority + " New: " + priority); + } + this._arr[index].priority = priority; + this._decrease(index); +}; + +PriorityQueue.prototype._heapify = function(i) { + var arr = this._arr; + var l = 2 * i, + r = l + 1, + largest = i; + if (l < arr.length) { + largest = arr[l].priority < arr[largest].priority ? l : largest; + if (r < arr.length) { + largest = arr[r].priority < arr[largest].priority ? r : largest; + } + if (largest !== i) { + this._swap(i, largest); + this._heapify(largest); + } + } +}; + +PriorityQueue.prototype._decrease = function(index) { + var arr = this._arr; + var priority = arr[index].priority; + var parent; + while (index !== 0) { + parent = index >> 1; + if (arr[parent].priority < priority) { + break; + } + this._swap(index, parent); + index = parent; + } +}; + +PriorityQueue.prototype._swap = function(i, j) { + var arr = this._arr; + var keyIndices = this._keyIndices; + var origArrI = arr[i]; + var origArrJ = arr[j]; + arr[i] = origArrJ; + arr[j] = origArrI; + keyIndices[origArrJ.key] = i; + keyIndices[origArrI.key] = j; +}; + +},{}],7:[function(require,module,exports){ +var util = require('./util'); + +module.exports = Set; + +/** + * Constructs a new Set with an optional set of `initialKeys`. + * + * It is important to note that keys are coerced to String for most purposes + * with this object, similar to the behavior of JavaScript's Object. For + * example, the following will add only one key: + * + * var s = new Set(); + * s.add(1); + * s.add("1"); + * + * However, the type of the key is preserved internally so that `keys` returns + * the original key set uncoerced. For the above example, `keys` would return + * `[1]`. + */ +function Set(initialKeys) { + this._size = 0; + this._keys = {}; + + if (initialKeys) { + for (var i = 0, il = initialKeys.length; i < il; ++i) { + this.add(initialKeys[i]); + } + } +} + +/** + * Returns a new Set that represents the set intersection of the array of given + * sets. + */ +Set.intersect = function(sets) { + if (sets.length === 0) { + return new Set(); + } + + var result = new Set(!util.isArray(sets[0]) ? sets[0].keys() : sets[0]); + for (var i = 1, il = sets.length; i < il; ++i) { + var resultKeys = result.keys(), + other = !util.isArray(sets[i]) ? sets[i] : new Set(sets[i]); + for (var j = 0, jl = resultKeys.length; j < jl; ++j) { + var key = resultKeys[j]; + if (!other.has(key)) { + result.remove(key); + } + } + } + + return result; +}; + +/** + * Returns a new Set that represents the set union of the array of given sets. + */ +Set.union = function(sets) { + var totalElems = util.reduce(sets, function(lhs, rhs) { + return lhs + (rhs.size ? rhs.size() : rhs.length); + }, 0); + var arr = new Array(totalElems); + + var k = 0; + for (var i = 0, il = sets.length; i < il; ++i) { + var cur = sets[i], + keys = !util.isArray(cur) ? cur.keys() : cur; + for (var j = 0, jl = keys.length; j < jl; ++j) { + arr[k++] = keys[j]; + } + } + + return new Set(arr); +}; + +/** + * Returns the size of this set in `O(1)` time. + */ +Set.prototype.size = function() { + return this._size; +}; + +/** + * Returns the keys in this set. Takes `O(n)` time. + */ +Set.prototype.keys = function() { + return values(this._keys); +}; + +/** + * Tests if a key is present in this Set. Returns `true` if it is and `false` + * if not. Takes `O(1)` time. + */ +Set.prototype.has = function(key) { + return key in this._keys; +}; + +/** + * Adds a new key to this Set if it is not already present. Returns `true` if + * the key was added and `false` if it was already present. Takes `O(1)` time. + */ +Set.prototype.add = function(key) { + if (!(key in this._keys)) { + this._keys[key] = key; + ++this._size; + return true; + } + return false; +}; + +/** + * Removes a key from this Set. If the key was removed this function returns + * `true`. If not, it returns `false`. Takes `O(1)` time. + */ +Set.prototype.remove = function(key) { + if (key in this._keys) { + delete this._keys[key]; + --this._size; + return true; + } + return false; +}; + +/* + * Returns an array of all values for properties of **o**. + */ +function values(o) { + var ks = Object.keys(o), + len = ks.length, + result = new Array(len), + i; + for (i = 0; i < len; ++i) { + result[i] = o[ks[i]]; + } + return result; +} + +},{"./util":8}],8:[function(require,module,exports){ +/* + * This polyfill comes from + * https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/isArray + */ +if(!Array.isArray) { + exports.isArray = function (vArg) { + return Object.prototype.toString.call(vArg) === '[object Array]'; + }; +} else { + exports.isArray = Array.isArray; +} + +/* + * Slightly adapted polyfill from + * https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/Reduce + */ +if ('function' !== typeof Array.prototype.reduce) { + exports.reduce = function(array, callback, opt_initialValue) { + 'use strict'; + if (null === array || 'undefined' === typeof array) { + // At the moment all modern browsers, that support strict mode, have + // native implementation of Array.prototype.reduce. For instance, IE8 + // does not support strict mode, so this check is actually useless. + throw new TypeError( + 'Array.prototype.reduce called on null or undefined'); + } + if ('function' !== typeof callback) { + throw new TypeError(callback + ' is not a function'); + } + var index, value, + length = array.length >>> 0, + isValueSet = false; + if (1 < arguments.length) { + value = opt_initialValue; + isValueSet = true; + } + for (index = 0; length > index; ++index) { + if (array.hasOwnProperty(index)) { + if (isValueSet) { + value = callback(value, array[index], index, array); + } + else { + value = array[index]; + isValueSet = true; + } + } + } + if (!isValueSet) { + throw new TypeError('Reduce of empty array with no initial value'); + } + return value; + }; +} else { + exports.reduce = function(array, callback, opt_initialValue) { + return array.reduce(callback, opt_initialValue); + }; +} + +},{}],9:[function(require,module,exports){ +module.exports = '1.1.3'; + +},{}],10:[function(require,module,exports){ +require("./d3"); +module.exports = d3; +(function () { delete this.d3; })(); // unset global + +},{}],11:[function(require,module,exports){ +/* +Copyright (c) 2012-2013 Chris Pettitt + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. +*/ +exports.Digraph = require("graphlib").Digraph; +exports.Graph = require("graphlib").Graph; +exports.layout = require("./lib/layout"); +exports.version = require("./lib/version"); +exports.debug = require("./lib/debug"); + +},{"./lib/debug":12,"./lib/layout":13,"./lib/version":28,"graphlib":29}],12:[function(require,module,exports){ +'use strict'; + +var util = require('./util'); + +/** + * Renders a graph in a stringified DOT format that indicates the ordering of + * nodes by layer. Circles represent normal nodes. Diamons represent dummy + * nodes. While we try to put nodes in clusters, it appears that graphviz + * does not respect this because we're later using subgraphs for ordering nodes + * in each layer. + */ +exports.dotOrdering = function(g) { + var ordering = util.ordering(g.filterNodes(util.filterNonSubgraphs(g))); + var result = 'digraph {'; + + function dfs(u) { + var children = g.children(u); + if (children.length) { + result += 'subgraph cluster_' + u + ' {'; + result += 'label="' + u + '";'; + children.forEach(function(v) { + dfs(v); + }); + result += '}'; + } else { + result += u; + if (g.node(u).dummy) { + result += ' [shape=diamond]'; + } + result += ';'; + } + } + + g.children(null).forEach(dfs); + + ordering.forEach(function(layer) { + result += 'subgraph { rank=same; edge [style="invis"];'; + result += layer.join('->'); + result += '}'; + }); + + g.eachEdge(function(e, u, v) { + result += u + '->' + v + ';'; + }); + + result += '}'; + + return result; +}; + +},{"./util":27}],13:[function(require,module,exports){ +'use strict'; + +var util = require('./util'), + rank = require('./rank'), + order = require('./order'), + CGraph = require('graphlib').CGraph, + CDigraph = require('graphlib').CDigraph; + +module.exports = function() { + // External configuration + var config = { + // How much debug information to include? + debugLevel: 0, + // Max number of sweeps to perform in order phase + orderMaxSweeps: order.DEFAULT_MAX_SWEEPS, + // Use network simplex algorithm in ranking + rankSimplex: false, + // Rank direction. Valid values are (TB, LR) + rankDir: 'TB' + }; + + // Phase functions + var position = require('./position')(); + + // This layout object + var self = {}; + + self.orderIters = util.propertyAccessor(self, config, 'orderMaxSweeps'); + + self.rankSimplex = util.propertyAccessor(self, config, 'rankSimplex'); + + self.nodeSep = delegateProperty(position.nodeSep); + self.edgeSep = delegateProperty(position.edgeSep); + self.universalSep = delegateProperty(position.universalSep); + self.rankSep = delegateProperty(position.rankSep); + self.rankDir = util.propertyAccessor(self, config, 'rankDir'); + self.debugAlignment = delegateProperty(position.debugAlignment); + + self.debugLevel = util.propertyAccessor(self, config, 'debugLevel', function(x) { + util.log.level = x; + position.debugLevel(x); + }); + + self.run = util.time('Total layout', run); + + self._normalize = normalize; + + return self; + + /* + * Constructs an adjacency graph using the nodes and edges specified through + * config. For each node and edge we add a property `dagre` that contains an + * object that will hold intermediate and final layout information. Some of + * the contents include: + * + * 1) A generated ID that uniquely identifies the object. + * 2) Dimension information for nodes (copied from the source node). + * 3) Optional dimension information for edges. + * + * After the adjacency graph is constructed the code no longer needs to use + * the original nodes and edges passed in via config. + */ + function initLayoutGraph(inputGraph) { + var g = new CDigraph(); + + inputGraph.eachNode(function(u, value) { + if (value === undefined) value = {}; + g.addNode(u, { + width: value.width, + height: value.height + }); + if (value.hasOwnProperty('rank')) { + g.node(u).prefRank = value.rank; + } + }); + + // Set up subgraphs + if (inputGraph.parent) { + inputGraph.nodes().forEach(function(u) { + g.parent(u, inputGraph.parent(u)); + }); + } + + inputGraph.eachEdge(function(e, u, v, value) { + if (value === undefined) value = {}; + var newValue = { + e: e, + minLen: value.minLen || 1, + width: value.width || 0, + height: value.height || 0, + points: [] + }; + + g.addEdge(null, u, v, newValue); + }); + + // Initial graph attributes + var graphValue = inputGraph.graph() || {}; + g.graph({ + rankDir: graphValue.rankDir || config.rankDir, + orderRestarts: graphValue.orderRestarts + }); + + return g; + } + + function run(inputGraph) { + var rankSep = self.rankSep(); + var g; + try { + // Build internal graph + g = util.time('initLayoutGraph', initLayoutGraph)(inputGraph); + + if (g.order() === 0) { + return g; + } + + // Make space for edge labels + g.eachEdge(function(e, s, t, a) { + a.minLen *= 2; + }); + self.rankSep(rankSep / 2); + + // Determine the rank for each node. Nodes with a lower rank will appear + // above nodes of higher rank. + util.time('rank.run', rank.run)(g, config.rankSimplex); + + // Normalize the graph by ensuring that every edge is proper (each edge has + // a length of 1). We achieve this by adding dummy nodes to long edges, + // thus shortening them. + util.time('normalize', normalize)(g); + + // Order the nodes so that edge crossings are minimized. + util.time('order', order)(g, config.orderMaxSweeps); + + // Find the x and y coordinates for every node in the graph. + util.time('position', position.run)(g); + + // De-normalize the graph by removing dummy nodes and augmenting the + // original long edges with coordinate information. + util.time('undoNormalize', undoNormalize)(g); + + // Reverses points for edges that are in a reversed state. + util.time('fixupEdgePoints', fixupEdgePoints)(g); + + // Restore delete edges and reverse edges that were reversed in the rank + // phase. + util.time('rank.restoreEdges', rank.restoreEdges)(g); + + // Construct final result graph and return it + return util.time('createFinalGraph', createFinalGraph)(g, inputGraph.isDirected()); + } finally { + self.rankSep(rankSep); + } + } + + /* + * This function is responsible for 'normalizing' the graph. The process of + * normalization ensures that no edge in the graph has spans more than one + * rank. To do this it inserts dummy nodes as needed and links them by adding + * dummy edges. This function keeps enough information in the dummy nodes and + * edges to ensure that the original graph can be reconstructed later. + * + * This method assumes that the input graph is cycle free. + */ + function normalize(g) { + var dummyCount = 0; + g.eachEdge(function(e, s, t, a) { + var sourceRank = g.node(s).rank; + var targetRank = g.node(t).rank; + if (sourceRank + 1 < targetRank) { + for (var u = s, rank = sourceRank + 1, i = 0; rank < targetRank; ++rank, ++i) { + var v = '_D' + (++dummyCount); + var node = { + width: a.width, + height: a.height, + edge: { id: e, source: s, target: t, attrs: a }, + rank: rank, + dummy: true + }; + + // If this node represents a bend then we will use it as a control + // point. For edges with 2 segments this will be the center dummy + // node. For edges with more than two segments, this will be the + // first and last dummy node. + if (i === 0) node.index = 0; + else if (rank + 1 === targetRank) node.index = 1; + + g.addNode(v, node); + g.addEdge(null, u, v, {}); + u = v; + } + g.addEdge(null, u, t, {}); + g.delEdge(e); + } + }); + } + + /* + * Reconstructs the graph as it was before normalization. The positions of + * dummy nodes are used to build an array of points for the original 'long' + * edge. Dummy nodes and edges are removed. + */ + function undoNormalize(g) { + g.eachNode(function(u, a) { + if (a.dummy) { + if ('index' in a) { + var edge = a.edge; + if (!g.hasEdge(edge.id)) { + g.addEdge(edge.id, edge.source, edge.target, edge.attrs); + } + var points = g.edge(edge.id).points; + points[a.index] = { x: a.x, y: a.y, ul: a.ul, ur: a.ur, dl: a.dl, dr: a.dr }; + } + g.delNode(u); + } + }); + } + + /* + * For each edge that was reversed during the `acyclic` step, reverse its + * array of points. + */ + function fixupEdgePoints(g) { + g.eachEdge(function(e, s, t, a) { if (a.reversed) a.points.reverse(); }); + } + + function createFinalGraph(g, isDirected) { + var out = isDirected ? new CDigraph() : new CGraph(); + out.graph(g.graph()); + g.eachNode(function(u, value) { out.addNode(u, value); }); + g.eachNode(function(u) { out.parent(u, g.parent(u)); }); + g.eachEdge(function(e, u, v, value) { + out.addEdge(value.e, u, v, value); + }); + + // Attach bounding box information + var maxX = 0, maxY = 0; + g.eachNode(function(u, value) { + if (!g.children(u).length) { + maxX = Math.max(maxX, value.x + value.width / 2); + maxY = Math.max(maxY, value.y + value.height / 2); + } + }); + g.eachEdge(function(e, u, v, value) { + var maxXPoints = Math.max.apply(Math, value.points.map(function(p) { return p.x; })); + var maxYPoints = Math.max.apply(Math, value.points.map(function(p) { return p.y; })); + maxX = Math.max(maxX, maxXPoints + value.width / 2); + maxY = Math.max(maxY, maxYPoints + value.height / 2); + }); + out.graph().width = maxX; + out.graph().height = maxY; + + return out; + } + + /* + * Given a function, a new function is returned that invokes the given + * function. The return value from the function is always the `self` object. + */ + function delegateProperty(f) { + return function() { + if (!arguments.length) return f(); + f.apply(null, arguments); + return self; + }; + } +}; + + +},{"./order":14,"./position":19,"./rank":20,"./util":27,"graphlib":29}],14:[function(require,module,exports){ +'use strict'; + +var util = require('./util'), + crossCount = require('./order/crossCount'), + initLayerGraphs = require('./order/initLayerGraphs'), + initOrder = require('./order/initOrder'), + sortLayer = require('./order/sortLayer'); + +module.exports = order; + +// The maximum number of sweeps to perform before finishing the order phase. +var DEFAULT_MAX_SWEEPS = 24; +order.DEFAULT_MAX_SWEEPS = DEFAULT_MAX_SWEEPS; + +/* + * Runs the order phase with the specified `graph, `maxSweeps`, and + * `debugLevel`. If `maxSweeps` is not specified we use `DEFAULT_MAX_SWEEPS`. + * If `debugLevel` is not set we assume 0. + */ +function order(g, maxSweeps) { + if (arguments.length < 2) { + maxSweeps = DEFAULT_MAX_SWEEPS; + } + + var restarts = g.graph().orderRestarts || 0; + + var layerGraphs = initLayerGraphs(g); + // TODO: remove this when we add back support for ordering clusters + layerGraphs.forEach(function(lg) { + lg = lg.filterNodes(function(u) { return !g.children(u).length; }); + }); + + var iters = 0, + currentBestCC, + allTimeBestCC = Number.MAX_VALUE, + allTimeBest = {}; + + function saveAllTimeBest() { + g.eachNode(function(u, value) { allTimeBest[u] = value.order; }); + } + + for (var j = 0; j < Number(restarts) + 1 && allTimeBestCC !== 0; ++j) { + currentBestCC = Number.MAX_VALUE; + initOrder(g, restarts > 0); + + util.log(2, 'Order phase start cross count: ' + g.graph().orderInitCC); + + var i, lastBest, cc; + for (i = 0, lastBest = 0; lastBest < 4 && i < maxSweeps && currentBestCC > 0; ++i, ++lastBest, ++iters) { + sweep(g, layerGraphs, i); + cc = crossCount(g); + if (cc < currentBestCC) { + lastBest = 0; + currentBestCC = cc; + if (cc < allTimeBestCC) { + saveAllTimeBest(); + allTimeBestCC = cc; + } + } + util.log(3, 'Order phase start ' + j + ' iter ' + i + ' cross count: ' + cc); + } + } + + Object.keys(allTimeBest).forEach(function(u) { + if (!g.children || !g.children(u).length) { + g.node(u).order = allTimeBest[u]; + } + }); + g.graph().orderCC = allTimeBestCC; + + util.log(2, 'Order iterations: ' + iters); + util.log(2, 'Order phase best cross count: ' + g.graph().orderCC); +} + +function predecessorWeights(g, nodes) { + var weights = {}; + nodes.forEach(function(u) { + weights[u] = g.inEdges(u).map(function(e) { + return g.node(g.source(e)).order; + }); + }); + return weights; +} + +function successorWeights(g, nodes) { + var weights = {}; + nodes.forEach(function(u) { + weights[u] = g.outEdges(u).map(function(e) { + return g.node(g.target(e)).order; + }); + }); + return weights; +} + +function sweep(g, layerGraphs, iter) { + if (iter % 2 === 0) { + sweepDown(g, layerGraphs, iter); + } else { + sweepUp(g, layerGraphs, iter); + } +} + +function sweepDown(g, layerGraphs) { + var cg; + for (var i = 1; i < layerGraphs.length; ++i) { + cg = sortLayer(layerGraphs[i], cg, predecessorWeights(g, layerGraphs[i].nodes())); + } +} + +function sweepUp(g, layerGraphs) { + var cg; + for (var i = layerGraphs.length - 2; i >= 0; --i) { + sortLayer(layerGraphs[i], cg, successorWeights(g, layerGraphs[i].nodes())); + } +} + +},{"./order/crossCount":15,"./order/initLayerGraphs":16,"./order/initOrder":17,"./order/sortLayer":18,"./util":27}],15:[function(require,module,exports){ +'use strict'; + +var util = require('../util'); + +module.exports = crossCount; + +/* + * Returns the cross count for the given graph. + */ +function crossCount(g) { + var cc = 0; + var ordering = util.ordering(g); + for (var i = 1; i < ordering.length; ++i) { + cc += twoLayerCrossCount(g, ordering[i-1], ordering[i]); + } + return cc; +} + +/* + * This function searches through a ranked and ordered graph and counts the + * number of edges that cross. This algorithm is derived from: + * + * W. Barth et al., Bilayer Cross Counting, JGAA, 8(2) 179–194 (2004) + */ +function twoLayerCrossCount(g, layer1, layer2) { + var indices = []; + layer1.forEach(function(u) { + var nodeIndices = []; + g.outEdges(u).forEach(function(e) { nodeIndices.push(g.node(g.target(e)).order); }); + nodeIndices.sort(function(x, y) { return x - y; }); + indices = indices.concat(nodeIndices); + }); + + var firstIndex = 1; + while (firstIndex < layer2.length) firstIndex <<= 1; + + var treeSize = 2 * firstIndex - 1; + firstIndex -= 1; + + var tree = []; + for (var i = 0; i < treeSize; ++i) { tree[i] = 0; } + + var cc = 0; + indices.forEach(function(i) { + var treeIndex = i + firstIndex; + ++tree[treeIndex]; + while (treeIndex > 0) { + if (treeIndex % 2) { + cc += tree[treeIndex + 1]; + } + treeIndex = (treeIndex - 1) >> 1; + ++tree[treeIndex]; + } + }); + + return cc; +} + +},{"../util":27}],16:[function(require,module,exports){ +'use strict'; + +var nodesFromList = require('graphlib').filter.nodesFromList, + /* jshint -W079 */ + Set = require('cp-data').Set; + +module.exports = initLayerGraphs; + +/* + * This function takes a compound layered graph, g, and produces an array of + * layer graphs. Each entry in the array represents a subgraph of nodes + * relevant for performing crossing reduction on that layer. + */ +function initLayerGraphs(g) { + var ranks = []; + + function dfs(u) { + if (u === null) { + g.children(u).forEach(function(v) { dfs(v); }); + return; + } + + var value = g.node(u); + value.minRank = ('rank' in value) ? value.rank : Number.MAX_VALUE; + value.maxRank = ('rank' in value) ? value.rank : Number.MIN_VALUE; + var uRanks = new Set(); + g.children(u).forEach(function(v) { + var rs = dfs(v); + uRanks = Set.union([uRanks, rs]); + value.minRank = Math.min(value.minRank, g.node(v).minRank); + value.maxRank = Math.max(value.maxRank, g.node(v).maxRank); + }); + + if ('rank' in value) uRanks.add(value.rank); + + uRanks.keys().forEach(function(r) { + if (!(r in ranks)) ranks[r] = []; + ranks[r].push(u); + }); + + return uRanks; + } + dfs(null); + + var layerGraphs = []; + ranks.forEach(function(us, rank) { + layerGraphs[rank] = g.filterNodes(nodesFromList(us)); + }); + + return layerGraphs; +} + +},{"cp-data":5,"graphlib":29}],17:[function(require,module,exports){ +'use strict'; + +var crossCount = require('./crossCount'), + util = require('../util'); + +module.exports = initOrder; + +/* + * Given a graph with a set of layered nodes (i.e. nodes that have a `rank` + * attribute) this function attaches an `order` attribute that uniquely + * arranges each node of each rank. If no constraint graph is provided the + * order of the nodes in each rank is entirely arbitrary. + */ +function initOrder(g, random) { + var layers = []; + + g.eachNode(function(u, value) { + var layer = layers[value.rank]; + if (g.children && g.children(u).length > 0) return; + if (!layer) { + layer = layers[value.rank] = []; + } + layer.push(u); + }); + + layers.forEach(function(layer) { + if (random) { + util.shuffle(layer); + } + layer.forEach(function(u, i) { + g.node(u).order = i; + }); + }); + + var cc = crossCount(g); + g.graph().orderInitCC = cc; + g.graph().orderCC = Number.MAX_VALUE; +} + +},{"../util":27,"./crossCount":15}],18:[function(require,module,exports){ +'use strict'; + +var util = require('../util'), + Digraph = require('graphlib').Digraph, + topsort = require('graphlib').alg.topsort, + nodesFromList = require('graphlib').filter.nodesFromList; + +module.exports = sortLayer; + +function sortLayer(g, cg, weights) { + weights = adjustWeights(g, weights); + var result = sortLayerSubgraph(g, null, cg, weights); + + result.list.forEach(function(u, i) { + g.node(u).order = i; + }); + return result.constraintGraph; +} + +function sortLayerSubgraph(g, sg, cg, weights) { + cg = cg ? cg.filterNodes(nodesFromList(g.children(sg))) : new Digraph(); + + var nodeData = {}; + g.children(sg).forEach(function(u) { + if (g.children(u).length) { + nodeData[u] = sortLayerSubgraph(g, u, cg, weights); + nodeData[u].firstSG = u; + nodeData[u].lastSG = u; + } else { + var ws = weights[u]; + nodeData[u] = { + degree: ws.length, + barycenter: util.sum(ws) / ws.length, + order: g.node(u).order, + orderCount: 1, + list: [u] + }; + } + }); + + resolveViolatedConstraints(g, cg, nodeData); + + var keys = Object.keys(nodeData); + keys.sort(function(x, y) { + return nodeData[x].barycenter - nodeData[y].barycenter || + nodeData[x].order - nodeData[y].order; + }); + + var result = keys.map(function(u) { return nodeData[u]; }) + .reduce(function(lhs, rhs) { return mergeNodeData(g, lhs, rhs); }); + return result; +} + +function mergeNodeData(g, lhs, rhs) { + var cg = mergeDigraphs(lhs.constraintGraph, rhs.constraintGraph); + + if (lhs.lastSG !== undefined && rhs.firstSG !== undefined) { + if (cg === undefined) { + cg = new Digraph(); + } + if (!cg.hasNode(lhs.lastSG)) { cg.addNode(lhs.lastSG); } + cg.addNode(rhs.firstSG); + cg.addEdge(null, lhs.lastSG, rhs.firstSG); + } + + return { + degree: lhs.degree + rhs.degree, + barycenter: (lhs.barycenter * lhs.degree + rhs.barycenter * rhs.degree) / + (lhs.degree + rhs.degree), + order: (lhs.order * lhs.orderCount + rhs.order * rhs.orderCount) / + (lhs.orderCount + rhs.orderCount), + orderCount: lhs.orderCount + rhs.orderCount, + list: lhs.list.concat(rhs.list), + firstSG: lhs.firstSG !== undefined ? lhs.firstSG : rhs.firstSG, + lastSG: rhs.lastSG !== undefined ? rhs.lastSG : lhs.lastSG, + constraintGraph: cg + }; +} + +function mergeDigraphs(lhs, rhs) { + if (lhs === undefined) return rhs; + if (rhs === undefined) return lhs; + + lhs = lhs.copy(); + rhs.nodes().forEach(function(u) { lhs.addNode(u); }); + rhs.edges().forEach(function(e, u, v) { lhs.addEdge(null, u, v); }); + return lhs; +} + +function resolveViolatedConstraints(g, cg, nodeData) { + // Removes nodes `u` and `v` from `cg` and makes any edges incident on them + // incident on `w` instead. + function collapseNodes(u, v, w) { + // TODO original paper removes self loops, but it is not obvious when this would happen + cg.inEdges(u).forEach(function(e) { + cg.delEdge(e); + cg.addEdge(null, cg.source(e), w); + }); + + cg.outEdges(v).forEach(function(e) { + cg.delEdge(e); + cg.addEdge(null, w, cg.target(e)); + }); + + cg.delNode(u); + cg.delNode(v); + } + + var violated; + while ((violated = findViolatedConstraint(cg, nodeData)) !== undefined) { + var source = cg.source(violated), + target = cg.target(violated); + + var v; + while ((v = cg.addNode(null)) && g.hasNode(v)) { + cg.delNode(v); + } + + // Collapse barycenter and list + nodeData[v] = mergeNodeData(g, nodeData[source], nodeData[target]); + delete nodeData[source]; + delete nodeData[target]; + + collapseNodes(source, target, v); + if (cg.incidentEdges(v).length === 0) { cg.delNode(v); } + } +} + +function findViolatedConstraint(cg, nodeData) { + var us = topsort(cg); + for (var i = 0; i < us.length; ++i) { + var u = us[i]; + var inEdges = cg.inEdges(u); + for (var j = 0; j < inEdges.length; ++j) { + var e = inEdges[j]; + if (nodeData[cg.source(e)].barycenter >= nodeData[u].barycenter) { + return e; + } + } + } +} + +// Adjust weights so that they fall in the range of 0..|N|-1. If a node has no +// weight assigned then set its adjusted weight to its current position. This +// allows us to better retain the origiinal position of nodes without neighbors. +function adjustWeights(g, weights) { + var minW = Number.MAX_VALUE, + maxW = 0, + adjusted = {}; + g.eachNode(function(u) { + if (g.children(u).length) return; + + var ws = weights[u]; + if (ws.length) { + minW = Math.min(minW, util.min(ws)); + maxW = Math.max(maxW, util.max(ws)); + } + }); + + var rangeW = (maxW - minW); + g.eachNode(function(u) { + if (g.children(u).length) return; + + var ws = weights[u]; + if (!ws.length) { + adjusted[u] = [g.node(u).order]; + } else { + adjusted[u] = ws.map(function(w) { + if (rangeW) { + return (w - minW) * (g.order() - 1) / rangeW; + } else { + return g.order() - 1 / 2; + } + }); + } + }); + + return adjusted; +} + +},{"../util":27,"graphlib":29}],19:[function(require,module,exports){ +'use strict'; + +var util = require('./util'); + +/* + * The algorithms here are based on Brandes and Köpf, "Fast and Simple + * Horizontal Coordinate Assignment". + */ +module.exports = function() { + // External configuration + var config = { + nodeSep: 50, + edgeSep: 10, + universalSep: null, + rankSep: 30 + }; + + var self = {}; + + self.nodeSep = util.propertyAccessor(self, config, 'nodeSep'); + self.edgeSep = util.propertyAccessor(self, config, 'edgeSep'); + // If not null this separation value is used for all nodes and edges + // regardless of their widths. `nodeSep` and `edgeSep` are ignored with this + // option. + self.universalSep = util.propertyAccessor(self, config, 'universalSep'); + self.rankSep = util.propertyAccessor(self, config, 'rankSep'); + self.debugLevel = util.propertyAccessor(self, config, 'debugLevel'); + + self.run = run; + + return self; + + function run(g) { + g = g.filterNodes(util.filterNonSubgraphs(g)); + + var layering = util.ordering(g); + + var conflicts = findConflicts(g, layering); + + var xss = {}; + ['u', 'd'].forEach(function(vertDir) { + if (vertDir === 'd') layering.reverse(); + + ['l', 'r'].forEach(function(horizDir) { + if (horizDir === 'r') reverseInnerOrder(layering); + + var dir = vertDir + horizDir; + var align = verticalAlignment(g, layering, conflicts, vertDir === 'u' ? 'predecessors' : 'successors'); + xss[dir]= horizontalCompaction(g, layering, align.pos, align.root, align.align); + + if (config.debugLevel >= 3) + debugPositioning(vertDir + horizDir, g, layering, xss[dir]); + + if (horizDir === 'r') flipHorizontally(xss[dir]); + + if (horizDir === 'r') reverseInnerOrder(layering); + }); + + if (vertDir === 'd') layering.reverse(); + }); + + balance(g, layering, xss); + + g.eachNode(function(v) { + var xs = []; + for (var alignment in xss) { + var alignmentX = xss[alignment][v]; + posXDebug(alignment, g, v, alignmentX); + xs.push(alignmentX); + } + xs.sort(function(x, y) { return x - y; }); + posX(g, v, (xs[1] + xs[2]) / 2); + }); + + // Align y coordinates with ranks + var y = 0, reverseY = g.graph().rankDir === 'BT' || g.graph().rankDir === 'RL'; + layering.forEach(function(layer) { + var maxHeight = util.max(layer.map(function(u) { return height(g, u); })); + y += maxHeight / 2; + layer.forEach(function(u) { + posY(g, u, reverseY ? -y : y); + }); + y += maxHeight / 2 + config.rankSep; + }); + + // Translate layout so that top left corner of bounding rectangle has + // coordinate (0, 0). + var minX = util.min(g.nodes().map(function(u) { return posX(g, u) - width(g, u) / 2; })); + var minY = util.min(g.nodes().map(function(u) { return posY(g, u) - height(g, u) / 2; })); + g.eachNode(function(u) { + posX(g, u, posX(g, u) - minX); + posY(g, u, posY(g, u) - minY); + }); + } + + /* + * Generate an ID that can be used to represent any undirected edge that is + * incident on `u` and `v`. + */ + function undirEdgeId(u, v) { + return u < v + ? u.toString().length + ':' + u + '-' + v + : v.toString().length + ':' + v + '-' + u; + } + + function findConflicts(g, layering) { + var conflicts = {}, // Set of conflicting edge ids + pos = {}, // Position of node in its layer + prevLayer, + currLayer, + k0, // Position of the last inner segment in the previous layer + l, // Current position in the current layer (for iteration up to `l1`) + k1; // Position of the next inner segment in the previous layer or + // the position of the last element in the previous layer + + if (layering.length <= 2) return conflicts; + + function updateConflicts(v) { + var k = pos[v]; + if (k < k0 || k > k1) { + conflicts[undirEdgeId(currLayer[l], v)] = true; + } + } + + layering[1].forEach(function(u, i) { pos[u] = i; }); + for (var i = 1; i < layering.length - 1; ++i) { + prevLayer = layering[i]; + currLayer = layering[i+1]; + k0 = 0; + l = 0; + + // Scan current layer for next node that is incident to an inner segement + // between layering[i+1] and layering[i]. + for (var l1 = 0; l1 < currLayer.length; ++l1) { + var u = currLayer[l1]; // Next inner segment in the current layer or + // last node in the current layer + pos[u] = l1; + k1 = undefined; + + if (g.node(u).dummy) { + var uPred = g.predecessors(u)[0]; + // Note: In the case of self loops and sideways edges it is possible + // for a dummy not to have a predecessor. + if (uPred !== undefined && g.node(uPred).dummy) + k1 = pos[uPred]; + } + if (k1 === undefined && l1 === currLayer.length - 1) + k1 = prevLayer.length - 1; + + if (k1 !== undefined) { + for (; l <= l1; ++l) { + g.predecessors(currLayer[l]).forEach(updateConflicts); + } + k0 = k1; + } + } + } + + return conflicts; + } + + function verticalAlignment(g, layering, conflicts, relationship) { + var pos = {}, // Position for a node in its layer + root = {}, // Root of the block that the node participates in + align = {}; // Points to the next node in the block or, if the last + // element in the block, points to the first block's root + + layering.forEach(function(layer) { + layer.forEach(function(u, i) { + root[u] = u; + align[u] = u; + pos[u] = i; + }); + }); + + layering.forEach(function(layer) { + var prevIdx = -1; + layer.forEach(function(v) { + var related = g[relationship](v), // Adjacent nodes from the previous layer + mid; // The mid point in the related array + + if (related.length > 0) { + related.sort(function(x, y) { return pos[x] - pos[y]; }); + mid = (related.length - 1) / 2; + related.slice(Math.floor(mid), Math.ceil(mid) + 1).forEach(function(u) { + if (align[v] === v) { + if (!conflicts[undirEdgeId(u, v)] && prevIdx < pos[u]) { + align[u] = v; + align[v] = root[v] = root[u]; + prevIdx = pos[u]; + } + } + }); + } + }); + }); + + return { pos: pos, root: root, align: align }; + } + + // This function deviates from the standard BK algorithm in two ways. First + // it takes into account the size of the nodes. Second it includes a fix to + // the original algorithm that is described in Carstens, "Node and Label + // Placement in a Layered Layout Algorithm". + function horizontalCompaction(g, layering, pos, root, align) { + var sink = {}, // Mapping of node id -> sink node id for class + maybeShift = {}, // Mapping of sink node id -> { class node id, min shift } + shift = {}, // Mapping of sink node id -> shift + pred = {}, // Mapping of node id -> predecessor node (or null) + xs = {}; // Calculated X positions + + layering.forEach(function(layer) { + layer.forEach(function(u, i) { + sink[u] = u; + maybeShift[u] = {}; + if (i > 0) + pred[u] = layer[i - 1]; + }); + }); + + function updateShift(toShift, neighbor, delta) { + if (!(neighbor in maybeShift[toShift])) { + maybeShift[toShift][neighbor] = delta; + } else { + maybeShift[toShift][neighbor] = Math.min(maybeShift[toShift][neighbor], delta); + } + } + + function placeBlock(v) { + if (!(v in xs)) { + xs[v] = 0; + var w = v; + do { + if (pos[w] > 0) { + var u = root[pred[w]]; + placeBlock(u); + if (sink[v] === v) { + sink[v] = sink[u]; + } + var delta = sep(g, pred[w]) + sep(g, w); + if (sink[v] !== sink[u]) { + updateShift(sink[u], sink[v], xs[v] - xs[u] - delta); + } else { + xs[v] = Math.max(xs[v], xs[u] + delta); + } + } + w = align[w]; + } while (w !== v); + } + } + + // Root coordinates relative to sink + util.values(root).forEach(function(v) { + placeBlock(v); + }); + + // Absolute coordinates + // There is an assumption here that we've resolved shifts for any classes + // that begin at an earlier layer. We guarantee this by visiting layers in + // order. + layering.forEach(function(layer) { + layer.forEach(function(v) { + xs[v] = xs[root[v]]; + if (v === root[v] && v === sink[v]) { + var minShift = 0; + if (v in maybeShift && Object.keys(maybeShift[v]).length > 0) { + minShift = util.min(Object.keys(maybeShift[v]) + .map(function(u) { + return maybeShift[v][u] + (u in shift ? shift[u] : 0); + } + )); + } + shift[v] = minShift; + } + }); + }); + + layering.forEach(function(layer) { + layer.forEach(function(v) { + xs[v] += shift[sink[root[v]]] || 0; + }); + }); + + return xs; + } + + function findMinCoord(g, layering, xs) { + return util.min(layering.map(function(layer) { + var u = layer[0]; + return xs[u]; + })); + } + + function findMaxCoord(g, layering, xs) { + return util.max(layering.map(function(layer) { + var u = layer[layer.length - 1]; + return xs[u]; + })); + } + + function balance(g, layering, xss) { + var min = {}, // Min coordinate for the alignment + max = {}, // Max coordinate for the alginment + smallestAlignment, + shift = {}; // Amount to shift a given alignment + + function updateAlignment(v) { + xss[alignment][v] += shift[alignment]; + } + + var smallest = Number.POSITIVE_INFINITY; + for (var alignment in xss) { + var xs = xss[alignment]; + min[alignment] = findMinCoord(g, layering, xs); + max[alignment] = findMaxCoord(g, layering, xs); + var w = max[alignment] - min[alignment]; + if (w < smallest) { + smallest = w; + smallestAlignment = alignment; + } + } + + // Determine how much to adjust positioning for each alignment + ['u', 'd'].forEach(function(vertDir) { + ['l', 'r'].forEach(function(horizDir) { + var alignment = vertDir + horizDir; + shift[alignment] = horizDir === 'l' + ? min[smallestAlignment] - min[alignment] + : max[smallestAlignment] - max[alignment]; + }); + }); + + // Find average of medians for xss array + for (alignment in xss) { + g.eachNode(updateAlignment); + } + } + + function flipHorizontally(xs) { + for (var u in xs) { + xs[u] = -xs[u]; + } + } + + function reverseInnerOrder(layering) { + layering.forEach(function(layer) { + layer.reverse(); + }); + } + + function width(g, u) { + switch (g.graph().rankDir) { + case 'LR': return g.node(u).height; + case 'RL': return g.node(u).height; + default: return g.node(u).width; + } + } + + function height(g, u) { + switch(g.graph().rankDir) { + case 'LR': return g.node(u).width; + case 'RL': return g.node(u).width; + default: return g.node(u).height; + } + } + + function sep(g, u) { + if (config.universalSep !== null) { + return config.universalSep; + } + var w = width(g, u); + var s = g.node(u).dummy ? config.edgeSep : config.nodeSep; + return (w + s) / 2; + } + + function posX(g, u, x) { + if (g.graph().rankDir === 'LR' || g.graph().rankDir === 'RL') { + if (arguments.length < 3) { + return g.node(u).y; + } else { + g.node(u).y = x; + } + } else { + if (arguments.length < 3) { + return g.node(u).x; + } else { + g.node(u).x = x; + } + } + } + + function posXDebug(name, g, u, x) { + if (g.graph().rankDir === 'LR' || g.graph().rankDir === 'RL') { + if (arguments.length < 3) { + return g.node(u)[name]; + } else { + g.node(u)[name] = x; + } + } else { + if (arguments.length < 3) { + return g.node(u)[name]; + } else { + g.node(u)[name] = x; + } + } + } + + function posY(g, u, y) { + if (g.graph().rankDir === 'LR' || g.graph().rankDir === 'RL') { + if (arguments.length < 3) { + return g.node(u).x; + } else { + g.node(u).x = y; + } + } else { + if (arguments.length < 3) { + return g.node(u).y; + } else { + g.node(u).y = y; + } + } + } + + function debugPositioning(align, g, layering, xs) { + layering.forEach(function(l, li) { + var u, xU; + l.forEach(function(v) { + var xV = xs[v]; + if (u) { + var s = sep(g, u) + sep(g, v); + if (xV - xU < s) + console.log('Position phase: sep violation. Align: ' + align + '. Layer: ' + li + '. ' + + 'U: ' + u + ' V: ' + v + '. Actual sep: ' + (xV - xU) + ' Expected sep: ' + s); + } + u = v; + xU = xV; + }); + }); + } +}; + +},{"./util":27}],20:[function(require,module,exports){ +'use strict'; + +var util = require('./util'), + acyclic = require('./rank/acyclic'), + initRank = require('./rank/initRank'), + feasibleTree = require('./rank/feasibleTree'), + constraints = require('./rank/constraints'), + simplex = require('./rank/simplex'), + components = require('graphlib').alg.components, + filter = require('graphlib').filter; + +exports.run = run; +exports.restoreEdges = restoreEdges; + +/* + * Heuristic function that assigns a rank to each node of the input graph with + * the intent of minimizing edge lengths, while respecting the `minLen` + * attribute of incident edges. + * + * Prerequisites: + * + * * Each edge in the input graph must have an assigned 'minLen' attribute + */ +function run(g, useSimplex) { + expandSelfLoops(g); + + // If there are rank constraints on nodes, then build a new graph that + // encodes the constraints. + util.time('constraints.apply', constraints.apply)(g); + + expandSidewaysEdges(g); + + // Reverse edges to get an acyclic graph, we keep the graph in an acyclic + // state until the very end. + util.time('acyclic', acyclic)(g); + + // Convert the graph into a flat graph for ranking + var flatGraph = g.filterNodes(util.filterNonSubgraphs(g)); + + // Assign an initial ranking using DFS. + initRank(flatGraph); + + // For each component improve the assigned ranks. + components(flatGraph).forEach(function(cmpt) { + var subgraph = flatGraph.filterNodes(filter.nodesFromList(cmpt)); + rankComponent(subgraph, useSimplex); + }); + + // Relax original constraints + util.time('constraints.relax', constraints.relax(g)); + + // When handling nodes with constrained ranks it is possible to end up with + // edges that point to previous ranks. Most of the subsequent algorithms assume + // that edges are pointing to successive ranks only. Here we reverse any "back + // edges" and mark them as such. The acyclic algorithm will reverse them as a + // post processing step. + util.time('reorientEdges', reorientEdges)(g); +} + +function restoreEdges(g) { + acyclic.undo(g); +} + +/* + * Expand self loops into three dummy nodes. One will sit above the incident + * node, one will be at the same level, and one below. The result looks like: + * + * /--<--x--->--\ + * node y + * \--<--z--->--/ + * + * Dummy nodes x, y, z give us the shape of a loop and node y is where we place + * the label. + * + * TODO: consolidate knowledge of dummy node construction. + * TODO: support minLen = 2 + */ +function expandSelfLoops(g) { + g.eachEdge(function(e, u, v, a) { + if (u === v) { + var x = addDummyNode(g, e, u, v, a, 0, false), + y = addDummyNode(g, e, u, v, a, 1, true), + z = addDummyNode(g, e, u, v, a, 2, false); + g.addEdge(null, x, u, {minLen: 1, selfLoop: true}); + g.addEdge(null, x, y, {minLen: 1, selfLoop: true}); + g.addEdge(null, u, z, {minLen: 1, selfLoop: true}); + g.addEdge(null, y, z, {minLen: 1, selfLoop: true}); + g.delEdge(e); + } + }); +} + +function expandSidewaysEdges(g) { + g.eachEdge(function(e, u, v, a) { + if (u === v) { + var origEdge = a.originalEdge, + dummy = addDummyNode(g, origEdge.e, origEdge.u, origEdge.v, origEdge.value, 0, true); + g.addEdge(null, u, dummy, {minLen: 1}); + g.addEdge(null, dummy, v, {minLen: 1}); + g.delEdge(e); + } + }); +} + +function addDummyNode(g, e, u, v, a, index, isLabel) { + return g.addNode(null, { + width: isLabel ? a.width : 0, + height: isLabel ? a.height : 0, + edge: { id: e, source: u, target: v, attrs: a }, + dummy: true, + index: index + }); +} + +function reorientEdges(g) { + g.eachEdge(function(e, u, v, value) { + if (g.node(u).rank > g.node(v).rank) { + g.delEdge(e); + value.reversed = true; + g.addEdge(e, v, u, value); + } + }); +} + +function rankComponent(subgraph, useSimplex) { + var spanningTree = feasibleTree(subgraph); + + if (useSimplex) { + util.log(1, 'Using network simplex for ranking'); + simplex(subgraph, spanningTree); + } + normalize(subgraph); +} + +function normalize(g) { + var m = util.min(g.nodes().map(function(u) { return g.node(u).rank; })); + g.eachNode(function(u, node) { node.rank -= m; }); +} + +},{"./rank/acyclic":21,"./rank/constraints":22,"./rank/feasibleTree":23,"./rank/initRank":24,"./rank/simplex":26,"./util":27,"graphlib":29}],21:[function(require,module,exports){ +'use strict'; + +var util = require('../util'); + +module.exports = acyclic; +module.exports.undo = undo; + +/* + * This function takes a directed graph that may have cycles and reverses edges + * as appropriate to break these cycles. Each reversed edge is assigned a + * `reversed` attribute with the value `true`. + * + * There should be no self loops in the graph. + */ +function acyclic(g) { + var onStack = {}, + visited = {}, + reverseCount = 0; + + function dfs(u) { + if (u in visited) return; + visited[u] = onStack[u] = true; + g.outEdges(u).forEach(function(e) { + var t = g.target(e), + value; + + if (u === t) { + console.error('Warning: found self loop "' + e + '" for node "' + u + '"'); + } else if (t in onStack) { + value = g.edge(e); + g.delEdge(e); + value.reversed = true; + ++reverseCount; + g.addEdge(e, t, u, value); + } else { + dfs(t); + } + }); + + delete onStack[u]; + } + + g.eachNode(function(u) { dfs(u); }); + + util.log(2, 'Acyclic Phase: reversed ' + reverseCount + ' edge(s)'); + + return reverseCount; +} + +/* + * Given a graph that has had the acyclic operation applied, this function + * undoes that operation. More specifically, any edge with the `reversed` + * attribute is again reversed to restore the original direction of the edge. + */ +function undo(g) { + g.eachEdge(function(e, s, t, a) { + if (a.reversed) { + delete a.reversed; + g.delEdge(e); + g.addEdge(e, t, s, a); + } + }); +} + +},{"../util":27}],22:[function(require,module,exports){ +'use strict'; + +exports.apply = function(g) { + function dfs(sg) { + var rankSets = {}; + g.children(sg).forEach(function(u) { + if (g.children(u).length) { + dfs(u); + return; + } + + var value = g.node(u), + prefRank = value.prefRank; + if (prefRank !== undefined) { + if (!checkSupportedPrefRank(prefRank)) { return; } + + if (!(prefRank in rankSets)) { + rankSets.prefRank = [u]; + } else { + rankSets.prefRank.push(u); + } + + var newU = rankSets[prefRank]; + if (newU === undefined) { + newU = rankSets[prefRank] = g.addNode(null, { originalNodes: [] }); + g.parent(newU, sg); + } + + redirectInEdges(g, u, newU, prefRank === 'min'); + redirectOutEdges(g, u, newU, prefRank === 'max'); + + // Save original node and remove it from reduced graph + g.node(newU).originalNodes.push({ u: u, value: value, parent: sg }); + g.delNode(u); + } + }); + + addLightEdgesFromMinNode(g, sg, rankSets.min); + addLightEdgesToMaxNode(g, sg, rankSets.max); + } + + dfs(null); +}; + +function checkSupportedPrefRank(prefRank) { + if (prefRank !== 'min' && prefRank !== 'max' && prefRank.indexOf('same_') !== 0) { + console.error('Unsupported rank type: ' + prefRank); + return false; + } + return true; +} + +function redirectInEdges(g, u, newU, reverse) { + g.inEdges(u).forEach(function(e) { + var origValue = g.edge(e), + value; + if (origValue.originalEdge) { + value = origValue; + } else { + value = { + originalEdge: { e: e, u: g.source(e), v: g.target(e), value: origValue }, + minLen: g.edge(e).minLen + }; + } + + // Do not reverse edges for self-loops. + if (origValue.selfLoop) { + reverse = false; + } + + if (reverse) { + // Ensure that all edges to min are reversed + g.addEdge(null, newU, g.source(e), value); + value.reversed = true; + } else { + g.addEdge(null, g.source(e), newU, value); + } + }); +} + +function redirectOutEdges(g, u, newU, reverse) { + g.outEdges(u).forEach(function(e) { + var origValue = g.edge(e), + value; + if (origValue.originalEdge) { + value = origValue; + } else { + value = { + originalEdge: { e: e, u: g.source(e), v: g.target(e), value: origValue }, + minLen: g.edge(e).minLen + }; + } + + // Do not reverse edges for self-loops. + if (origValue.selfLoop) { + reverse = false; + } + + if (reverse) { + // Ensure that all edges from max are reversed + g.addEdge(null, g.target(e), newU, value); + value.reversed = true; + } else { + g.addEdge(null, newU, g.target(e), value); + } + }); +} + +function addLightEdgesFromMinNode(g, sg, minNode) { + if (minNode !== undefined) { + g.children(sg).forEach(function(u) { + // The dummy check ensures we don't add an edge if the node is involved + // in a self loop or sideways edge. + if (u !== minNode && !g.outEdges(minNode, u).length && !g.node(u).dummy) { + g.addEdge(null, minNode, u, { minLen: 0 }); + } + }); + } +} + +function addLightEdgesToMaxNode(g, sg, maxNode) { + if (maxNode !== undefined) { + g.children(sg).forEach(function(u) { + // The dummy check ensures we don't add an edge if the node is involved + // in a self loop or sideways edge. + if (u !== maxNode && !g.outEdges(u, maxNode).length && !g.node(u).dummy) { + g.addEdge(null, u, maxNode, { minLen: 0 }); + } + }); + } +} + +/* + * This function "relaxes" the constraints applied previously by the "apply" + * function. It expands any nodes that were collapsed and assigns the rank of + * the collapsed node to each of the expanded nodes. It also restores the + * original edges and removes any dummy edges pointing at the collapsed nodes. + * + * Note that the process of removing collapsed nodes also removes dummy edges + * automatically. + */ +exports.relax = function(g) { + // Save original edges + var originalEdges = []; + g.eachEdge(function(e, u, v, value) { + var originalEdge = value.originalEdge; + if (originalEdge) { + originalEdges.push(originalEdge); + } + }); + + // Expand collapsed nodes + g.eachNode(function(u, value) { + var originalNodes = value.originalNodes; + if (originalNodes) { + originalNodes.forEach(function(originalNode) { + originalNode.value.rank = value.rank; + g.addNode(originalNode.u, originalNode.value); + g.parent(originalNode.u, originalNode.parent); + }); + g.delNode(u); + } + }); + + // Restore original edges + originalEdges.forEach(function(edge) { + g.addEdge(edge.e, edge.u, edge.v, edge.value); + }); +}; + +},{}],23:[function(require,module,exports){ +'use strict'; + +/* jshint -W079 */ +var Set = require('cp-data').Set, +/* jshint +W079 */ + Digraph = require('graphlib').Digraph, + util = require('../util'); + +module.exports = feasibleTree; + +/* + * Given an acyclic graph with each node assigned a `rank` attribute, this + * function constructs and returns a spanning tree. This function may reduce + * the length of some edges from the initial rank assignment while maintaining + * the `minLen` specified by each edge. + * + * Prerequisites: + * + * * The input graph is acyclic + * * Each node in the input graph has an assigned `rank` attribute + * * Each edge in the input graph has an assigned `minLen` attribute + * + * Outputs: + * + * A feasible spanning tree for the input graph (i.e. a spanning tree that + * respects each graph edge's `minLen` attribute) represented as a Digraph with + * a `root` attribute on graph. + * + * Nodes have the same id and value as that in the input graph. + * + * Edges in the tree have arbitrarily assigned ids. The attributes for edges + * include `reversed`. `reversed` indicates that the edge is a + * back edge in the input graph. + */ +function feasibleTree(g) { + var remaining = new Set(g.nodes()), + tree = new Digraph(); + + if (remaining.size() === 1) { + var root = g.nodes()[0]; + tree.addNode(root, {}); + tree.graph({ root: root }); + return tree; + } + + function addTightEdges(v) { + var continueToScan = true; + g.predecessors(v).forEach(function(u) { + if (remaining.has(u) && !slack(g, u, v)) { + if (remaining.has(v)) { + tree.addNode(v, {}); + remaining.remove(v); + tree.graph({ root: v }); + } + + tree.addNode(u, {}); + tree.addEdge(null, u, v, { reversed: true }); + remaining.remove(u); + addTightEdges(u); + continueToScan = false; + } + }); + + g.successors(v).forEach(function(w) { + if (remaining.has(w) && !slack(g, v, w)) { + if (remaining.has(v)) { + tree.addNode(v, {}); + remaining.remove(v); + tree.graph({ root: v }); + } + + tree.addNode(w, {}); + tree.addEdge(null, v, w, {}); + remaining.remove(w); + addTightEdges(w); + continueToScan = false; + } + }); + return continueToScan; + } + + function createTightEdge() { + var minSlack = Number.MAX_VALUE; + remaining.keys().forEach(function(v) { + g.predecessors(v).forEach(function(u) { + if (!remaining.has(u)) { + var edgeSlack = slack(g, u, v); + if (Math.abs(edgeSlack) < Math.abs(minSlack)) { + minSlack = -edgeSlack; + } + } + }); + + g.successors(v).forEach(function(w) { + if (!remaining.has(w)) { + var edgeSlack = slack(g, v, w); + if (Math.abs(edgeSlack) < Math.abs(minSlack)) { + minSlack = edgeSlack; + } + } + }); + }); + + tree.eachNode(function(u) { g.node(u).rank -= minSlack; }); + } + + while (remaining.size()) { + var nodesToSearch = !tree.order() ? remaining.keys() : tree.nodes(); + for (var i = 0, il = nodesToSearch.length; + i < il && addTightEdges(nodesToSearch[i]); + ++i); + if (remaining.size()) { + createTightEdge(); + } + } + + return tree; +} + +function slack(g, u, v) { + var rankDiff = g.node(v).rank - g.node(u).rank; + var maxMinLen = util.max(g.outEdges(u, v) + .map(function(e) { return g.edge(e).minLen; })); + return rankDiff - maxMinLen; +} + +},{"../util":27,"cp-data":5,"graphlib":29}],24:[function(require,module,exports){ +'use strict'; + +var util = require('../util'), + topsort = require('graphlib').alg.topsort; + +module.exports = initRank; + +/* + * Assigns a `rank` attribute to each node in the input graph and ensures that + * this rank respects the `minLen` attribute of incident edges. + * + * Prerequisites: + * + * * The input graph must be acyclic + * * Each edge in the input graph must have an assigned 'minLen' attribute + */ +function initRank(g) { + var sorted = topsort(g); + + sorted.forEach(function(u) { + var inEdges = g.inEdges(u); + if (inEdges.length === 0) { + g.node(u).rank = 0; + return; + } + + var minLens = inEdges.map(function(e) { + return g.node(g.source(e)).rank + g.edge(e).minLen; + }); + g.node(u).rank = util.max(minLens); + }); +} + +},{"../util":27,"graphlib":29}],25:[function(require,module,exports){ +'use strict'; + +module.exports = { + slack: slack +}; + +/* + * A helper to calculate the slack between two nodes (`u` and `v`) given a + * `minLen` constraint. The slack represents how much the distance between `u` + * and `v` could shrink while maintaining the `minLen` constraint. If the value + * is negative then the constraint is currently violated. + * + This function requires that `u` and `v` are in `graph` and they both have a + `rank` attribute. + */ +function slack(graph, u, v, minLen) { + return Math.abs(graph.node(u).rank - graph.node(v).rank) - minLen; +} + +},{}],26:[function(require,module,exports){ +'use strict'; + +var util = require('../util'), + rankUtil = require('./rankUtil'); + +module.exports = simplex; + +function simplex(graph, spanningTree) { + // The network simplex algorithm repeatedly replaces edges of + // the spanning tree with negative cut values until no such + // edge exists. + initCutValues(graph, spanningTree); + while (true) { + var e = leaveEdge(spanningTree); + if (e === null) break; + var f = enterEdge(graph, spanningTree, e); + exchange(graph, spanningTree, e, f); + } +} + +/* + * Set the cut values of edges in the spanning tree by a depth-first + * postorder traversal. The cut value corresponds to the cost, in + * terms of a ranking's edge length sum, of lengthening an edge. + * Negative cut values typically indicate edges that would yield a + * smaller edge length sum if they were lengthened. + */ +function initCutValues(graph, spanningTree) { + computeLowLim(spanningTree); + + spanningTree.eachEdge(function(id, u, v, treeValue) { + treeValue.cutValue = 0; + }); + + // Propagate cut values up the tree. + function dfs(n) { + var children = spanningTree.successors(n); + for (var c in children) { + var child = children[c]; + dfs(child); + } + if (n !== spanningTree.graph().root) { + setCutValue(graph, spanningTree, n); + } + } + dfs(spanningTree.graph().root); +} + +/* + * Perform a DFS postorder traversal, labeling each node v with + * its traversal order 'lim(v)' and the minimum traversal number + * of any of its descendants 'low(v)'. This provides an efficient + * way to test whether u is an ancestor of v since + * low(u) <= lim(v) <= lim(u) if and only if u is an ancestor. + */ +function computeLowLim(tree) { + var postOrderNum = 0; + + function dfs(n) { + var children = tree.successors(n); + var low = postOrderNum; + for (var c in children) { + var child = children[c]; + dfs(child); + low = Math.min(low, tree.node(child).low); + } + tree.node(n).low = low; + tree.node(n).lim = postOrderNum++; + } + + dfs(tree.graph().root); +} + +/* + * To compute the cut value of the edge parent -> child, we consider + * it and any other graph edges to or from the child. + * parent + * | + * child + * / \ + * u v + */ +function setCutValue(graph, tree, child) { + var parentEdge = tree.inEdges(child)[0]; + + // List of child's children in the spanning tree. + var grandchildren = []; + var grandchildEdges = tree.outEdges(child); + for (var gce in grandchildEdges) { + grandchildren.push(tree.target(grandchildEdges[gce])); + } + + var cutValue = 0; + + // TODO: Replace unit increment/decrement with edge weights. + var E = 0; // Edges from child to grandchild's subtree. + var F = 0; // Edges to child from grandchild's subtree. + var G = 0; // Edges from child to nodes outside of child's subtree. + var H = 0; // Edges from nodes outside of child's subtree to child. + + // Consider all graph edges from child. + var outEdges = graph.outEdges(child); + var gc; + for (var oe in outEdges) { + var succ = graph.target(outEdges[oe]); + for (gc in grandchildren) { + if (inSubtree(tree, succ, grandchildren[gc])) { + E++; + } + } + if (!inSubtree(tree, succ, child)) { + G++; + } + } + + // Consider all graph edges to child. + var inEdges = graph.inEdges(child); + for (var ie in inEdges) { + var pred = graph.source(inEdges[ie]); + for (gc in grandchildren) { + if (inSubtree(tree, pred, grandchildren[gc])) { + F++; + } + } + if (!inSubtree(tree, pred, child)) { + H++; + } + } + + // Contributions depend on the alignment of the parent -> child edge + // and the child -> u or v edges. + var grandchildCutSum = 0; + for (gc in grandchildren) { + var cv = tree.edge(grandchildEdges[gc]).cutValue; + if (!tree.edge(grandchildEdges[gc]).reversed) { + grandchildCutSum += cv; + } else { + grandchildCutSum -= cv; + } + } + + if (!tree.edge(parentEdge).reversed) { + cutValue += grandchildCutSum - E + F - G + H; + } else { + cutValue -= grandchildCutSum - E + F - G + H; + } + + tree.edge(parentEdge).cutValue = cutValue; +} + +/* + * Return whether n is a node in the subtree with the given + * root. + */ +function inSubtree(tree, n, root) { + return (tree.node(root).low <= tree.node(n).lim && + tree.node(n).lim <= tree.node(root).lim); +} + +/* + * Return an edge from the tree with a negative cut value, or null if there + * is none. + */ +function leaveEdge(tree) { + var edges = tree.edges(); + for (var n in edges) { + var e = edges[n]; + var treeValue = tree.edge(e); + if (treeValue.cutValue < 0) { + return e; + } + } + return null; +} + +/* + * The edge e should be an edge in the tree, with an underlying edge + * in the graph, with a negative cut value. Of the two nodes incident + * on the edge, take the lower one. enterEdge returns an edge with + * minimum slack going from outside of that node's subtree to inside + * of that node's subtree. + */ +function enterEdge(graph, tree, e) { + var source = tree.source(e); + var target = tree.target(e); + var lower = tree.node(target).lim < tree.node(source).lim ? target : source; + + // Is the tree edge aligned with the graph edge? + var aligned = !tree.edge(e).reversed; + + var minSlack = Number.POSITIVE_INFINITY; + var minSlackEdge; + if (aligned) { + graph.eachEdge(function(id, u, v, value) { + if (id !== e && inSubtree(tree, u, lower) && !inSubtree(tree, v, lower)) { + var slack = rankUtil.slack(graph, u, v, value.minLen); + if (slack < minSlack) { + minSlack = slack; + minSlackEdge = id; + } + } + }); + } else { + graph.eachEdge(function(id, u, v, value) { + if (id !== e && !inSubtree(tree, u, lower) && inSubtree(tree, v, lower)) { + var slack = rankUtil.slack(graph, u, v, value.minLen); + if (slack < minSlack) { + minSlack = slack; + minSlackEdge = id; + } + } + }); + } + + if (minSlackEdge === undefined) { + var outside = []; + var inside = []; + graph.eachNode(function(id) { + if (!inSubtree(tree, id, lower)) { + outside.push(id); + } else { + inside.push(id); + } + }); + throw new Error('No edge found from outside of tree to inside'); + } + + return minSlackEdge; +} + +/* + * Replace edge e with edge f in the tree, recalculating the tree root, + * the nodes' low and lim properties and the edges' cut values. + */ +function exchange(graph, tree, e, f) { + tree.delEdge(e); + var source = graph.source(f); + var target = graph.target(f); + + // Redirect edges so that target is the root of its subtree. + function redirect(v) { + var edges = tree.inEdges(v); + for (var i in edges) { + var e = edges[i]; + var u = tree.source(e); + var value = tree.edge(e); + redirect(u); + tree.delEdge(e); + value.reversed = !value.reversed; + tree.addEdge(e, v, u, value); + } + } + + redirect(target); + + var root = source; + var edges = tree.inEdges(root); + while (edges.length > 0) { + root = tree.source(edges[0]); + edges = tree.inEdges(root); + } + + tree.graph().root = root; + + tree.addEdge(null, source, target, {cutValue: 0}); + + initCutValues(graph, tree); + + adjustRanks(graph, tree); +} + +/* + * Reset the ranks of all nodes based on the current spanning tree. + * The rank of the tree's root remains unchanged, while all other + * nodes are set to the sum of minimum length constraints along + * the path from the root. + */ +function adjustRanks(graph, tree) { + function dfs(p) { + var children = tree.successors(p); + children.forEach(function(c) { + var minLen = minimumLength(graph, p, c); + graph.node(c).rank = graph.node(p).rank + minLen; + dfs(c); + }); + } + + dfs(tree.graph().root); +} + +/* + * If u and v are connected by some edges in the graph, return the + * minimum length of those edges, as a positive number if v succeeds + * u and as a negative number if v precedes u. + */ +function minimumLength(graph, u, v) { + var outEdges = graph.outEdges(u, v); + if (outEdges.length > 0) { + return util.max(outEdges.map(function(e) { + return graph.edge(e).minLen; + })); + } + + var inEdges = graph.inEdges(u, v); + if (inEdges.length > 0) { + return -util.max(inEdges.map(function(e) { + return graph.edge(e).minLen; + })); + } +} + +},{"../util":27,"./rankUtil":25}],27:[function(require,module,exports){ +'use strict'; + +/* + * Returns the smallest value in the array. + */ +exports.min = function(values) { + return Math.min.apply(Math, values); +}; + +/* + * Returns the largest value in the array. + */ +exports.max = function(values) { + return Math.max.apply(Math, values); +}; + +/* + * Returns `true` only if `f(x)` is `true` for all `x` in `xs`. Otherwise + * returns `false`. This function will return immediately if it finds a + * case where `f(x)` does not hold. + */ +exports.all = function(xs, f) { + for (var i = 0; i < xs.length; ++i) { + if (!f(xs[i])) { + return false; + } + } + return true; +}; + +/* + * Accumulates the sum of elements in the given array using the `+` operator. + */ +exports.sum = function(values) { + return values.reduce(function(acc, x) { return acc + x; }, 0); +}; + +/* + * Returns an array of all values in the given object. + */ +exports.values = function(obj) { + return Object.keys(obj).map(function(k) { return obj[k]; }); +}; + +exports.shuffle = function(array) { + for (var i = array.length - 1; i > 0; --i) { + var j = Math.floor(Math.random() * (i + 1)); + var aj = array[j]; + array[j] = array[i]; + array[i] = aj; + } +}; + +exports.propertyAccessor = function(self, config, field, setHook) { + return function(x) { + if (!arguments.length) return config[field]; + config[field] = x; + if (setHook) setHook(x); + return self; + }; +}; + +/* + * Given a layered, directed graph with `rank` and `order` node attributes, + * this function returns an array of ordered ranks. Each rank contains an array + * of the ids of the nodes in that rank in the order specified by the `order` + * attribute. + */ +exports.ordering = function(g) { + var ordering = []; + g.eachNode(function(u, value) { + var rank = ordering[value.rank] || (ordering[value.rank] = []); + rank[value.order] = u; + }); + return ordering; +}; + +/* + * A filter that can be used with `filterNodes` to get a graph that only + * includes nodes that do not contain others nodes. + */ +exports.filterNonSubgraphs = function(g) { + return function(u) { + return g.children(u).length === 0; + }; +}; + +/* + * Returns a new function that wraps `func` with a timer. The wrapper logs the + * time it takes to execute the function. + * + * The timer will be enabled provided `log.level >= 1`. + */ +function time(name, func) { + return function() { + var start = new Date().getTime(); + try { + return func.apply(null, arguments); + } finally { + log(1, name + ' time: ' + (new Date().getTime() - start) + 'ms'); + } + }; +} +time.enabled = false; + +exports.time = time; + +/* + * A global logger with the specification `log(level, message, ...)` that + * will log a message to the console if `log.level >= level`. + */ +function log(level) { + if (log.level >= level) { + console.log.apply(console, Array.prototype.slice.call(arguments, 1)); + } +} +log.level = 0; + +exports.log = log; + +},{}],28:[function(require,module,exports){ +module.exports = '0.4.6'; + +},{}],29:[function(require,module,exports){ +exports.Graph = require("./lib/Graph"); +exports.Digraph = require("./lib/Digraph"); +exports.CGraph = require("./lib/CGraph"); +exports.CDigraph = require("./lib/CDigraph"); +require("./lib/graph-converters"); + +exports.alg = { + isAcyclic: require("./lib/alg/isAcyclic"), + components: require("./lib/alg/components"), + dijkstra: require("./lib/alg/dijkstra"), + dijkstraAll: require("./lib/alg/dijkstraAll"), + findCycles: require("./lib/alg/findCycles"), + floydWarshall: require("./lib/alg/floydWarshall"), + postorder: require("./lib/alg/postorder"), + preorder: require("./lib/alg/preorder"), + prim: require("./lib/alg/prim"), + tarjan: require("./lib/alg/tarjan"), + topsort: require("./lib/alg/topsort") +}; + +exports.converter = { + json: require("./lib/converter/json.js") +}; + +var filter = require("./lib/filter"); +exports.filter = { + all: filter.all, + nodesFromList: filter.nodesFromList +}; + +exports.version = require("./lib/version"); + +},{"./lib/CDigraph":31,"./lib/CGraph":32,"./lib/Digraph":33,"./lib/Graph":34,"./lib/alg/components":35,"./lib/alg/dijkstra":36,"./lib/alg/dijkstraAll":37,"./lib/alg/findCycles":38,"./lib/alg/floydWarshall":39,"./lib/alg/isAcyclic":40,"./lib/alg/postorder":41,"./lib/alg/preorder":42,"./lib/alg/prim":43,"./lib/alg/tarjan":44,"./lib/alg/topsort":45,"./lib/converter/json.js":47,"./lib/filter":48,"./lib/graph-converters":49,"./lib/version":51}],30:[function(require,module,exports){ +/* jshint -W079 */ +var Set = require("cp-data").Set; +/* jshint +W079 */ + +module.exports = BaseGraph; + +function BaseGraph() { + // The value assigned to the graph itself. + this._value = undefined; + + // Map of node id -> { id, value } + this._nodes = {}; + + // Map of edge id -> { id, u, v, value } + this._edges = {}; + + // Used to generate a unique id in the graph + this._nextId = 0; +} + +// Number of nodes +BaseGraph.prototype.order = function() { + return Object.keys(this._nodes).length; +}; + +// Number of edges +BaseGraph.prototype.size = function() { + return Object.keys(this._edges).length; +}; + +// Accessor for graph level value +BaseGraph.prototype.graph = function(value) { + if (arguments.length === 0) { + return this._value; + } + this._value = value; +}; + +BaseGraph.prototype.hasNode = function(u) { + return u in this._nodes; +}; + +BaseGraph.prototype.node = function(u, value) { + var node = this._strictGetNode(u); + if (arguments.length === 1) { + return node.value; + } + node.value = value; +}; + +BaseGraph.prototype.nodes = function() { + var nodes = []; + this.eachNode(function(id) { nodes.push(id); }); + return nodes; +}; + +BaseGraph.prototype.eachNode = function(func) { + for (var k in this._nodes) { + var node = this._nodes[k]; + func(node.id, node.value); + } +}; + +BaseGraph.prototype.hasEdge = function(e) { + return e in this._edges; +}; + +BaseGraph.prototype.edge = function(e, value) { + var edge = this._strictGetEdge(e); + if (arguments.length === 1) { + return edge.value; + } + edge.value = value; +}; + +BaseGraph.prototype.edges = function() { + var es = []; + this.eachEdge(function(id) { es.push(id); }); + return es; +}; + +BaseGraph.prototype.eachEdge = function(func) { + for (var k in this._edges) { + var edge = this._edges[k]; + func(edge.id, edge.u, edge.v, edge.value); + } +}; + +BaseGraph.prototype.incidentNodes = function(e) { + var edge = this._strictGetEdge(e); + return [edge.u, edge.v]; +}; + +BaseGraph.prototype.addNode = function(u, value) { + if (u === undefined || u === null) { + do { + u = "_" + (++this._nextId); + } while (this.hasNode(u)); + } else if (this.hasNode(u)) { + throw new Error("Graph already has node '" + u + "'"); + } + this._nodes[u] = { id: u, value: value }; + return u; +}; + +BaseGraph.prototype.delNode = function(u) { + this._strictGetNode(u); + this.incidentEdges(u).forEach(function(e) { this.delEdge(e); }, this); + delete this._nodes[u]; +}; + +// inMap and outMap are opposite sides of an incidence map. For example, for +// Graph these would both come from the _incidentEdges map, while for Digraph +// they would come from _inEdges and _outEdges. +BaseGraph.prototype._addEdge = function(e, u, v, value, inMap, outMap) { + this._strictGetNode(u); + this._strictGetNode(v); + + if (e === undefined || e === null) { + do { + e = "_" + (++this._nextId); + } while (this.hasEdge(e)); + } + else if (this.hasEdge(e)) { + throw new Error("Graph already has edge '" + e + "'"); + } + + this._edges[e] = { id: e, u: u, v: v, value: value }; + addEdgeToMap(inMap[v], u, e); + addEdgeToMap(outMap[u], v, e); + + return e; +}; + +// See note for _addEdge regarding inMap and outMap. +BaseGraph.prototype._delEdge = function(e, inMap, outMap) { + var edge = this._strictGetEdge(e); + delEdgeFromMap(inMap[edge.v], edge.u, e); + delEdgeFromMap(outMap[edge.u], edge.v, e); + delete this._edges[e]; +}; + +BaseGraph.prototype.copy = function() { + var copy = new this.constructor(); + copy.graph(this.graph()); + this.eachNode(function(u, value) { copy.addNode(u, value); }); + this.eachEdge(function(e, u, v, value) { copy.addEdge(e, u, v, value); }); + copy._nextId = this._nextId; + return copy; +}; + +BaseGraph.prototype.filterNodes = function(filter) { + var copy = new this.constructor(); + copy.graph(this.graph()); + this.eachNode(function(u, value) { + if (filter(u)) { + copy.addNode(u, value); + } + }); + this.eachEdge(function(e, u, v, value) { + if (copy.hasNode(u) && copy.hasNode(v)) { + copy.addEdge(e, u, v, value); + } + }); + return copy; +}; + +BaseGraph.prototype._strictGetNode = function(u) { + var node = this._nodes[u]; + if (node === undefined) { + throw new Error("Node '" + u + "' is not in graph"); + } + return node; +}; + +BaseGraph.prototype._strictGetEdge = function(e) { + var edge = this._edges[e]; + if (edge === undefined) { + throw new Error("Edge '" + e + "' is not in graph"); + } + return edge; +}; + +function addEdgeToMap(map, v, e) { + (map[v] || (map[v] = new Set())).add(e); +} + +function delEdgeFromMap(map, v, e) { + var vEntry = map[v]; + vEntry.remove(e); + if (vEntry.size() === 0) { + delete map[v]; + } +} + + +},{"cp-data":5}],31:[function(require,module,exports){ +var Digraph = require("./Digraph"), + compoundify = require("./compoundify"); + +var CDigraph = compoundify(Digraph); + +module.exports = CDigraph; + +CDigraph.fromDigraph = function(src) { + var g = new CDigraph(), + graphValue = src.graph(); + + if (graphValue !== undefined) { + g.graph(graphValue); + } + + src.eachNode(function(u, value) { + if (value === undefined) { + g.addNode(u); + } else { + g.addNode(u, value); + } + }); + src.eachEdge(function(e, u, v, value) { + if (value === undefined) { + g.addEdge(null, u, v); + } else { + g.addEdge(null, u, v, value); + } + }); + return g; +}; + +CDigraph.prototype.toString = function() { + return "CDigraph " + JSON.stringify(this, null, 2); +}; + +},{"./Digraph":33,"./compoundify":46}],32:[function(require,module,exports){ +var Graph = require("./Graph"), + compoundify = require("./compoundify"); + +var CGraph = compoundify(Graph); + +module.exports = CGraph; + +CGraph.fromGraph = function(src) { + var g = new CGraph(), + graphValue = src.graph(); + + if (graphValue !== undefined) { + g.graph(graphValue); + } + + src.eachNode(function(u, value) { + if (value === undefined) { + g.addNode(u); + } else { + g.addNode(u, value); + } + }); + src.eachEdge(function(e, u, v, value) { + if (value === undefined) { + g.addEdge(null, u, v); + } else { + g.addEdge(null, u, v, value); + } + }); + return g; +}; + +CGraph.prototype.toString = function() { + return "CGraph " + JSON.stringify(this, null, 2); +}; + +},{"./Graph":34,"./compoundify":46}],33:[function(require,module,exports){ +/* + * This file is organized with in the following order: + * + * Exports + * Graph constructors + * Graph queries (e.g. nodes(), edges() + * Graph mutators + * Helper functions + */ + +var util = require("./util"), + BaseGraph = require("./BaseGraph"), +/* jshint -W079 */ + Set = require("cp-data").Set; +/* jshint +W079 */ + +module.exports = Digraph; + +/* + * Constructor to create a new directed multi-graph. + */ +function Digraph() { + BaseGraph.call(this); + + /*! Map of sourceId -> {targetId -> Set of edge ids} */ + this._inEdges = {}; + + /*! Map of targetId -> {sourceId -> Set of edge ids} */ + this._outEdges = {}; +} + +Digraph.prototype = new BaseGraph(); +Digraph.prototype.constructor = Digraph; + +/* + * Always returns `true`. + */ +Digraph.prototype.isDirected = function() { + return true; +}; + +/* + * Returns all successors of the node with the id `u`. That is, all nodes + * that have the node `u` as their source are returned. + * + * If no node `u` exists in the graph this function throws an Error. + * + * @param {String} u a node id + */ +Digraph.prototype.successors = function(u) { + this._strictGetNode(u); + return Object.keys(this._outEdges[u]) + .map(function(v) { return this._nodes[v].id; }, this); +}; + +/* + * Returns all predecessors of the node with the id `u`. That is, all nodes + * that have the node `u` as their target are returned. + * + * If no node `u` exists in the graph this function throws an Error. + * + * @param {String} u a node id + */ +Digraph.prototype.predecessors = function(u) { + this._strictGetNode(u); + return Object.keys(this._inEdges[u]) + .map(function(v) { return this._nodes[v].id; }, this); +}; + +/* + * Returns all nodes that are adjacent to the node with the id `u`. In other + * words, this function returns the set of all successors and predecessors of + * node `u`. + * + * @param {String} u a node id + */ +Digraph.prototype.neighbors = function(u) { + return Set.union([this.successors(u), this.predecessors(u)]).keys(); +}; + +/* + * Returns all nodes in the graph that have no in-edges. + */ +Digraph.prototype.sources = function() { + var self = this; + return this._filterNodes(function(u) { + // This could have better space characteristics if we had an inDegree function. + return self.inEdges(u).length === 0; + }); +}; + +/* + * Returns all nodes in the graph that have no out-edges. + */ +Digraph.prototype.sinks = function() { + var self = this; + return this._filterNodes(function(u) { + // This could have better space characteristics if we have an outDegree function. + return self.outEdges(u).length === 0; + }); +}; + +/* + * Returns the source node incident on the edge identified by the id `e`. If no + * such edge exists in the graph this function throws an Error. + * + * @param {String} e an edge id + */ +Digraph.prototype.source = function(e) { + return this._strictGetEdge(e).u; +}; + +/* + * Returns the target node incident on the edge identified by the id `e`. If no + * such edge exists in the graph this function throws an Error. + * + * @param {String} e an edge id + */ +Digraph.prototype.target = function(e) { + return this._strictGetEdge(e).v; +}; + +/* + * Returns an array of ids for all edges in the graph that have the node + * `target` as their target. If the node `target` is not in the graph this + * function raises an Error. + * + * Optionally a `source` node can also be specified. This causes the results + * to be filtered such that only edges from `source` to `target` are included. + * If the node `source` is specified but is not in the graph then this function + * raises an Error. + * + * @param {String} target the target node id + * @param {String} [source] an optional source node id + */ +Digraph.prototype.inEdges = function(target, source) { + this._strictGetNode(target); + var results = Set.union(util.values(this._inEdges[target])).keys(); + if (arguments.length > 1) { + this._strictGetNode(source); + results = results.filter(function(e) { return this.source(e) === source; }, this); + } + return results; +}; + +/* + * Returns an array of ids for all edges in the graph that have the node + * `source` as their source. If the node `source` is not in the graph this + * function raises an Error. + * + * Optionally a `target` node may also be specified. This causes the results + * to be filtered such that only edges from `source` to `target` are included. + * If the node `target` is specified but is not in the graph then this function + * raises an Error. + * + * @param {String} source the source node id + * @param {String} [target] an optional target node id + */ +Digraph.prototype.outEdges = function(source, target) { + this._strictGetNode(source); + var results = Set.union(util.values(this._outEdges[source])).keys(); + if (arguments.length > 1) { + this._strictGetNode(target); + results = results.filter(function(e) { return this.target(e) === target; }, this); + } + return results; +}; + +/* + * Returns an array of ids for all edges in the graph that have the `u` as + * their source or their target. If the node `u` is not in the graph this + * function raises an Error. + * + * Optionally a `v` node may also be specified. This causes the results to be + * filtered such that only edges between `u` and `v` - in either direction - + * are included. IF the node `v` is specified but not in the graph then this + * function raises an Error. + * + * @param {String} u the node for which to find incident edges + * @param {String} [v] option node that must be adjacent to `u` + */ +Digraph.prototype.incidentEdges = function(u, v) { + if (arguments.length > 1) { + return Set.union([this.outEdges(u, v), this.outEdges(v, u)]).keys(); + } else { + return Set.union([this.inEdges(u), this.outEdges(u)]).keys(); + } +}; + +/* + * Returns a string representation of this graph. + */ +Digraph.prototype.toString = function() { + return "Digraph " + JSON.stringify(this, null, 2); +}; + +/* + * Adds a new node with the id `u` to the graph and assigns it the value + * `value`. If a node with the id is already a part of the graph this function + * throws an Error. + * + * @param {String} u a node id + * @param {Object} [value] an optional value to attach to the node + */ +Digraph.prototype.addNode = function(u, value) { + u = BaseGraph.prototype.addNode.call(this, u, value); + this._inEdges[u] = {}; + this._outEdges[u] = {}; + return u; +}; + +/* + * Removes a node from the graph that has the id `u`. Any edges incident on the + * node are also removed. If the graph does not contain a node with the id this + * function will throw an Error. + * + * @param {String} u a node id + */ +Digraph.prototype.delNode = function(u) { + BaseGraph.prototype.delNode.call(this, u); + delete this._inEdges[u]; + delete this._outEdges[u]; +}; + +/* + * Adds a new edge to the graph with the id `e` from a node with the id `source` + * to a node with an id `target` and assigns it the value `value`. This graph + * allows more than one edge from `source` to `target` as long as the id `e` + * is unique in the set of edges. If `e` is `null` the graph will assign a + * unique identifier to the edge. + * + * If `source` or `target` are not present in the graph this function will + * throw an Error. + * + * @param {String} [e] an edge id + * @param {String} source the source node id + * @param {String} target the target node id + * @param {Object} [value] an optional value to attach to the edge + */ +Digraph.prototype.addEdge = function(e, source, target, value) { + return BaseGraph.prototype._addEdge.call(this, e, source, target, value, + this._inEdges, this._outEdges); +}; + +/* + * Removes an edge in the graph with the id `e`. If no edge in the graph has + * the id `e` this function will throw an Error. + * + * @param {String} e an edge id + */ +Digraph.prototype.delEdge = function(e) { + BaseGraph.prototype._delEdge.call(this, e, this._inEdges, this._outEdges); +}; + +// Unlike BaseGraph.filterNodes, this helper just returns nodes that +// satisfy a predicate. +Digraph.prototype._filterNodes = function(pred) { + var filtered = []; + this.eachNode(function(u) { + if (pred(u)) { + filtered.push(u); + } + }); + return filtered; +}; + + +},{"./BaseGraph":30,"./util":50,"cp-data":5}],34:[function(require,module,exports){ +/* + * This file is organized with in the following order: + * + * Exports + * Graph constructors + * Graph queries (e.g. nodes(), edges() + * Graph mutators + * Helper functions + */ + +var util = require("./util"), + BaseGraph = require("./BaseGraph"), +/* jshint -W079 */ + Set = require("cp-data").Set; +/* jshint +W079 */ + +module.exports = Graph; + +/* + * Constructor to create a new undirected multi-graph. + */ +function Graph() { + BaseGraph.call(this); + + /*! Map of nodeId -> { otherNodeId -> Set of edge ids } */ + this._incidentEdges = {}; +} + +Graph.prototype = new BaseGraph(); +Graph.prototype.constructor = Graph; + +/* + * Always returns `false`. + */ +Graph.prototype.isDirected = function() { + return false; +}; + +/* + * Returns all nodes that are adjacent to the node with the id `u`. + * + * @param {String} u a node id + */ +Graph.prototype.neighbors = function(u) { + this._strictGetNode(u); + return Object.keys(this._incidentEdges[u]) + .map(function(v) { return this._nodes[v].id; }, this); +}; + +/* + * Returns an array of ids for all edges in the graph that are incident on `u`. + * If the node `u` is not in the graph this function raises an Error. + * + * Optionally a `v` node may also be specified. This causes the results to be + * filtered such that only edges between `u` and `v` are included. If the node + * `v` is specified but not in the graph then this function raises an Error. + * + * @param {String} u the node for which to find incident edges + * @param {String} [v] option node that must be adjacent to `u` + */ +Graph.prototype.incidentEdges = function(u, v) { + this._strictGetNode(u); + if (arguments.length > 1) { + this._strictGetNode(v); + return v in this._incidentEdges[u] ? this._incidentEdges[u][v].keys() : []; + } else { + return Set.union(util.values(this._incidentEdges[u])).keys(); + } +}; + +/* + * Returns a string representation of this graph. + */ +Graph.prototype.toString = function() { + return "Graph " + JSON.stringify(this, null, 2); +}; + +/* + * Adds a new node with the id `u` to the graph and assigns it the value + * `value`. If a node with the id is already a part of the graph this function + * throws an Error. + * + * @param {String} u a node id + * @param {Object} [value] an optional value to attach to the node + */ +Graph.prototype.addNode = function(u, value) { + u = BaseGraph.prototype.addNode.call(this, u, value); + this._incidentEdges[u] = {}; + return u; +}; + +/* + * Removes a node from the graph that has the id `u`. Any edges incident on the + * node are also removed. If the graph does not contain a node with the id this + * function will throw an Error. + * + * @param {String} u a node id + */ +Graph.prototype.delNode = function(u) { + BaseGraph.prototype.delNode.call(this, u); + delete this._incidentEdges[u]; +}; + +/* + * Adds a new edge to the graph with the id `e` between a node with the id `u` + * and a node with an id `v` and assigns it the value `value`. This graph + * allows more than one edge between `u` and `v` as long as the id `e` + * is unique in the set of edges. If `e` is `null` the graph will assign a + * unique identifier to the edge. + * + * If `u` or `v` are not present in the graph this function will throw an + * Error. + * + * @param {String} [e] an edge id + * @param {String} u the node id of one of the adjacent nodes + * @param {String} v the node id of the other adjacent node + * @param {Object} [value] an optional value to attach to the edge + */ +Graph.prototype.addEdge = function(e, u, v, value) { + return BaseGraph.prototype._addEdge.call(this, e, u, v, value, + this._incidentEdges, this._incidentEdges); +}; + +/* + * Removes an edge in the graph with the id `e`. If no edge in the graph has + * the id `e` this function will throw an Error. + * + * @param {String} e an edge id + */ +Graph.prototype.delEdge = function(e) { + BaseGraph.prototype._delEdge.call(this, e, this._incidentEdges, this._incidentEdges); +}; + + +},{"./BaseGraph":30,"./util":50,"cp-data":5}],35:[function(require,module,exports){ +/* jshint -W079 */ +var Set = require("cp-data").Set; +/* jshint +W079 */ + +module.exports = components; + +/** + * Finds all [connected components][] in a graph and returns an array of these + * components. Each component is itself an array that contains the ids of nodes + * in the component. + * + * This function only works with undirected Graphs. + * + * [connected components]: http://en.wikipedia.org/wiki/Connected_component_(graph_theory) + * + * @param {Graph} g the graph to search for components + */ +function components(g) { + var results = []; + var visited = new Set(); + + function dfs(v, component) { + if (!visited.has(v)) { + visited.add(v); + component.push(v); + g.neighbors(v).forEach(function(w) { + dfs(w, component); + }); + } + } + + g.nodes().forEach(function(v) { + var component = []; + dfs(v, component); + if (component.length > 0) { + results.push(component); + } + }); + + return results; +} + +},{"cp-data":5}],36:[function(require,module,exports){ +var PriorityQueue = require("cp-data").PriorityQueue; + +module.exports = dijkstra; + +/** + * This function is an implementation of [Dijkstra's algorithm][] which finds + * the shortest path from **source** to all other nodes in **g**. This + * function returns a map of `u -> { distance, predecessor }`. The distance + * property holds the sum of the weights from **source** to `u` along the + * shortest path or `Number.POSITIVE_INFINITY` if there is no path from + * **source**. The predecessor property can be used to walk the individual + * elements of the path from **source** to **u** in reverse order. + * + * This function takes an optional `weightFunc(e)` which returns the + * weight of the edge `e`. If no weightFunc is supplied then each edge is + * assumed to have a weight of 1. This function throws an Error if any of + * the traversed edges have a negative edge weight. + * + * This function takes an optional `incidentFunc(u)` which returns the ids of + * all edges incident to the node `u` for the purposes of shortest path + * traversal. By default this function uses the `g.outEdges` for Digraphs and + * `g.incidentEdges` for Graphs. + * + * This function takes `O((|E| + |V|) * log |V|)` time. + * + * [Dijkstra's algorithm]: http://en.wikipedia.org/wiki/Dijkstra%27s_algorithm + * + * @param {Graph} g the graph to search for shortest paths from **source** + * @param {Object} source the source from which to start the search + * @param {Function} [weightFunc] optional weight function + * @param {Function} [incidentFunc] optional incident function + */ +function dijkstra(g, source, weightFunc, incidentFunc) { + var results = {}, + pq = new PriorityQueue(); + + function updateNeighbors(e) { + var incidentNodes = g.incidentNodes(e), + v = incidentNodes[0] !== u ? incidentNodes[0] : incidentNodes[1], + vEntry = results[v], + weight = weightFunc(e), + distance = uEntry.distance + weight; + + if (weight < 0) { + throw new Error("dijkstra does not allow negative edge weights. Bad edge: " + e + " Weight: " + weight); + } + + if (distance < vEntry.distance) { + vEntry.distance = distance; + vEntry.predecessor = u; + pq.decrease(v, distance); + } + } + + weightFunc = weightFunc || function() { return 1; }; + incidentFunc = incidentFunc || (g.isDirected() + ? function(u) { return g.outEdges(u); } + : function(u) { return g.incidentEdges(u); }); + + g.eachNode(function(u) { + var distance = u === source ? 0 : Number.POSITIVE_INFINITY; + results[u] = { distance: distance }; + pq.add(u, distance); + }); + + var u, uEntry; + while (pq.size() > 0) { + u = pq.removeMin(); + uEntry = results[u]; + if (uEntry.distance === Number.POSITIVE_INFINITY) { + break; + } + + incidentFunc(u).forEach(updateNeighbors); + } + + return results; +} + +},{"cp-data":5}],37:[function(require,module,exports){ +var dijkstra = require("./dijkstra"); + +module.exports = dijkstraAll; + +/** + * This function finds the shortest path from each node to every other + * reachable node in the graph. It is similar to [alg.dijkstra][], but + * instead of returning a single-source array, it returns a mapping of + * of `source -> alg.dijksta(g, source, weightFunc, incidentFunc)`. + * + * This function takes an optional `weightFunc(e)` which returns the + * weight of the edge `e`. If no weightFunc is supplied then each edge is + * assumed to have a weight of 1. This function throws an Error if any of + * the traversed edges have a negative edge weight. + * + * This function takes an optional `incidentFunc(u)` which returns the ids of + * all edges incident to the node `u` for the purposes of shortest path + * traversal. By default this function uses the `outEdges` function on the + * supplied graph. + * + * This function takes `O(|V| * (|E| + |V|) * log |V|)` time. + * + * [alg.dijkstra]: dijkstra.js.html#dijkstra + * + * @param {Graph} g the graph to search for shortest paths from **source** + * @param {Function} [weightFunc] optional weight function + * @param {Function} [incidentFunc] optional incident function + */ +function dijkstraAll(g, weightFunc, incidentFunc) { + var results = {}; + g.eachNode(function(u) { + results[u] = dijkstra(g, u, weightFunc, incidentFunc); + }); + return results; +} + +},{"./dijkstra":36}],38:[function(require,module,exports){ +var tarjan = require("./tarjan"); + +module.exports = findCycles; + +/* + * Given a Digraph **g** this function returns all nodes that are part of a + * cycle. Since there may be more than one cycle in a graph this function + * returns an array of these cycles, where each cycle is itself represented + * by an array of ids for each node involved in that cycle. + * + * [alg.isAcyclic][] is more efficient if you only need to determine whether + * a graph has a cycle or not. + * + * [alg.isAcyclic]: isAcyclic.js.html#isAcyclic + * + * @param {Digraph} g the graph to search for cycles. + */ +function findCycles(g) { + return tarjan(g).filter(function(cmpt) { return cmpt.length > 1; }); +} + +},{"./tarjan":44}],39:[function(require,module,exports){ +module.exports = floydWarshall; + +/** + * This function is an implementation of the [Floyd-Warshall algorithm][], + * which finds the shortest path from each node to every other reachable node + * in the graph. It is similar to [alg.dijkstraAll][], but it handles negative + * edge weights and is more efficient for some types of graphs. This function + * returns a map of `source -> { target -> { distance, predecessor }`. The + * distance property holds the sum of the weights from `source` to `target` + * along the shortest path of `Number.POSITIVE_INFINITY` if there is no path + * from `source`. The predecessor property can be used to walk the individual + * elements of the path from `source` to `target` in reverse order. + * + * This function takes an optional `weightFunc(e)` which returns the + * weight of the edge `e`. If no weightFunc is supplied then each edge is + * assumed to have a weight of 1. + * + * This function takes an optional `incidentFunc(u)` which returns the ids of + * all edges incident to the node `u` for the purposes of shortest path + * traversal. By default this function uses the `outEdges` function on the + * supplied graph. + * + * This algorithm takes O(|V|^3) time. + * + * [Floyd-Warshall algorithm]: https://en.wikipedia.org/wiki/Floyd-Warshall_algorithm + * [alg.dijkstraAll]: dijkstraAll.js.html#dijkstraAll + * + * @param {Graph} g the graph to search for shortest paths from **source** + * @param {Function} [weightFunc] optional weight function + * @param {Function} [incidentFunc] optional incident function + */ +function floydWarshall(g, weightFunc, incidentFunc) { + var results = {}, + nodes = g.nodes(); + + weightFunc = weightFunc || function() { return 1; }; + incidentFunc = incidentFunc || (g.isDirected() + ? function(u) { return g.outEdges(u); } + : function(u) { return g.incidentEdges(u); }); + + nodes.forEach(function(u) { + results[u] = {}; + results[u][u] = { distance: 0 }; + nodes.forEach(function(v) { + if (u !== v) { + results[u][v] = { distance: Number.POSITIVE_INFINITY }; + } + }); + incidentFunc(u).forEach(function(e) { + var incidentNodes = g.incidentNodes(e), + v = incidentNodes[0] !== u ? incidentNodes[0] : incidentNodes[1], + d = weightFunc(e); + if (d < results[u][v].distance) { + results[u][v] = { distance: d, predecessor: u }; + } + }); + }); + + nodes.forEach(function(k) { + var rowK = results[k]; + nodes.forEach(function(i) { + var rowI = results[i]; + nodes.forEach(function(j) { + var ik = rowI[k]; + var kj = rowK[j]; + var ij = rowI[j]; + var altDistance = ik.distance + kj.distance; + if (altDistance < ij.distance) { + ij.distance = altDistance; + ij.predecessor = kj.predecessor; + } + }); + }); + }); + + return results; +} + +},{}],40:[function(require,module,exports){ +var topsort = require("./topsort"); + +module.exports = isAcyclic; + +/* + * Given a Digraph **g** this function returns `true` if the graph has no + * cycles and returns `false` if it does. This algorithm returns as soon as it + * detects the first cycle. + * + * Use [alg.findCycles][] if you need the actual list of cycles in a graph. + * + * [alg.findCycles]: findCycles.js.html#findCycles + * + * @param {Digraph} g the graph to test for cycles + */ +function isAcyclic(g) { + try { + topsort(g); + } catch (e) { + if (e instanceof topsort.CycleException) return false; + throw e; + } + return true; +} + +},{"./topsort":45}],41:[function(require,module,exports){ +/* jshint -W079 */ +var Set = require("cp-data").Set; +/* jshint +W079 */ + +module.exports = postorder; + +// Postorder traversal of g, calling f for each visited node. Assumes the graph +// is a tree. +function postorder(g, root, f) { + var visited = new Set(); + if (g.isDirected()) { + throw new Error("This function only works for undirected graphs"); + } + function dfs(u, prev) { + if (visited.has(u)) { + throw new Error("The input graph is not a tree: " + g); + } + visited.add(u); + g.neighbors(u).forEach(function(v) { + if (v !== prev) dfs(v, u); + }); + f(u); + } + dfs(root); +} + +},{"cp-data":5}],42:[function(require,module,exports){ +/* jshint -W079 */ +var Set = require("cp-data").Set; +/* jshint +W079 */ + +module.exports = preorder; + +// Preorder traversal of g, calling f for each visited node. Assumes the graph +// is a tree. +function preorder(g, root, f) { + var visited = new Set(); + if (g.isDirected()) { + throw new Error("This function only works for undirected graphs"); + } + function dfs(u, prev) { + if (visited.has(u)) { + throw new Error("The input graph is not a tree: " + g); + } + visited.add(u); + f(u); + g.neighbors(u).forEach(function(v) { + if (v !== prev) dfs(v, u); + }); + } + dfs(root); +} + +},{"cp-data":5}],43:[function(require,module,exports){ +var Graph = require("../Graph"), + PriorityQueue = require("cp-data").PriorityQueue; + +module.exports = prim; + +/** + * [Prim's algorithm][] takes a connected undirected graph and generates a + * [minimum spanning tree][]. This function returns the minimum spanning + * tree as an undirected graph. This algorithm is derived from the description + * in "Introduction to Algorithms", Third Edition, Cormen, et al., Pg 634. + * + * This function takes a `weightFunc(e)` which returns the weight of the edge + * `e`. It throws an Error if the graph is not connected. + * + * This function takes `O(|E| log |V|)` time. + * + * [Prim's algorithm]: https://en.wikipedia.org/wiki/Prim's_algorithm + * [minimum spanning tree]: https://en.wikipedia.org/wiki/Minimum_spanning_tree + * + * @param {Graph} g the graph used to generate the minimum spanning tree + * @param {Function} weightFunc the weight function to use + */ +function prim(g, weightFunc) { + var result = new Graph(), + parents = {}, + pq = new PriorityQueue(), + u; + + function updateNeighbors(e) { + var incidentNodes = g.incidentNodes(e), + v = incidentNodes[0] !== u ? incidentNodes[0] : incidentNodes[1], + pri = pq.priority(v); + if (pri !== undefined) { + var edgeWeight = weightFunc(e); + if (edgeWeight < pri) { + parents[v] = u; + pq.decrease(v, edgeWeight); + } + } + } + + if (g.order() === 0) { + return result; + } + + g.eachNode(function(u) { + pq.add(u, Number.POSITIVE_INFINITY); + result.addNode(u); + }); + + // Start from an arbitrary node + pq.decrease(g.nodes()[0], 0); + + var init = false; + while (pq.size() > 0) { + u = pq.removeMin(); + if (u in parents) { + result.addEdge(null, u, parents[u]); + } else if (init) { + throw new Error("Input graph is not connected: " + g); + } else { + init = true; + } + + g.incidentEdges(u).forEach(updateNeighbors); + } + + return result; +} + +},{"../Graph":34,"cp-data":5}],44:[function(require,module,exports){ +module.exports = tarjan; + +/** + * This function is an implementation of [Tarjan's algorithm][] which finds + * all [strongly connected components][] in the directed graph **g**. Each + * strongly connected component is composed of nodes that can reach all other + * nodes in the component via directed edges. A strongly connected component + * can consist of a single node if that node cannot both reach and be reached + * by any other specific node in the graph. Components of more than one node + * are guaranteed to have at least one cycle. + * + * This function returns an array of components. Each component is itself an + * array that contains the ids of all nodes in the component. + * + * [Tarjan's algorithm]: http://en.wikipedia.org/wiki/Tarjan's_strongly_connected_components_algorithm + * [strongly connected components]: http://en.wikipedia.org/wiki/Strongly_connected_component + * + * @param {Digraph} g the graph to search for strongly connected components + */ +function tarjan(g) { + if (!g.isDirected()) { + throw new Error("tarjan can only be applied to a directed graph. Bad input: " + g); + } + + var index = 0, + stack = [], + visited = {}, // node id -> { onStack, lowlink, index } + results = []; + + function dfs(u) { + var entry = visited[u] = { + onStack: true, + lowlink: index, + index: index++ + }; + stack.push(u); + + g.successors(u).forEach(function(v) { + if (!(v in visited)) { + dfs(v); + entry.lowlink = Math.min(entry.lowlink, visited[v].lowlink); + } else if (visited[v].onStack) { + entry.lowlink = Math.min(entry.lowlink, visited[v].index); + } + }); + + if (entry.lowlink === entry.index) { + var cmpt = [], + v; + do { + v = stack.pop(); + visited[v].onStack = false; + cmpt.push(v); + } while (u !== v); + results.push(cmpt); + } + } + + g.nodes().forEach(function(u) { + if (!(u in visited)) { + dfs(u); + } + }); + + return results; +} + +},{}],45:[function(require,module,exports){ +module.exports = topsort; +topsort.CycleException = CycleException; + +/* + * Given a graph **g**, this function returns an ordered list of nodes such + * that for each edge `u -> v`, `u` appears before `v` in the list. If the + * graph has a cycle it is impossible to generate such a list and + * **CycleException** is thrown. + * + * See [topological sorting](https://en.wikipedia.org/wiki/Topological_sorting) + * for more details about how this algorithm works. + * + * @param {Digraph} g the graph to sort + */ +function topsort(g) { + if (!g.isDirected()) { + throw new Error("topsort can only be applied to a directed graph. Bad input: " + g); + } + + var visited = {}; + var stack = {}; + var results = []; + + function visit(node) { + if (node in stack) { + throw new CycleException(); + } + + if (!(node in visited)) { + stack[node] = true; + visited[node] = true; + g.predecessors(node).forEach(function(pred) { + visit(pred); + }); + delete stack[node]; + results.push(node); + } + } + + var sinks = g.sinks(); + if (g.order() !== 0 && sinks.length === 0) { + throw new CycleException(); + } + + g.sinks().forEach(function(sink) { + visit(sink); + }); + + return results; +} + +function CycleException() {} + +CycleException.prototype.toString = function() { + return "Graph has at least one cycle"; +}; + +},{}],46:[function(require,module,exports){ +// This file provides a helper function that mixes-in Dot behavior to an +// existing graph prototype. + +/* jshint -W079 */ +var Set = require("cp-data").Set; +/* jshint +W079 */ + +module.exports = compoundify; + +// Extends the given SuperConstructor with the ability for nodes to contain +// other nodes. A special node id `null` is used to indicate the root graph. +function compoundify(SuperConstructor) { + function Constructor() { + SuperConstructor.call(this); + + // Map of object id -> parent id (or null for root graph) + this._parents = {}; + + // Map of id (or null) -> children set + this._children = {}; + this._children[null] = new Set(); + } + + Constructor.prototype = new SuperConstructor(); + Constructor.prototype.constructor = Constructor; + + Constructor.prototype.parent = function(u, parent) { + this._strictGetNode(u); + + if (arguments.length < 2) { + return this._parents[u]; + } + + if (u === parent) { + throw new Error("Cannot make " + u + " a parent of itself"); + } + if (parent !== null) { + this._strictGetNode(parent); + } + + this._children[this._parents[u]].remove(u); + this._parents[u] = parent; + this._children[parent].add(u); + }; + + Constructor.prototype.children = function(u) { + if (u !== null) { + this._strictGetNode(u); + } + return this._children[u].keys(); + }; + + Constructor.prototype.addNode = function(u, value) { + u = SuperConstructor.prototype.addNode.call(this, u, value); + this._parents[u] = null; + this._children[u] = new Set(); + this._children[null].add(u); + return u; + }; + + Constructor.prototype.delNode = function(u) { + // Promote all children to the parent of the subgraph + var parent = this.parent(u); + this._children[u].keys().forEach(function(child) { + this.parent(child, parent); + }, this); + + this._children[parent].remove(u); + delete this._parents[u]; + delete this._children[u]; + + return SuperConstructor.prototype.delNode.call(this, u); + }; + + Constructor.prototype.copy = function() { + var copy = SuperConstructor.prototype.copy.call(this); + this.nodes().forEach(function(u) { + copy.parent(u, this.parent(u)); + }, this); + return copy; + }; + + Constructor.prototype.filterNodes = function(filter) { + var self = this, + copy = SuperConstructor.prototype.filterNodes.call(this, filter); + + var parents = {}; + function findParent(u) { + var parent = self.parent(u); + if (parent === null || copy.hasNode(parent)) { + parents[u] = parent; + return parent; + } else if (parent in parents) { + return parents[parent]; + } else { + return findParent(parent); + } + } + + copy.eachNode(function(u) { copy.parent(u, findParent(u)); }); + + return copy; + }; + + return Constructor; +} + +},{"cp-data":5}],47:[function(require,module,exports){ +var Graph = require("../Graph"), + Digraph = require("../Digraph"), + CGraph = require("../CGraph"), + CDigraph = require("../CDigraph"); + +exports.decode = function(nodes, edges, Ctor) { + Ctor = Ctor || Digraph; + + if (typeOf(nodes) !== "Array") { + throw new Error("nodes is not an Array"); + } + + if (typeOf(edges) !== "Array") { + throw new Error("edges is not an Array"); + } + + if (typeof Ctor === "string") { + switch(Ctor) { + case "graph": Ctor = Graph; break; + case "digraph": Ctor = Digraph; break; + case "cgraph": Ctor = CGraph; break; + case "cdigraph": Ctor = CDigraph; break; + default: throw new Error("Unrecognized graph type: " + Ctor); + } + } + + var graph = new Ctor(); + + nodes.forEach(function(u) { + graph.addNode(u.id, u.value); + }); + + // If the graph is compound, set up children... + if (graph.parent) { + nodes.forEach(function(u) { + if (u.children) { + u.children.forEach(function(v) { + graph.parent(v, u.id); + }); + } + }); + } + + edges.forEach(function(e) { + graph.addEdge(e.id, e.u, e.v, e.value); + }); + + return graph; +}; + +exports.encode = function(graph) { + var nodes = []; + var edges = []; + + graph.eachNode(function(u, value) { + var node = {id: u, value: value}; + if (graph.children) { + var children = graph.children(u); + if (children.length) { + node.children = children; + } + } + nodes.push(node); + }); + + graph.eachEdge(function(e, u, v, value) { + edges.push({id: e, u: u, v: v, value: value}); + }); + + var type; + if (graph instanceof CDigraph) { + type = "cdigraph"; + } else if (graph instanceof CGraph) { + type = "cgraph"; + } else if (graph instanceof Digraph) { + type = "digraph"; + } else if (graph instanceof Graph) { + type = "graph"; + } else { + throw new Error("Couldn't determine type of graph: " + graph); + } + + return { nodes: nodes, edges: edges, type: type }; +}; + +function typeOf(obj) { + return Object.prototype.toString.call(obj).slice(8, -1); +} + +},{"../CDigraph":31,"../CGraph":32,"../Digraph":33,"../Graph":34}],48:[function(require,module,exports){ +/* jshint -W079 */ +var Set = require("cp-data").Set; +/* jshint +W079 */ + +exports.all = function() { + return function() { return true; }; +}; + +exports.nodesFromList = function(nodes) { + var set = new Set(nodes); + return function(u) { + return set.has(u); + }; +}; + +},{"cp-data":5}],49:[function(require,module,exports){ +var Graph = require("./Graph"), + Digraph = require("./Digraph"); + +// Side-effect based changes are lousy, but node doesn't seem to resolve the +// requires cycle. + +/** + * Returns a new directed graph using the nodes and edges from this graph. The + * new graph will have the same nodes, but will have twice the number of edges: + * each edge is split into two edges with opposite directions. Edge ids, + * consequently, are not preserved by this transformation. + */ +Graph.prototype.toDigraph = +Graph.prototype.asDirected = function() { + var g = new Digraph(); + this.eachNode(function(u, value) { g.addNode(u, value); }); + this.eachEdge(function(e, u, v, value) { + g.addEdge(null, u, v, value); + g.addEdge(null, v, u, value); + }); + return g; +}; + +/** + * Returns a new undirected graph using the nodes and edges from this graph. + * The new graph will have the same nodes, but the edges will be made + * undirected. Edge ids are preserved in this transformation. + */ +Digraph.prototype.toGraph = +Digraph.prototype.asUndirected = function() { + var g = new Graph(); + this.eachNode(function(u, value) { g.addNode(u, value); }); + this.eachEdge(function(e, u, v, value) { + g.addEdge(e, u, v, value); + }); + return g; +}; + +},{"./Digraph":33,"./Graph":34}],50:[function(require,module,exports){ +// Returns an array of all values for properties of **o**. +exports.values = function(o) { + var ks = Object.keys(o), + len = ks.length, + result = new Array(len), + i; + for (i = 0; i < len; ++i) { + result[i] = o[ks[i]]; + } + return result; +}; + +},{}],51:[function(require,module,exports){ +module.exports = '0.7.4'; + +},{}]},{},[1]) +; diff --git a/airflow/www/static/dagre-d3.min.js b/airflow/www/static/dagre-d3.min.js new file mode 100644 index 0000000000000..8d9ec3a5e31f6 --- /dev/null +++ b/airflow/www/static/dagre-d3.min.js @@ -0,0 +1,2 @@ +(function a(b,c,d){function e(g,h){if(!c[g]){if(!b[g]){var j=typeof require=="function"&&require;if(!h&&j)return j(g,!0);if(f)return f(g,!0);throw new Error("Cannot find module '"+g+"'")}var k=c[g]={exports:{}};b[g][0].call(k.exports,function(a){var c=b[g][1][a];return e(c?c:a)},k,k.exports,a,b,c,d)}return c[g].exports}var f=typeof require=="function"&&require;for(var g=0;gMath.abs(e)*h?(f<0&&(h=-h),i=f===0?0:h*e/f,j=h):(e<0&&(g=-g),i=g,j=e===0?0:g*f/e),{x:c+i,y:d+j}}function H(a,b,c){var d=a.x,e=a.y,f,g;r(b)?f=g=b.r.baseVal.value:(f=b.rx.baseVal.value,g=b.ry.baseVal.value);var h=d-c.x,i=e-c.y,j=Math.sqrt(f*f*i*i+g*g*h*h),k=Math.abs(f*g*h/j);c.x0}function J(a,b,c){c.some(function(c){return c[0]===a&&c[1]===b})||c.push([a,b])}function K(a,b,c,d,e,f,g,h,i){var j,k,l,m,n,o,p,q,r,s,t,u,v,w,x;j=d-b,l=a-c,n=c*b-a*d,r=j*e+l*f+n,s=j*g+l*h+n;if(r!==0&&s!==0&&I(r,s))return;k=h-f,m=e-g,o=g*f-e*h,p=k*a+m*b+o,q=k*c+m*d+o;if(p!==0&&q!==0&&I(p,q))return;t=j*m-k*l;if(t===0)return;u=Math.abs(t/2),v=l*o-m*n,w=v<0?(v-u)/t:(v+u)/t,v=k*n-j*o,x=v<0?(v-u)/t:(v+u)/t,J(w,x,i)}function L(a,b,c){var d=a.x,e=a.y,f=c.x,g=c.y,h=[],i=b.points,j=1e5,k=1e5;for(var l=0;l1?(h.sort(function(a,b){var d=a[0]-c.x,e=a[1]-c.y,f=Math.sqrt(d*d+e*e),g=b[0]-c.x,h=b[1]-c.y,i=Math.sqrt(g*g+h*h);return fthis._arr[c].priority)throw new Error("New priority is greater than current priority. Key: "+a+" Old: "+this._arr[c].priority+" New: "+b);this._arr[c].priority=b,this._decrease(c)},d.prototype._heapify=function(a){var b=this._arr,c=2*a,d=c+1,e=a;c>1;if(b[d].priority>>0,g=!1;1d;++d)a.hasOwnProperty(d)&&(g?e=b(e,a[d],d,a):(e=a[d],g=!0));if(!g)throw new TypeError("Reduce of empty array with no initial value");return e}:c.reduce=function(a,b,c){return a.reduce(b,c)}},{}],9:[function(a,b,c){b.exports="1.1.3"},{}],10:[function(a,b,c){a("./d3"),b.exports=d3,function(){delete this.d3}()},{}],11:[function(a,b,c){c.Digraph=a("graphlib").Digraph,c.Graph=a("graphlib").Graph,c.layout=a("./lib/layout"),c.version=a("./lib/version"),c.debug=a("./lib/debug")},{"./lib/debug":12,"./lib/layout":13,"./lib/version":28,graphlib:29}],12:[function(a,b,c){"use strict";var d=a("./util");c.dotOrdering=function(a){function e(b){var d=a.children(b);d.length?(c+="subgraph cluster_"+b+" {",c+='label="'+b+'";',d.forEach(function(a){e(a)}),c+="}"):(c+=b,a.node(b).dummy&&(c+=" [shape=diamond]"),c+=";")}var b=d.ordering(a.filterNodes(d.filterNonSubgraphs(a))),c="digraph {";return a.children(null).forEach(e),b.forEach(function(a){c+='subgraph { rank=same; edge [style="invis"];',c+=a.join("->"),c+="}"}),a.eachEdge(function(a,b,d){c+=b+"->"+d+";"}),c+="}",c}},{"./util":27}],13:[function(a,b,c){"use strict";var d=a("./util"),e=a("./rank"),f=a("./order"),g=a("graphlib").CGraph,h=a("graphlib").CDigraph;b.exports=function(){function j(a){var c=new h;a.eachNode(function(a,b){b===undefined&&(b={}),c.addNode(a,{width:b.width,height:b.height}),b.hasOwnProperty("rank")&&(c.node(a).prefRank=b.rank)}),a.parent&&a.nodes().forEach(function(b){c.parent(b,a.parent(b))}),a.eachEdge(function(a,b,d,e){e===undefined&&(e={});var f={e:a,minLen:e.minLen||1,width:e.width||0,height:e.height||0,points:[]};c.addEdge(null,b,d,f)});var d=a.graph()||{};return c.graph({rankDir:d.rankDir||b.rankDir,orderRestarts:d.orderRestarts}),c}function k(a){var g=i.rankSep(),h;try{return h=d.time("initLayoutGraph",j)(a),h.order()===0?h:(h.eachEdge(function(a,b,c,d){d.minLen*=2}),i.rankSep(g/2),d.time("rank.run",e.run)(h,b.rankSimplex),d.time("normalize",l)(h),d.time("order",f)(h,b.orderMaxSweeps),d.time("position",c.run)(h),d.time("undoNormalize",m)(h),d.time("fixupEdgePoints",n)(h),d.time("rank.restoreEdges",e.restoreEdges)(h),d.time("createFinalGraph",o)(h,a.isDirected()))}finally{i.rankSep(g)}}function l(a){var b=0;a.eachEdge(function(c,d,e,f){var g=a.node(d).rank,h=a.node(e).rank;if(g+10),d.log(2,"Order phase start cross count: "+a.graph().orderInitCC);var q,r,s;for(q=0,r=0;r<4&&q0;++q,++r,++j)m(a,h,q),s=e(a),s=0;--d)h(b[d],c,l(a,b[d].nodes()))}"use strict";var d=a("./util"),e=a("./order/crossCount"),f=a("./order/initLayerGraphs"),g=a("./order/initOrder"),h=a("./order/sortLayer");b.exports=j;var i=24;j.DEFAULT_MAX_SWEEPS=i},{"./order/crossCount":15,"./order/initLayerGraphs":16,"./order/initOrder":17,"./order/sortLayer":18,"./util":27}],15:[function(a,b,c){function e(a){var b=0,c=d.ordering(a);for(var e=1;e0)b%2&&(i+=g[b+1]),b=b-1>>1,++g[b]}),i}"use strict";var d=a("../util");b.exports=e},{"../util":27}],16:[function(a,b,c){function f(a){function c(d){if(d===null){a.children(d).forEach(function(a){c(a)});return}var f=a.node(d);f.minRank="rank"in f?f.rank:Number.MAX_VALUE,f.maxRank="rank"in f?f.rank:Number.MIN_VALUE;var h=new e;return a.children(d).forEach(function(b){var d=c(b);h=e.union([h,d]),f.minRank=Math.min(f.minRank,a.node(b).minRank),f.maxRank=Math.max(f.maxRank,a.node(b).maxRank)}),"rank"in f&&h.add(f.rank),h.keys().forEach(function(a){a in b||(b[a]=[]),b[a].push(d)}),h}var b=[];c(null);var f=[];return b.forEach(function(b,c){f[c]=a.filterNodes(d(b))}),f}"use strict";var d=a("graphlib").filter.nodesFromList,e=a("cp-data").Set;b.exports=f},{"cp-data":5,graphlib:29}],17:[function(a,b,c){function f(a,b){var c=[];a.eachNode(function(b,d){var e=c[d.rank];if(a.children&&a.children(b).length>0)return;e||(e=c[d.rank]=[]),e.push(b)}),c.forEach(function(c){b&&e.shuffle(c),c.forEach(function(b,c){a.node(b).order=c})});var f=d(a);a.graph().orderInitCC=f,a.graph().orderCC=Number.MAX_VALUE}"use strict";var d=a("./crossCount"),e=a("../util");b.exports=f},{"../util":27,"./crossCount":15}],18:[function(a,b,c){function h(a,b,c){c=n(a,c);var d=i(a,null,b,c);return d.list.forEach(function(b,c){a.node(b).order=c}),d.constraintGraph}function i(a,b,c,f){c=c?c.filterNodes(g(a.children(b))):new e;var h={};a.children(b).forEach(function(b){if(a.children(b).length)h[b]=i(a,b,c,f),h[b].firstSG=b,h[b].lastSG=b;else{var e=f[b];h[b]={degree:e.length,barycenter:d.sum(e)/e.length,order:a.node(b).order,orderCount:1,list:[b]}}}),l(a,c,h);var k=Object.keys(h);k.sort(function(a,b){return h[a].barycenter-h[b].barycenter||h[a].order-h[b].order});var m=k.map(function(a){return h[a]}).reduce(function(b,c){return j(a,b,c)});return m}function j(a,b,c){var d=k(b.constraintGraph,c.constraintGraph);return b.lastSG!==undefined&&c.firstSG!==undefined&&(d===undefined&&(d=new e),d.hasNode(b.lastSG)||d.addNode(b.lastSG),d.addNode(c.firstSG),d.addEdge(null,b.lastSG,c.firstSG)),{degree:b.degree+c.degree,barycenter:(b.barycenter*b.degree+c.barycenter*c.degree)/(b.degree+c.degree),order:(b.order*b.orderCount+c.order*c.orderCount)/(b.orderCount+c.orderCount),orderCount:b.orderCount+c.orderCount,list:b.list.concat(c.list),firstSG:b.firstSG!==undefined?b.firstSG:c.firstSG,lastSG:c.lastSG!==undefined?c.lastSG:b.lastSG,constraintGraph:d}}function k(a,b){return a===undefined?b:b===undefined?a:(a=a.copy(),b.nodes().forEach(function(b){a.addNode(b)}),b.edges().forEach(function(b,c,d){a.addEdge(null,c,d)}),a)}function l(a,b,c){function d(a,c,d){b.inEdges(a).forEach(function(a){b.delEdge(a),b.addEdge(null,b.source(a),d)}),b.outEdges(c).forEach(function(a){b.delEdge(a),b.addEdge(null,d,b.target(a))}),b.delNode(a),b.delNode(c)}var e;while((e=m(b,c))!==undefined){var f=b.source(e),g=b.target(e),h;while((h=b.addNode(null))&&a.hasNode(h))b.delNode(h);c[h]=j(a,c[f],c[g]),delete c[f],delete c[g],d(f,g,h),b.incidentEdges(h).length===0&&b.delNode(h)}}function m(a,b){var c=f(a);for(var d=0;d=b[e].barycenter)return i}}}function n(a,b){var c=Number.MAX_VALUE,e=0,f={};a.eachNode(function(f){if(a.children(f).length)return;var g=b[f];g.length&&(c=Math.min(c,d.min(g)),e=Math.max(e,d.max(g)))});var g=e-c;return a.eachNode(function(d){if(a.children(d).length)return;var e=b[d];e.length?f[d]=e.map(function(b){return g?(b-c)*(a.order()-1)/g:a.order()-.5}):f[d]=[a.node(d).order]}),f}"use strict";var d=a("../util"),e=a("graphlib").Digraph,f=a("graphlib").alg.topsort,g=a("graphlib").filter.nodesFromList;b.exports=h},{"../util":27,graphlib:29}],19:[function(a,b,c){"use strict";var d=a("./util");b.exports=function(){function c(b){b=b.filterNodes(d.filterNonSubgraphs(b));var c=d.ordering(b),e=f(b,c),i={};["u","d"].forEach(function(d){d==="d"&&c.reverse(),["l","r"].forEach(function(f){f==="r"&&m(c);var j=d+f,k=g(b,c,e,d==="u"?"predecessors":"successors");i[j]=h(b,c,k.pos,k.root,k.align),a.debugLevel>=3&&t(d+f,b,c,i[j]),f==="r"&&l(i[j]),f==="r"&&m(c)}),d==="d"&&c.reverse()}),k(b,c,i),b.eachNode(function(a){var c=[];for(var d in i){var e=i[d][a];r(d,b,a,e),c.push(e)}c.sort(function(a,b){return a-b}),q(b,a,(c[1]+c[2])/2)});var j=0,p=b.graph().rankDir==="BT"||b.graph().rankDir==="RL";c.forEach(function(c){var e=d.max(c.map(function(a){return o(b,a)}));j+=e/2,c.forEach(function(a){s(b,a,p?-j:j)}),j+=e/2+a.rankSep});var u=d.min(b.nodes().map(function(a){return q(b,a)-n(b,a)/2})),v=d.min(b.nodes().map(function(a){return s(b,a)-o(b,a)/2}));b.eachNode(function(a){q(b,a,q(b,a)-u),s(b,a,s(b,a)-v)})}function e(a,b){return aj)c[e(g[i],a)]=!0}var c={},d={},f,g,h,i,j;if(b.length<=2)return c;b[1].forEach(function(a,b){d[a]=b});for(var l=1;l0&&(j.sort(function(a,b){return f[a]-f[b]}),k=(j.length-1)/2,j.slice(Math.floor(k),Math.ceil(k)+1).forEach(function(a){h[b]===b&&!c[e(a,b)]&&i0){var h=e[j[d]];m(h),g[b]===b&&(g[b]=g[h]);var i=p(a,j[d])+p(a,d);g[b]!==g[h]?l(g[h],g[b],k[b]-k[h]-i):k[b]=Math.max(k[b],k[h]+i)}d=f[d]}while(d!==b)}}var g={},h={},i={},j={},k={};return b.forEach(function(a){a.forEach(function(b,c){g[b]=b,h[b]={},c>0&&(j[b]=a[c-1])})}),d.values(e).forEach(function(a){m(a)}),b.forEach(function(a){a.forEach(function(a){k[a]=k[e[a]];if(a===e[a]&&a===g[a]){var b=0;a in h&&Object.keys(h[a]).length>0&&(b=d.min(Object.keys(h[a]).map(function(b){return h[a][b]+(b in i?i[b]:0)}))),i[a]=b}})}),b.forEach(function(a){a.forEach(function(a){k[a]+=i[g[e[a]]]||0})}),k}function i(a,b,c){return d.min(b.map(function(a){var b=a[0];return c[b]}))}function j(a,b,c){return d.max(b.map(function(a){var b=a[a.length-1];return c[b]}))}function k(a,b,c){function h(a){c[l][a]+=g[l]}var d={},e={},f,g={},k=Number.POSITIVE_INFINITY;for(var l in c){var m=c[l];d[l]=i(a,b,m),e[l]=j(a,b,m);var n=e[l]-d[l];na.node(d).rank&&(a.delEdge(b),e.reversed=!0,a.addEdge(b,d,c,e))})}function r(a,b){var c=g(a);b&&(d.log(1,"Using network simplex for ranking"),i(a,c)),s(a)}function s(a){var b=d.min(a.nodes().map(function(b){return a.node(b).rank}));a.eachNode(function(a,c){c.rank-=b})}"use strict";var d=a("./util"),e=a("./rank/acyclic"),f=a("./rank/initRank"),g=a("./rank/feasibleTree"),h=a("./rank/constraints"),i=a("./rank/simplex"),j=a("graphlib").alg.components,k=a("graphlib").filter;c.run=l,c.restoreEdges=m},{"./rank/acyclic":21,"./rank/constraints":22,"./rank/feasibleTree":23,"./rank/initRank":24,"./rank/simplex":26,"./util":27,graphlib:29}],21:[function(a,b,c){function e(a){function f(d){if(d in c)return;c[d]=b[d]=!0,a.outEdges(d).forEach(function(c){var h=a.target(c),i;d===h?console.error('Warning: found self loop "'+c+'" for node "'+d+'"'):h in b?(i=a.edge(c),a.delEdge(c),i.reversed=!0,++e,a.addEdge(c,h,d,i)):f(h)}),delete b[d]}var b={},c={},e=0;return a.eachNode(function(a){f(a)}),d.log(2,"Acyclic Phase: reversed "+e+" edge(s)"),e}function f(a){a.eachEdge(function(b,c,d,e){e.reversed&&(delete e.reversed,a.delEdge(b),a.addEdge(b,d,c,e))})}"use strict";var d=a("../util");b.exports=e,b.exports.undo=f},{"../util":27}],22:[function(a,b,c){function d(a){return a!=="min"&&a!=="max"&&a.indexOf("same_")!==0?(console.error("Unsupported rank type: "+a),!1):!0}function e(a,b,c,d){a.inEdges(b).forEach(function(b){var e=a.edge(b),f;e.originalEdge?f=e:f={originalEdge:{e:b,u:a.source(b),v:a.target(b),value:e},minLen:a.edge(b).minLen},e.selfLoop&&(d=!1),d?(a.addEdge(null,c,a.source(b),f),f.reversed=!0):a.addEdge(null,a.source(b),c,f)})}function f(a,b,c,d){a.outEdges(b).forEach(function(b){var e=a.edge(b),f;e.originalEdge?f=e:f={originalEdge:{e:b,u:a.source(b),v:a.target(b),value:e},minLen:a.edge(b).minLen},e.selfLoop&&(d=!1),d?(a.addEdge(null,a.target(b),c,f),f.reversed=!0):a.addEdge(null,c,a.target(b),f)})}function g(a,b,c){c!==undefined&&a.children(b).forEach(function(b){b!==c&&!a.outEdges(c,b).length&&!a.node(b).dummy&&a.addEdge(null,c,b,{minLen:0})})}function h(a,b,c){c!==undefined&&a.children(b).forEach(function(b){b!==c&&!a.outEdges(b,c).length&&!a.node(b).dummy&&a.addEdge(null,b,c,{minLen:0})})}"use strict",c.apply=function(a){function b(c){var i={};a.children(c).forEach(function(g){if(a.children(g).length){b(g);return}var h=a.node(g),j=h.prefRank;if(j!==undefined){if(!d(j))return;j in i?i.prefRank.push(g):i.prefRank=[g];var k=i[j];k===undefined&&(k=i[j]=a.addNode(null,{originalNodes:[]}),a.parent(k,c)),e(a,g,k,j==="min"),f(a,g,k,j==="max"),a.node(k).originalNodes.push({u:g,value:h,parent:c}),a.delNode(g)}}),g(a,c,i.min),h(a,c,i.max)}b(null)},c.relax=function(a){var b=[];a.eachEdge(function(a,c,d,e){var f=e.originalEdge;f&&b.push(f)}),a.eachNode(function( +b,c){var d=c.originalNodes;d&&(d.forEach(function(b){b.value.rank=c.rank,a.addNode(b.u,b.value),a.parent(b.u,b.parent)}),a.delNode(b))}),b.forEach(function(b){a.addEdge(b.e,b.u,b.v,b.value)})}},{}],23:[function(a,b,c){function g(a){function g(d){var e=!0;return a.predecessors(d).forEach(function(f){b.has(f)&&!h(a,f,d)&&(b.has(d)&&(c.addNode(d,{}),b.remove(d),c.graph({root:d})),c.addNode(f,{}),c.addEdge(null,f,d,{reversed:!0}),b.remove(f),g(f),e=!1)}),a.successors(d).forEach(function(f){b.has(f)&&!h(a,d,f)&&(b.has(d)&&(c.addNode(d,{}),b.remove(d),c.graph({root:d})),c.addNode(f,{}),c.addEdge(null,d,f,{}),b.remove(f),g(f),e=!1)}),e}function i(){var d=Number.MAX_VALUE;b.keys().forEach(function(c){a.predecessors(c).forEach(function(e){if(!b.has(e)){var f=h(a,e,c);Math.abs(f)0)i=b.source(j[0]),j=b.inEdges(i);b.graph().root=i,b.addEdge(null,e,f,{cutValue:0}),g(a,b),n(a,b)}function n(a,b){function c(d){var e=b.successors(d);e.forEach(function(b){var e=o(a,d,b);a.node(b).rank=a.node(d).rank+e,c(b)})}c(b.graph().root)}function o(a,b,c){var e=a.outEdges(b,c);if(e.length>0)return d.max(e.map(function(b){return a.edge(b).minLen}));var f=a.inEdges(b,c);if(f.length>0)return-d.max(f.map(function(b){return a.edge(b).minLen}))}"use strict";var d=a("../util"),e=a("./rankUtil");b.exports=f},{"../util":27,"./rankUtil":25}],27:[function(a,b,c){function d(a,b){return function(){var c=(new Date).getTime();try{return b.apply(null,arguments)}finally{e(1,a+" time: "+((new Date).getTime()-c)+"ms")}}}function e(a){e.level>=a&&console.log.apply(console,Array.prototype.slice.call(arguments,1))}"use strict",c.min=function(a){return Math.min.apply(Math,a)},c.max=function(a){return Math.max.apply(Math,a)},c.all=function(a,b){for(var c=0;c0;--b){var c=Math.floor(Math.random()*(b+1)),d=a[c];a[c]=a[b],a[b]=d}},c.propertyAccessor=function(a,b,c,d){return function(e){return arguments.length?(b[c]=e,d&&d(e),a):b[c]}},c.ordering=function(a){var b=[];return a.eachNode(function(a,c){var d=b[c.rank]||(b[c.rank]=[]);d[c.order]=a}),b},c.filterNonSubgraphs=function(a){return function(b){return a.children(b).length===0}},d.enabled=!1,c.time=d,e.level=0,c.log=e},{}],28:[function(a,b,c){b.exports="0.4.6"},{}],29:[function(a,b,c){c.Graph=a("./lib/Graph"),c.Digraph=a("./lib/Digraph"),c.CGraph=a("./lib/CGraph"),c.CDigraph=a("./lib/CDigraph"),a("./lib/graph-converters"),c.alg={isAcyclic:a("./lib/alg/isAcyclic"),components:a("./lib/alg/components"),dijkstra:a("./lib/alg/dijkstra"),dijkstraAll:a("./lib/alg/dijkstraAll"),findCycles:a("./lib/alg/findCycles"),floydWarshall:a("./lib/alg/floydWarshall"),postorder:a("./lib/alg/postorder"),preorder:a("./lib/alg/preorder"),prim:a("./lib/alg/prim"),tarjan:a("./lib/alg/tarjan"),topsort:a("./lib/alg/topsort")},c.converter={json:a("./lib/converter/json.js")};var d=a("./lib/filter");c.filter={all:d.all,nodesFromList:d.nodesFromList},c.version=a("./lib/version")},{"./lib/CDigraph":31,"./lib/CGraph":32,"./lib/Digraph":33,"./lib/Graph":34,"./lib/alg/components":35,"./lib/alg/dijkstra":36,"./lib/alg/dijkstraAll":37,"./lib/alg/findCycles":38,"./lib/alg/floydWarshall":39,"./lib/alg/isAcyclic":40,"./lib/alg/postorder":41,"./lib/alg/preorder":42,"./lib/alg/prim":43,"./lib/alg/tarjan":44,"./lib/alg/topsort":45,"./lib/converter/json.js":47,"./lib/filter":48,"./lib/graph-converters":49,"./lib/version":51}],30:[function(a,b,c){function e(){this._value=undefined,this._nodes={},this._edges={},this._nextId=0}function f(a,b,c){(a[b]||(a[b]=new d)).add(c)}function g(a,b,c){var d=a[b];d.remove(c),d.size()===0&&delete a[b]}var d=a("cp-data").Set;b.exports=e,e.prototype.order=function(){return Object.keys(this._nodes).length},e.prototype.size=function(){return Object.keys(this._edges).length},e.prototype.graph=function(a){if(arguments.length===0)return this._value;this._value=a},e.prototype.hasNode=function(a){return a in this._nodes},e.prototype.node=function(a,b){var c=this._strictGetNode(a);if(arguments.length===1)return c.value;c.value=b},e.prototype.nodes=function(){var a=[];return this.eachNode(function(b){a.push(b)}),a},e.prototype.eachNode=function(a){for(var b in this._nodes){var c=this._nodes[b];a(c.id,c.value)}},e.prototype.hasEdge=function(a){return a in this._edges},e.prototype.edge=function(a,b){var c=this._strictGetEdge(a);if(arguments.length===1)return c.value;c.value=b},e.prototype.edges=function(){var a=[];return this.eachEdge(function(b){a.push(b)}),a},e.prototype.eachEdge=function(a){for(var b in this._edges){var c=this._edges[b];a(c.id,c.u,c.v,c.value)}},e.prototype.incidentNodes=function(a){var b=this._strictGetEdge(a);return[b.u,b.v]},e.prototype.addNode=function(a,b){if(a===undefined||a===null){do a="_"+ ++this._nextId;while(this.hasNode(a))}else if(this.hasNode(a))throw new Error("Graph already has node '"+a+"'");return this._nodes[a]={id:a,value:b},a},e.prototype.delNode=function(a){this._strictGetNode(a),this.incidentEdges(a).forEach(function(a){this.delEdge(a)},this),delete this._nodes[a]},e.prototype._addEdge=function(a,b,c,d,e,g){this._strictGetNode(b),this._strictGetNode(c);if(a===undefined||a===null){do a="_"+ ++this._nextId;while(this.hasEdge(a))}else if(this.hasEdge(a))throw new Error("Graph already has edge '"+a+"'");return this._edges[a]={id:a,u:b,v:c,value:d},f(e[c],b,a),f(g[b],c,a),a},e.prototype._delEdge=function(a,b,c){var d=this._strictGetEdge(a);g(b[d.v],d.u,a),g(c[d.u],d.v,a),delete this._edges[a]},e.prototype.copy=function(){var a=new this.constructor;return a.graph(this.graph()),this.eachNode(function(b,c){a.addNode(b,c)}),this.eachEdge(function(b,c,d,e){a.addEdge(b,c,d,e)}),a._nextId=this._nextId,a},e.prototype.filterNodes=function(a){var b=new this.constructor;return b.graph(this.graph()),this.eachNode(function(c,d){a(c)&&b.addNode(c,d)}),this.eachEdge(function(a,c,d,e){b.hasNode(c)&&b.hasNode(d)&&b.addEdge(a,c,d,e)}),b},e.prototype._strictGetNode=function(a){var b=this._nodes[a];if(b===undefined)throw new Error("Node '"+a+"' is not in graph");return b},e.prototype._strictGetEdge=function(a){var b=this._edges[a];if(b===undefined)throw new Error("Edge '"+a+"' is not in graph");return b}},{"cp-data":5}],31:[function(a,b,c){var d=a("./Digraph"),e=a("./compoundify"),f=e(d);b.exports=f,f.fromDigraph=function(a){var b=new f,c=a.graph();return c!==undefined&&b.graph(c),a.eachNode(function(a,c){c===undefined?b.addNode(a):b.addNode(a,c)}),a.eachEdge(function(a,c,d,e){e===undefined?b.addEdge(null,c,d):b.addEdge(null,c,d,e)}),b},f.prototype.toString=function(){return"CDigraph "+JSON.stringify(this,null,2)}},{"./Digraph":33,"./compoundify":46}],32:[function(a,b,c){var d=a("./Graph"),e=a("./compoundify"),f=e(d);b.exports=f,f.fromGraph=function(a){var b=new f,c=a.graph();return c!==undefined&&b.graph(c),a.eachNode(function(a,c){c===undefined?b.addNode(a):b.addNode(a,c)}),a.eachEdge(function(a,c,d,e){e===undefined?b.addEdge(null,c,d):b.addEdge(null,c,d,e)}),b},f.prototype.toString=function(){return"CGraph "+JSON.stringify(this,null,2)}},{"./Graph":34,"./compoundify":46}],33:[function(a,b,c){function g(){e.call(this),this._inEdges={},this._outEdges={}}var d=a("./util"),e=a("./BaseGraph"),f=a("cp-data").Set;b.exports=g,g.prototype=new e,g.prototype.constructor=g,g.prototype.isDirected=function(){return!0},g.prototype.successors=function(a){return this._strictGetNode(a),Object.keys(this._outEdges[a]).map(function(a){return this._nodes[a].id},this)},g.prototype.predecessors=function(a){return this._strictGetNode(a),Object.keys(this._inEdges[a]).map(function(a){return this._nodes[a].id},this)},g.prototype.neighbors=function(a){return f.union([this.successors(a),this.predecessors(a)]).keys()},g.prototype.sources=function(){var a=this;return this._filterNodes(function(b){return a.inEdges(b).length===0})},g.prototype.sinks=function(){var a=this;return this._filterNodes(function(b){return a.outEdges(b).length===0})},g.prototype.source=function(a){return this._strictGetEdge(a).u},g.prototype.target=function(a){return this._strictGetEdge(a).v},g.prototype.inEdges=function(a,b){this._strictGetNode(a);var c=f.union(d.values(this._inEdges[a])).keys();return arguments.length>1&&(this._strictGetNode(b),c=c.filter(function(a){return this.source(a)===b},this)),c},g.prototype.outEdges=function(a,b){this._strictGetNode(a);var c=f.union(d.values(this._outEdges[a])).keys();return arguments.length>1&&(this._strictGetNode(b),c=c.filter(function(a){return this.target(a)===b},this)),c},g.prototype.incidentEdges=function(a,b){return arguments.length>1?f.union([this.outEdges(a,b),this.outEdges(b,a)]).keys():f.union([this.inEdges(a),this.outEdges(a)]).keys()},g.prototype.toString=function(){return"Digraph "+JSON.stringify(this,null,2)},g.prototype.addNode=function(a,b){return a=e.prototype.addNode.call(this,a,b),this._inEdges[a]={},this._outEdges[a]={},a},g.prototype.delNode=function(a){e.prototype.delNode.call(this,a),delete this._inEdges[a],delete this._outEdges[a]},g.prototype.addEdge=function(a,b,c,d){return e.prototype._addEdge.call(this,a,b,c,d,this._inEdges,this._outEdges)},g.prototype.delEdge=function(a){e.prototype._delEdge.call(this,a,this._inEdges,this._outEdges)},g.prototype._filterNodes=function(a){var b=[];return this.eachNode(function(c){a(c)&&b.push(c)}),b}},{"./BaseGraph":30,"./util":50,"cp-data":5}],34:[function(a,b,c){function g(){e.call(this),this._incidentEdges={}}var d=a("./util"),e=a("./BaseGraph"),f=a("cp-data").Set;b.exports=g,g.prototype=new e,g.prototype.constructor=g,g.prototype.isDirected=function(){return!1},g.prototype.neighbors=function(a){return this._strictGetNode(a),Object.keys(this._incidentEdges[a]).map(function(a){return this._nodes[a].id},this)},g.prototype.incidentEdges=function(a,b){return this._strictGetNode(a),arguments.length>1?(this._strictGetNode(b),b in this._incidentEdges[a]?this._incidentEdges[a][b].keys():[]):f.union(d.values(this._incidentEdges[a])).keys()},g.prototype.toString=function(){return"Graph "+JSON.stringify(this,null,2)},g.prototype.addNode=function(a,b){return a=e.prototype.addNode.call(this,a,b),this._incidentEdges[a]={},a},g.prototype.delNode=function(a){e.prototype.delNode.call(this,a),delete this._incidentEdges[a]},g.prototype.addEdge=function(a,b,c,d){return e.prototype._addEdge.call(this,a,b,c,d,this._incidentEdges,this._incidentEdges)},g.prototype.delEdge=function(a){e.prototype._delEdge.call(this,a,this._incidentEdges,this._incidentEdges)}},{"./BaseGraph":30,"./util":50,"cp-data":5}],35:[function(a,b,c){function e(a){function e(b,d){c.has(b)||(c.add(b),d.push(b),a.neighbors(b).forEach(function(a){e(a,d)}))}var b=[],c=new d;return a.nodes().forEach(function(a){var c=[];e(a,c),c.length>0&&b.push(c)}),b}var d=a("cp-data").Set;b.exports=e},{"cp-data":5}],36:[function(a,b,c){function e(a,b,c,e){function h(b){var d=a.incidentNodes(b),e=d[0]!==i?d[0]:d[1],h=f[e],k=c(b),l=j.distance+k;if(k<0)throw new Error("dijkstra does not allow negative edge weights. Bad edge: "+b+" Weight: "+k);l0){i=g.removeMin(),j=f[i];if(j.distance===Number.POSITIVE_INFINITY)break;e(i).forEach(h)}return f}var d=a("cp-data").PriorityQueue;b.exports=e},{"cp-data":5}],37:[function(a,b,c){function e(a,b,c){var e={};return a.eachNode(function(f){e[f]=d(a,f,b,c)}),e}var d=a("./dijkstra");b.exports=e},{"./dijkstra":36}],38:[function(a,b,c){function e(a){return d(a).filter(function(a){return a.length>1})}var d=a("./tarjan");b.exports=e},{"./tarjan":44}],39:[function(a,b,c){function d(a,b,c){var d={},e=a.nodes();return b=b||function(){return 1},c=c||(a.isDirected()?function(b){return a.outEdges(b)}:function(b){return a.incidentEdges(b)}),e.forEach(function(f){d[f]={},d[f][f]={distance:0},e.forEach(function(a){f!==a&&(d[f][a]={distance:Number.POSITIVE_INFINITY})}),c(f).forEach(function(c){var e=a.incidentNodes(c),h=e[0]!==f?e[0]:e[1],i=b(c);i0){h=g.removeMin();if(h in f)c.addEdge(null,h,f[h]);else{if(j)throw new Error("Input graph is not connected: "+a);j=!0}a.incidentEdges(h).forEach(i)}return c}var d=a("../Graph"),e=a("cp-data").PriorityQueue;b.exports=f},{"../Graph":34,"cp-data":5}],44:[function(a,b,c){function d(a){function f(h){var i=d[h]={onStack:!0,lowlink:b,index:b++};c.push(h),a.successors(h).forEach(function(a){a in d?d[a].onStack&&(i.lowlink=Math.min(i.lowlink,d[a].index)):(f(a),i.lowlink=Math.min(i.lowlink,d[a].lowlink))});if(i.lowlink===i.index){var j=[],k;do k=c.pop(),d[k].onStack=!1,j.push(k);while(h!==k);e.push(j)}}if(!a.isDirected())throw new Error("tarjan can only be applied to a directed graph. Bad input: "+a);var b=0,c=[],d={},e=[];return a.nodes().forEach(function(a){a in d||f(a)}),e}b.exports=d},{}],45:[function(a,b,c){function d(a){function f(g){if(g in c)throw new e;g in b||(c[g]=!0,b[g]=!0,a.predecessors(g).forEach(function(a){f(a)}),delete c[g],d.push(g))}if(!a.isDirected())throw new Error("topsort can only be applied to a directed graph. Bad input: "+a);var b={},c={},d=[],g=a.sinks();if(a.order()!==0&&g.length===0)throw new e;return a.sinks().forEach(function(a){f(a)}),d}function e(){}b.exports=d,d.CycleException=e,e.prototype.toString=function(){return"Graph has at least one cycle"}},{}],46:[function(a,b,c){function e(a){function b(){a.call(this),this._parents={},this._children={},this._children[null]=new d}return b.prototype=new a,b.prototype.constructor=b,b.prototype.parent=function(a,b){this._strictGetNode(a);if(arguments.length<2)return this._parents[a];if(a===b)throw new Error("Cannot make "+a+" a parent of itself");b!==null&&this._strictGetNode(b),this._children[this._parents[a]].remove(a),this._parents[a]=b,this._children[b].add(a)},b.prototype.children=function(a){return a!==null&&this._strictGetNode(a),this._children[a].keys()},b.prototype.addNode=function(b,c){return b=a.prototype.addNode.call(this,b,c),this._parents[b]=null,this._children[b]=new d,this._children[null].add(b),b},b.prototype.delNode=function(b){var c=this.parent(b);return this._children[b].keys().forEach(function(a){this.parent(a,c)},this),this._children[c].remove(b),delete this._parents[b],delete this._children[b],a.prototype.delNode.call(this,b)},b.prototype.copy=function(){var b=a.prototype.copy.call(this);return this.nodes().forEach(function(a){b.parent(a,this.parent(a))},this),b},b.prototype.filterNodes=function(b){function f(a){var b=c.parent(a);return b===null||d.hasNode(b)?(e[a]=b,b):b in e?e[b]:f(b)}var c=this,d=a.prototype.filterNodes.call(this,b),e={};return d.eachNode(function(a){d.parent(a,f(a))}),d},b}var d=a("cp-data").Set;b.exports=e},{"cp-data":5}],47:[function(a,b,c){function h(a){return Object.prototype.toString.call(a).slice(8,-1)}var d=a("../Graph"),e=a("../Digraph"),f=a("../CGraph"),g=a("../CDigraph");c.decode=function(a,b,c){c=c||e;if(h(a)!=="Array")throw new Error("nodes is not an Array");if(h(b)!=="Array")throw new Error("edges is not an Array");if(typeof c=="string")switch(c){case"graph":c=d;break;case"digraph":c=e;break;case"cgraph":c=f;break;case"cdigraph":c=g;break;default:throw new Error("Unrecognized graph type: "+c)}var i=new c;return a.forEach(function(a){i.addNode(a.id,a.value)}),i.parent&&a.forEach(function(a){a.children&&a.children.forEach(function(b){i.parent(b,a.id)})}),b.forEach(function(a){i.addEdge(a.id,a.u,a.v,a.value)}),i},c.encode=function(a){var b=[],c=[];a.eachNode(function(c,d){var e={id:c,value:d};if(a.children){var f=a.children(c);f.length&&(e.children=f)}b.push(e)}),a.eachEdge(function(a,b,d,e){c.push({id:a,u:b,v:d,value:e})});var h;if(a instanceof g)h="cdigraph";else if(a instanceof f)h="cgraph";else if(a instanceof e)h="digraph";else if(a instanceof d)h="graph";else throw new Error("Couldn't determine type of graph: "+a);return{nodes:b,edges:c,type:h}}},{"../CDigraph":31,"../CGraph":32,"../Digraph":33,"../Graph":34}],48:[function(a,b,c){var d=a("cp-data").Set;c.all=function(){return function(){return!0}},c.nodesFromList=function(a){var b=new d(a);return function(a){return b.has(a)}}},{"cp-data":5}],49:[function(a,b,c){var d=a("./Graph"),e=a("./Digraph");d.prototype.toDigraph=d.prototype.asDirected=function(){var a=new e;return this.eachNode(function(b,c){a.addNode(b,c)}),this.eachEdge(function(b,c,d,e){a.addEdge(null,c,d,e),a.addEdge(null,d,c,e)}),a},e.prototype.toGraph=e.prototype.asUndirected=function(){var a=new d;return this.eachNode(function(b,c){a.addNode(b,c)}),this.eachEdge(function(b,c,d,e){a.addEdge(b,c,d,e)}),a}},{"./Digraph":33,"./Graph":34}],50:[function(a,b,c){c.values=function(a){var b=Object.keys(a),c=b.length,d=new Array(c),e;for(e=0;e tr > th { + padding-left: 18px; + padding-right: 18px; +} + +table.dataTable th:active { + outline: none; +} + +/* Scrolling */ +div.dataTables_scrollHead table { + margin-bottom: 0 !important; + border-bottom-left-radius: 0; + border-bottom-right-radius: 0; +} + +div.dataTables_scrollHead table thead tr:last-child th:first-child, +div.dataTables_scrollHead table thead tr:last-child td:first-child { + border-bottom-left-radius: 0 !important; + border-bottom-right-radius: 0 !important; +} + +div.dataTables_scrollBody table { + border-top: none; + margin-top: 0 !important; + margin-bottom: 0 !important; +} + +div.dataTables_scrollBody tbody tr:first-child th, +div.dataTables_scrollBody tbody tr:first-child td { + border-top: none; +} + +div.dataTables_scrollFoot table { + margin-top: 0 !important; + border-top: none; +} + +/* Frustratingly the border-collapse:collapse used by Bootstrap makes the column + width calculations when using scrolling impossible to align columns. We have + to use separate + */ +table.table-bordered.dataTable { + border-collapse: separate !important; +} +table.table-bordered thead th, +table.table-bordered thead td { + border-left-width: 0; + border-top-width: 0; +} +table.table-bordered tbody th, +table.table-bordered tbody td { + border-left-width: 0; + border-bottom-width: 0; +} +table.table-bordered th:last-child, +table.table-bordered td:last-child { + border-right-width: 0; +} +div.dataTables_scrollHead table.table-bordered { + border-bottom-width: 0; +} + + + + +/* + * TableTools styles + */ +.table.dataTable tbody tr.active td, +.table.dataTable tbody tr.active th { + background-color: #08C; + color: white; +} + +.table.dataTable tbody tr.active:hover td, +.table.dataTable tbody tr.active:hover th { + background-color: #0075b0 !important; +} + +.table.dataTable tbody tr.active th > a, +.table.dataTable tbody tr.active td > a { + color: white; +} + +.table-striped.dataTable tbody tr.active:nth-child(odd) td, +.table-striped.dataTable tbody tr.active:nth-child(odd) th { + background-color: #017ebc; +} + +table.DTTT_selectable tbody tr { + cursor: pointer; +} + +div.DTTT .btn:hover { + text-decoration: none !important; +} + +ul.DTTT_dropdown.dropdown-menu { + z-index: 2003; +} + +ul.DTTT_dropdown.dropdown-menu a { + color: #333 !important; /* needed only when demo_page.css is included */ +} + +ul.DTTT_dropdown.dropdown-menu li { + position: relative; +} + +ul.DTTT_dropdown.dropdown-menu li:hover a { + background-color: #0088cc; + color: white !important; +} + +div.DTTT_collection_background { + z-index: 2002; +} + +/* TableTools information display */ +div.DTTT_print_info { + position: fixed; + top: 50%; + left: 50%; + width: 400px; + height: 150px; + margin-left: -200px; + margin-top: -75px; + text-align: center; + color: #333; + padding: 10px 30px; + opacity: 0.95; + + background-color: white; + border: 1px solid rgba(0, 0, 0, 0.2); + border-radius: 6px; + + -webkit-box-shadow: 0 3px 7px rgba(0, 0, 0, 0.5); + box-shadow: 0 3px 7px rgba(0, 0, 0, 0.5); +} + +div.DTTT_print_info h6 { + font-weight: normal; + font-size: 28px; + line-height: 28px; + margin: 1em; +} + +div.DTTT_print_info p { + font-size: 14px; + line-height: 20px; +} + +div.dataTables_processing { + position: absolute; + top: 50%; + left: 50%; + width: 100%; + height: 60px; + margin-left: -50%; + margin-top: -25px; + padding-top: 20px; + padding-bottom: 20px; + text-align: center; + font-size: 1.2em; + background-color: white; + background: -webkit-gradient(linear, left top, right top, color-stop(0%, rgba(255,255,255,0)), color-stop(25%, rgba(255,255,255,0.9)), color-stop(75%, rgba(255,255,255,0.9)), color-stop(100%, rgba(255,255,255,0))); + background: -webkit-linear-gradient(left, rgba(255,255,255,0) 0%, rgba(255,255,255,0.9) 25%, rgba(255,255,255,0.9) 75%, rgba(255,255,255,0) 100%); + background: -moz-linear-gradient(left, rgba(255,255,255,0) 0%, rgba(255,255,255,0.9) 25%, rgba(255,255,255,0.9) 75%, rgba(255,255,255,0) 100%); + background: -ms-linear-gradient(left, rgba(255,255,255,0) 0%, rgba(255,255,255,0.9) 25%, rgba(255,255,255,0.9) 75%, rgba(255,255,255,0) 100%); + background: -o-linear-gradient(left, rgba(255,255,255,0) 0%, rgba(255,255,255,0.9) 25%, rgba(255,255,255,0.9) 75%, rgba(255,255,255,0) 100%); + background: linear-gradient(to right, rgba(255,255,255,0) 0%, rgba(255,255,255,0.9) 25%, rgba(255,255,255,0.9) 75%, rgba(255,255,255,0) 100%); +} + + + +/* + * FixedColumns styles + */ +div.DTFC_LeftHeadWrapper table, +div.DTFC_LeftFootWrapper table, +div.DTFC_RightHeadWrapper table, +div.DTFC_RightFootWrapper table, +table.DTFC_Cloned tr.even { + background-color: white; + margin-bottom: 0; +} + +div.DTFC_RightHeadWrapper table , +div.DTFC_LeftHeadWrapper table { + border-bottom: none !important; + margin-bottom: 0 !important; + border-top-right-radius: 0 !important; + border-bottom-left-radius: 0 !important; + border-bottom-right-radius: 0 !important; +} + +div.DTFC_RightHeadWrapper table thead tr:last-child th:first-child, +div.DTFC_RightHeadWrapper table thead tr:last-child td:first-child, +div.DTFC_LeftHeadWrapper table thead tr:last-child th:first-child, +div.DTFC_LeftHeadWrapper table thead tr:last-child td:first-child { + border-bottom-left-radius: 0 !important; + border-bottom-right-radius: 0 !important; +} + +div.DTFC_RightBodyWrapper table, +div.DTFC_LeftBodyWrapper table { + border-top: none; + margin: 0 !important; +} + +div.DTFC_RightBodyWrapper tbody tr:first-child th, +div.DTFC_RightBodyWrapper tbody tr:first-child td, +div.DTFC_LeftBodyWrapper tbody tr:first-child th, +div.DTFC_LeftBodyWrapper tbody tr:first-child td { + border-top: none; +} + +div.DTFC_RightFootWrapper table, +div.DTFC_LeftFootWrapper table { + border-top: none; + margin-top: 0 !important; +} + + +/* + * FixedHeader styles + */ +div.FixedHeader_Cloned table { + margin: 0 !important +} + diff --git a/airflow/www/static/docs b/airflow/www/static/docs new file mode 120000 index 0000000000000..d5d38018e2486 --- /dev/null +++ b/airflow/www/static/docs @@ -0,0 +1 @@ +../../../docs/_build/html/ \ No newline at end of file diff --git a/airflow/www/static/favicon.ico b/airflow/www/static/favicon.ico new file mode 100644 index 0000000000000000000000000000000000000000..822e6d726205d88293123c43a374ab0f315b0729 GIT binary patch literal 1406 zcmeH{O%4G;5QSeGrU!%RUf8m936ZcCHnt*G5(#!B4&jL0K&(bW!t0(NElwbrntVO= zUb?E1S~MW|MG<%gSXF>FfF7ZRE-}6hk^Fx0Ih`HZkUi;>2^o_i*^&d9k|kNQr(m-M z^AS0c87ZRJK(T>8+<@;#d^hsYUP}g!A(W@3c}>y?mEw&|0lKVI!~baLFw~B0n%o}N zOtMth{cMmmbXCaIusT#U(pJXMHC4(c*;1WPa!PH1TWSoq)Lt;9&jE1nd}F-F8Hma0 P3P?jtZqygxl|lUm5)UBE literal 0 HcmV?d00001 diff --git a/airflow/www/static/gantt-chart-d3v2.js b/airflow/www/static/gantt-chart-d3v2.js new file mode 100644 index 0000000000000..d21311a1c541d --- /dev/null +++ b/airflow/www/static/gantt-chart-d3v2.js @@ -0,0 +1,267 @@ +/** +* Licensed to the Apache Software Foundation (ASF) under one +* or more contributor license agreements. See the NOTICE file +* distributed with this work for additional information +* regarding copyright ownership. The ASF licenses this file +* to you under the Apache License, Version 2.0 (the +* "License"); you may not use this file except in compliance +* with the License. You may obtain a copy of the License at +* +* http://www.apache.org/licenses/LICENSE-2.0 +* +* Unless required by applicable law or agreed to in writing, +* software distributed under the License is distributed on an +* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +* KIND, either express or implied. See the License for the +* specific language governing permissions and limitations +* under the License. +*/ + +/** + * @author Dimitry Kudrayvtsev + * @version 2.1 + * @modifiedby Maxime Beauchemin + */ + +d3.gantt = function() { + var FIT_TIME_DOMAIN_MODE = "fit"; + var FIXED_TIME_DOMAIN_MODE = "fixed"; + var tip = d3.tip() + .attr('class', 'd3-tip') + .offset([-10, 0]) + .html(function(d) { + var s = "" + s += "
    "; + s += "
    start:
    end:
    duration:
    " + s += "
    " + s += d.isoStart + "
    "; + s += d.isoEnd + "
    "; + s += d.duration + "
    "; + s += "
    "; + s += "
    "; + return s; + }) + + var margin = { + top : 20, + right : 40, + bottom : 20, + left : 150 + }; + var yAxisLeftOffset = 220; + var selector = 'body'; + var timeDomainStart = d3.time.day.offset(new Date(),-3); + var timeDomainEnd = d3.time.hour.offset(new Date(),+3); + var timeDomainMode = FIT_TIME_DOMAIN_MODE;// fixed or fit + var taskTypes = []; + var taskStatus = []; + var height = document.body.clientHeight - margin.top - margin.bottom-5; + var width = $('.gantt').width() - margin.right - margin.left-5; + + var tickFormat = "%H:%M"; + + var keyFunction = function(d) { + return d.startDate + d.taskName + d.endDate; + }; + + var rectTransform = function(d) { + return "translate(" + (x(d.startDate) + yAxisLeftOffset) + "," + y(d.taskName) + ")"; + }; + + var x = d3.time.scale().domain([ timeDomainStart, timeDomainEnd ]).range([ 0, (width-yAxisLeftOffset) ]).clamp(true); + + var y = d3.scale.ordinal().domain(taskTypes).rangeRoundBands([ 0, height - margin.top - margin.bottom ], .1); + + var xAxis = d3.svg.axis().scale(x).orient("bottom").tickFormat(d3.time.format(tickFormat)).tickSubdivide(true) + .tickSize(8).tickPadding(8); + + var yAxis = d3.svg.axis().scale(y).orient("left").tickSize(0); + + var initTimeDomain = function(tasks) { + if (timeDomainMode === FIT_TIME_DOMAIN_MODE) { + if (tasks === undefined || tasks.length < 1) { + timeDomainStart = d3.time.day.offset(new Date(), -3); + timeDomainEnd = d3.time.hour.offset(new Date(), +3); + return; + } + tasks.sort(function(a, b) { + return a.endDate - b.endDate; + }); + timeDomainEnd = tasks[tasks.length - 1].endDate; + tasks.sort(function(a, b) { + return a.startDate - b.startDate; + }); + timeDomainStart = tasks[0].startDate; + } + }; + + var initAxis = function() { + x = d3.time.scale().domain([ timeDomainStart, timeDomainEnd ]).range([ 0, width-yAxisLeftOffset ]).clamp(true); + y = d3.scale.ordinal().domain(taskTypes).rangeRoundBands([ 0, height - margin.top - margin.bottom ], .1); + xAxis = d3.svg.axis().scale(x).orient("bottom").tickFormat(d3.time.format(tickFormat)).tickSubdivide(true) + .tickSize(8).tickPadding(8); + + yAxis = d3.svg.axis().scale(y).orient("left").tickSize(0); + }; + + function gantt(tasks) { + + initTimeDomain(tasks); + initAxis(); + + var svg = d3.select(selector) + .append("svg") + .attr("class", "chart") + .attr("width", width + margin.left + margin.right) + .attr("height", height + margin.top + margin.bottom) + .append("g") + .attr("class", "gantt-chart") + .attr("width", width + margin.left + margin.right) + .attr("height", height + margin.top + margin.bottom) + .attr("transform", "translate(" + margin.left + ", " + margin.top + ")"); + + svg.selectAll(".chart") + .data(tasks, keyFunction).enter() + .append("rect") + .on('mouseover', tip.show) + .on('mouseout', tip.hide) + .on('click', function(d) { + call_modal(d.taskName, d.executionDate); + }) + .attr("class", function(d){ + if(taskStatus[d.status] == null){ return "bar";} + return taskStatus[d.status]; + }) + .attr("y", 0) + .attr("transform", rectTransform) + .attr("height", function(d) { return y.rangeBand(); }) + .attr("width", function(d) { + return d3.max([x(d.endDate) - x(d.startDate), 1]); + }); + + + svg.append("g") + .attr("class", "x axis") + .attr("transform", "translate(" + yAxisLeftOffset + ", " + (height - margin.top - margin.bottom) + ")") + .transition() + .call(xAxis); + + svg.append("g").attr("class", "y axis").transition().attr("transform", "translate(" + yAxisLeftOffset + ", 0)").call(yAxis); + svg.call(tip); + + return gantt; + + }; + + gantt.redraw = function(tasks) { + + initTimeDomain(tasks); + initAxis(); + + var svg = d3.select(".chart"); + + var ganttChartGroup = svg.select(".gantt-chart"); + var rect = ganttChartGroup.selectAll("rect").data(tasks, keyFunction); + + rect.enter() + .insert("rect",":first-child") + .attr("rx", 5) + .attr("ry", 5) + .attr("class", function(d){ + if(taskStatus[d.status] == null){ return "bar";} + return taskStatus[d.status]; + }) + .transition() + .attr("y", 0) + .attr("transform", rectTransform) + .attr("height", function(d) { return y.rangeBand(); }) + .attr("width", function(d) { + return (x(d.endDate) - x(d.startDate)); + }); + + rect.transition() + .attr("transform", rectTransform) + .attr("height", function(d) { return y.rangeBand(); }) + .attr("width", function(d) { + return (x(d.endDate) - x(d.startDate)); + }); + + rect.exit().remove(); + + svg.select(".x").transition().call(xAxis); + svg.select(".y").transition().call(yAxis); + + return gantt; + }; + + gantt.margin = function(value) { + if (!arguments.length) + return margin; + margin = value; + return gantt; + }; + + gantt.timeDomain = function(value) { + if (!arguments.length) + return [ timeDomainStart, timeDomainEnd ]; + timeDomainStart = +value[0], timeDomainEnd = +value[1]; + return gantt; + }; + + /** + * @param {string} + * vale The value can be "fit" - the domain fits the data or + * "fixed" - fixed domain. + */ + gantt.timeDomainMode = function(value) { + if (!arguments.length) + return timeDomainMode; + timeDomainMode = value; + return gantt; + + }; + + gantt.taskTypes = function(value) { + if (!arguments.length) + return taskTypes; + taskTypes = value; + return gantt; + }; + + gantt.taskStatus = function(value) { + if (!arguments.length) + return taskStatus; + taskStatus = value; + return gantt; + }; + + gantt.width = function(value) { + if (!arguments.length) + return width; + width = +value; + return gantt; + }; + + gantt.height = function(value) { + if (!arguments.length) + return height; + height = +value; + return gantt; + }; + + gantt.tickFormat = function(value) { + if (!arguments.length) + return tickFormat; + tickFormat = value; + return gantt; + }; + + gantt.selector = function(value) { + if (!arguments.length) + return selector; + selector = value; + return gantt; + }; + + return gantt; +}; diff --git a/airflow/www/static/gantt.css b/airflow/www/static/gantt.css new file mode 100644 index 0000000000000..87147059dc0b2 --- /dev/null +++ b/airflow/www/static/gantt.css @@ -0,0 +1,57 @@ +/** +* Licensed to the Apache Software Foundation (ASF) under one +* or more contributor license agreements. See the NOTICE file +* distributed with this work for additional information +* regarding copyright ownership. The ASF licenses this file +* to you under the Apache License, Version 2.0 (the +* "License"); you may not use this file except in compliance +* with the License. You may obtain a copy of the License at +* +* http://www.apache.org/licenses/LICENSE-2.0 +* +* Unless required by applicable law or agreed to in writing, +* software distributed under the License is distributed on an +* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +* KIND, either express or implied. See the License for the +* specific language governing permissions and limitations +* under the License. +*/ + +.axis path,.axis line { + fill: none; + stroke: #000; + shape-rendering: crispEdges; +} +rect { + stroke: black; + cursor: pointer; +} +.d3-tip { + line-height: 1; + font-weight: bold; + padding: 5px; + background: rgba(0, 0, 0, 0.8); + color: #fff; + border-radius: 2px; + font-size: 12px; +} + +/* Creates a small triangle extender for the tooltip */ +.d3-tip:after { + box-sizing: border-box; + display: inline; + font-size: 8px; + width: 100%; + line-height: 1; + color: rgba(0, 0, 0, 0.8); + content: "\25BC"; + position: absolute; + text-align: center; +} + +/* Style northward tooltips differently */ +.d3-tip.n:after { + margin: -1px 0 0 0; + top: 100%; + left: 0; +} diff --git a/airflow/www/static/graph.css b/airflow/www/static/graph.css new file mode 100644 index 0000000000000..f1d34800612fa --- /dev/null +++ b/airflow/www/static/graph.css @@ -0,0 +1,72 @@ +/** +* Licensed to the Apache Software Foundation (ASF) under one +* or more contributor license agreements. See the NOTICE file +* distributed with this work for additional information +* regarding copyright ownership. The ASF licenses this file +* to you under the Apache License, Version 2.0 (the +* "License"); you may not use this file except in compliance +* with the License. You may obtain a copy of the License at +* +* http://www.apache.org/licenses/LICENSE-2.0 +* +* Unless required by applicable law or agreed to in writing, +* software distributed under the License is distributed on an +* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +* KIND, either express or implied. See the License for the +* specific language governing permissions and limitations +* under the License. +*/ + +g.node rect { + stroke-width: 3; + stroke: white; + cursor: pointer; +} +g.node text { + cursor: pointer; +} +g.node.success rect { + stroke: green; +} +g.node.up_for_retry rect { + stroke: gold; +} + +g.node.queued rect { + stroke: grey; +} + +g.node.running rect{ + stroke: lime; +} +g.node.failed rect { + stroke: red; +} +g.node.shutdown rect{ + stroke: blue; +} +g.node.upstream_failed rect{ + stroke: orange; +} +g.node.skipped rect{ + stroke: pink; +} +div#svg_container { + border: 1px solid black; + background-color: #EEE; + cursor: move; +} + +#refresh_button { + top: 15px; + right: 15px; + position: relative; +} + +#loading { + margin: auto; + display: none; + position: relative; + width: 100px; + top: -550px; +} diff --git a/airflow/www/static/jqClock.min.js b/airflow/www/static/jqClock.min.js new file mode 100644 index 0000000000000..5528efd186774 --- /dev/null +++ b/airflow/www/static/jqClock.min.js @@ -0,0 +1,27 @@ +/** + * jQuery Clock plugin + * Copyright (c) 2010 John R D'Orazio (priest@johnromanodorazio.com) + * Licensed under the Apache 2.0 license: + * https://www.apache.org/licenses/LICENSE-2.0 + */ +Date.prototype.hasOwnProperty("stdTimezoneOffset")||(Date.prototype.stdTimezoneOffset=function(){var b=this.getFullYear();if(!Date.prototype.stdTimezoneOffset.cache.hasOwnProperty(b)){for(var d=(new Date(b,0,1)).getTimezoneOffset(),h=[6,7,5,8,4,9,3,10,2,11,1],a=0;12>a;a++){var m=(new Date(b,h[a],1)).getTimezoneOffset();if(m!=d){d=Math.max(d,m);break}}Date.prototype.stdTimezoneOffset.cache[b]=d}return Date.prototype.stdTimezoneOffset.cache[b]},Date.prototype.stdTimezoneOffset.cache={}); +Date.prototype.hasOwnProperty("isDST")||(Date.prototype.isDST=function(){return this.getTimezoneOffset()>=0;d=String(d||" ");if(this.length>b)return String(this);b-=this.length;b>d.length&&(d+=d.repeat(b/d.length));return d.slice(0,b)+String(this)}); +(function(b,d){b.clock={version:"2.3.0",options:[{type:"string",value:"destroy",description:"Passing in 'destroy' to an already initialized clock will remove the setTimeout for that clock to stop it from ticking, and remove all html markup and data associated with the plugin instance on the dom elements"},{type:"string",value:"stop",description:"Passing in 'stop' to an already initialized clock will clear the setTimeout for that clock to stop it from ticking"},{type:"string",value:"start",description:"Passing in 'start' to an already initialized clock will restart the setTimeout for that clock to get it ticking again, as though it had never lost time"}, +{type:"object",description:"option set {}",values:[{name:"timestamp",description:"Either a javascript timestamp as produces by [JAVASCRIPT new Date().getTime()] or a php timestamp as produced by [PHP time()] ",type:"unix timestamp",values:["javascript timestamp","php timestamp"]},{name:"langSet",description:"two letter locale to be used for the translation of Day names and Month names",type:"String",values:"am ar bn bg ca zh hr cs da nl en et fi fr de el gu hi hu id it ja kn ko lv lt ms ml mr mo ps fa pl pt ro ru sr sk sl es sw sv ta te th tr uk vi".split(" ")}, +{name:"calendar",description:"Whether the date should be displayed together with the time",type:"Boolean",values:[!0,!1]},{name:"dateFormat",description:"PHP Style Format string for formatting a local date, see http://php.net/manual/en/function.date.php",type:"String",values:"dDjlNSwzWFmMntLoYy".split("")},{name:"timeFormat",description:"PHP Style Format string for formatting a local date, see http://php.net/manual/en/function.date.php",type:"String",values:"aABgGhHisveIOPZcrU".split("")},{name:"isDST", +description:"When a client side timestamp is used, whether DST is active will be automatically determined. However this cannot be determined for a server-side timestamp which must be passed in as UTC, in that can case it can be set with this option",type:"Boolean",values:[!0,!1]},{name:"rate",description:"Defines the rate at which the clock will update, in milliseconds",type:"Integer",values:"1 - 9007199254740991 (recommended 10-60000)"}]}],methods:{destroy:"Chaining clock().destroy() has the same effect as passing the 'destroy' option as in clock('destroy')", +stop:"Chaining clock().stop() has the same effect as passing the 'stop' option as in clock('stop')",start:"Chaining clock().start() has the same effect as passing the 'start' option as in clock('start')"}};Object.freeze(b.clock);var h=h||{};b.fn.clock=function(a){var m=this;this.initialize=function(){return this};this.destroy=function(){return m.each(function(a){a=b(this).attr("id");h.hasOwnProperty(a)&&(clearTimeout(h[a]),delete h[a]);b(this).html("");b(this).hasClass("jqclock")&&b(this).removeClass("jqclock"); +b(this).removeData("clockoptions")})};this.stop=function(){return m.each(function(a){a=b(this).attr("id");h.hasOwnProperty(a)&&(clearTimeout(h[a]),delete h[a])})};this.start=function(){return m.each(function(a){a=b(this).attr("id");var c=b(this).data("clockoptions");if(c!==d&&!1===h.hasOwnProperty(a)){var g=this;h[a]=setTimeout(function(){x(b(g))},c.rate)}})};var B=function(){return"xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(/[xy]/g,function(a){var b=16*Math.random()|0;return("x"==a?b:b&3|8).toString(16)}).toUpperCase()}, +x=function(a){var c=b(a).data("clockoptions"),d=(new Date).getTime()+c.sysdiff,k=new Date(d),g=k.getHours(),m=k.getMinutes(),C=k.getSeconds(),B=k.getMilliseconds(),e=k.getDay(),l=k.getDate(),D=k.getMonth(),y=k.getFullYear(),p=k.isLeapYear(),w=k.getDOY(),q=k.getWOY(),v=k.getWOY(!0),G=k.daysInMonth(),H=k.swatchTime(),n=parseInt(c.tzOffset/60),z=parseInt(60*c.tzOffset),E="AM",f="",r="";11t+1&&u!=A.length?(f+=A.substring(t+1,u),t+=u-t):f+=F;break; +default:f+=F}}f=''+f+""}e="";for(p=0;p<=c.timeFormat.length;p++)switch(w=c.timeFormat.charAt(p),w){case "a":e+=E.toLowerCase();break;case "A":e+=E;break;case "B":e+=H;break;case "g":e+=r;break;case "G":e+=g;break;case "h":e+=(""+r).padStart(2,"0");break;case "H":e+=(""+g).padStart(2,"0");break;case "i":e+=(""+m).padStart(2,"0");break;case "s":e+=(""+C).padStart(2,"0");break;case "v":e+=(""+B).padStart(3,"0");break;case "e":e+=c.timezone;break;case "I":e+=c.isDST?"DST": +"";break;case "O":e+=(0>n?"+"+(""+Math.abs(n)).padStart(2,"0"):0n?"+"+(""+Math.abs(n)).padStart(2,"0"):0z?""+Math.abs(z):0n?"+"+(""+Math.abs(n)).padStart(2,"0"):0n?"+"+(""+Math.abs(n)).padStart(2,"0"):0p+1&&q!=v.length?(e+=v.substring(p+1,q),p+=q-p):e+=w;break;default:e+=w}r=''+e+"";b(a).html(f+r);d=b(a).attr("id");h[d]=setTimeout(function(){x(b(a))},c.rate)};this.each(function(g){if("undefined"===typeof a||"object"===typeof a){g=new Date;a=a||{};a.timestamp=a.timestamp||"localsystime";a.langSet=a.langSet||"en";a.calendar=a.hasOwnProperty("calendar")?a.calendar:!0;a.dateFormat=a.dateFormat||("en"==a.langSet?"l, F j, Y":"l, j F Y");a.timeFormat=a.timeFormat||("en"== +a.langSet?"h:i:s A":"H:i:s");a.timezone=a.timezone||"localsystimezone";a.isDST=a.hasOwnProperty("isDST")?a.isDST:g.isDST();a.rate=a.rate||500;"string"!==typeof a.langSet&&(a.langSet=""+a.langSet);"string"===typeof a.calendar?a.calendar="false"==a.calendar?!1:!0:"boolean"!==typeof a.calendar&&(a.calendar=!!a.calendar);"string"!==typeof a.dateFormat&&(a.dateFormat=""+a.dateFormat);"string"!==typeof a.timeFormat&&(a.timeFormat=""+a.dateFormat);"string"!==typeof a.timezone&&(a.timezone=""+a.timezone); +"string"===typeof a.isDST?a.isDST="true"==a.isDST?!0:!1:"boolean"!==typeof a.isDST&&(a.isDST=!!a.isDST);"number"!==typeof a.rate&&(a.rate=parseInt(a.rate));a.tzOffset=g.getTimezoneOffset();var c=a.tzOffset/60;a.sysdiff=0;"localsystime"!=a.timestamp?2<(g.getTime()+"").length-(a.timestamp+"").length?(a.timestamp*=1E3,a.sysdiff=a.timestamp-g.getTime()+6E4*a.tzOffset):(a.sysdiff=a.timestamp-g.getTime(),"localsystimezone"==a.timezone&&(a.timezone="UTC",0>c?a.timezone+="+"+Math.abs(c):0c?a.timezone+="+"+Math.abs(c):0 .sorting_1, +table.dataTable.order-column tbody tr > .sorting_2, +table.dataTable.order-column tbody tr > .sorting_3, table.dataTable.display tbody tr > .sorting_1, +table.dataTable.display tbody tr > .sorting_2, +table.dataTable.display tbody tr > .sorting_3 { + background-color: #f9f9f9; +} +table.dataTable.order-column tbody tr.selected > .sorting_1, +table.dataTable.order-column tbody tr.selected > .sorting_2, +table.dataTable.order-column tbody tr.selected > .sorting_3, table.dataTable.display tbody tr.selected > .sorting_1, +table.dataTable.display tbody tr.selected > .sorting_2, +table.dataTable.display tbody tr.selected > .sorting_3 { + background-color: #acbad4; +} +table.dataTable.display tbody tr.odd > .sorting_1, table.dataTable.order-column.stripe tbody tr.odd > .sorting_1 { + background-color: #f1f1f1; +} +table.dataTable.display tbody tr.odd > .sorting_2, table.dataTable.order-column.stripe tbody tr.odd > .sorting_2 { + background-color: #f3f3f3; +} +table.dataTable.display tbody tr.odd > .sorting_3, table.dataTable.order-column.stripe tbody tr.odd > .sorting_3 { + background-color: whitesmoke; +} +table.dataTable.display tbody tr.odd.selected > .sorting_1, table.dataTable.order-column.stripe tbody tr.odd.selected > .sorting_1 { + background-color: #a6b3cd; +} +table.dataTable.display tbody tr.odd.selected > .sorting_2, table.dataTable.order-column.stripe tbody tr.odd.selected > .sorting_2 { + background-color: #a7b5ce; +} +table.dataTable.display tbody tr.odd.selected > .sorting_3, table.dataTable.order-column.stripe tbody tr.odd.selected > .sorting_3 { + background-color: #a9b6d0; +} +table.dataTable.display tbody tr.even > .sorting_1, table.dataTable.order-column.stripe tbody tr.even > .sorting_1 { + background-color: #f9f9f9; +} +table.dataTable.display tbody tr.even > .sorting_2, table.dataTable.order-column.stripe tbody tr.even > .sorting_2 { + background-color: #fbfbfb; +} +table.dataTable.display tbody tr.even > .sorting_3, table.dataTable.order-column.stripe tbody tr.even > .sorting_3 { + background-color: #fdfdfd; +} +table.dataTable.display tbody tr.even.selected > .sorting_1, table.dataTable.order-column.stripe tbody tr.even.selected > .sorting_1 { + background-color: #acbad4; +} +table.dataTable.display tbody tr.even.selected > .sorting_2, table.dataTable.order-column.stripe tbody tr.even.selected > .sorting_2 { + background-color: #adbbd6; +} +table.dataTable.display tbody tr.even.selected > .sorting_3, table.dataTable.order-column.stripe tbody tr.even.selected > .sorting_3 { + background-color: #afbdd8; +} +table.dataTable.display tbody tr:hover > .sorting_1, +table.dataTable.display tbody tr.odd:hover > .sorting_1, +table.dataTable.display tbody tr.even:hover > .sorting_1, table.dataTable.order-column.hover tbody tr:hover > .sorting_1, +table.dataTable.order-column.hover tbody tr.odd:hover > .sorting_1, +table.dataTable.order-column.hover tbody tr.even:hover > .sorting_1 { + background-color: #eaeaea; +} +table.dataTable.display tbody tr:hover > .sorting_2, +table.dataTable.display tbody tr.odd:hover > .sorting_2, +table.dataTable.display tbody tr.even:hover > .sorting_2, table.dataTable.order-column.hover tbody tr:hover > .sorting_2, +table.dataTable.order-column.hover tbody tr.odd:hover > .sorting_2, +table.dataTable.order-column.hover tbody tr.even:hover > .sorting_2 { + background-color: #ebebeb; +} +table.dataTable.display tbody tr:hover > .sorting_3, +table.dataTable.display tbody tr.odd:hover > .sorting_3, +table.dataTable.display tbody tr.even:hover > .sorting_3, table.dataTable.order-column.hover tbody tr:hover > .sorting_3, +table.dataTable.order-column.hover tbody tr.odd:hover > .sorting_3, +table.dataTable.order-column.hover tbody tr.even:hover > .sorting_3 { + background-color: #eeeeee; +} +table.dataTable.display tbody tr:hover.selected > .sorting_1, +table.dataTable.display tbody tr.odd:hover.selected > .sorting_1, +table.dataTable.display tbody tr.even:hover.selected > .sorting_1, table.dataTable.order-column.hover tbody tr:hover.selected > .sorting_1, +table.dataTable.order-column.hover tbody tr.odd:hover.selected > .sorting_1, +table.dataTable.order-column.hover tbody tr.even:hover.selected > .sorting_1 { + background-color: #a1aec7; +} +table.dataTable.display tbody tr:hover.selected > .sorting_2, +table.dataTable.display tbody tr.odd:hover.selected > .sorting_2, +table.dataTable.display tbody tr.even:hover.selected > .sorting_2, table.dataTable.order-column.hover tbody tr:hover.selected > .sorting_2, +table.dataTable.order-column.hover tbody tr.odd:hover.selected > .sorting_2, +table.dataTable.order-column.hover tbody tr.even:hover.selected > .sorting_2 { + background-color: #a2afc8; +} +table.dataTable.display tbody tr:hover.selected > .sorting_3, +table.dataTable.display tbody tr.odd:hover.selected > .sorting_3, +table.dataTable.display tbody tr.even:hover.selected > .sorting_3, table.dataTable.order-column.hover tbody tr:hover.selected > .sorting_3, +table.dataTable.order-column.hover tbody tr.odd:hover.selected > .sorting_3, +table.dataTable.order-column.hover tbody tr.even:hover.selected > .sorting_3 { + background-color: #a4b2cb; +} +table.dataTable.no-footer { + border-bottom: 1px solid #111111; +} +table.dataTable.nowrap th, table.dataTable.nowrap td { + white-space: nowrap; +} +table.dataTable.compact thead th, +table.dataTable.compact thead td { + padding: 5px 9px; +} +table.dataTable.compact tfoot th, +table.dataTable.compact tfoot td { + padding: 5px 9px 3px 9px; +} +table.dataTable.compact tbody th, +table.dataTable.compact tbody td { + padding: 4px 5px; +} +table.dataTable th.dt-left, +table.dataTable td.dt-left { + text-align: left; +} +table.dataTable th.dt-center, +table.dataTable td.dt-center, +table.dataTable td.dataTables_empty { + text-align: center; +} +table.dataTable th.dt-right, +table.dataTable td.dt-right { + text-align: right; +} +table.dataTable th.dt-justify, +table.dataTable td.dt-justify { + text-align: justify; +} +table.dataTable th.dt-nowrap, +table.dataTable td.dt-nowrap { + white-space: nowrap; +} +table.dataTable thead th.dt-head-left, +table.dataTable thead td.dt-head-left, +table.dataTable tfoot th.dt-head-left, +table.dataTable tfoot td.dt-head-left { + text-align: left; +} +table.dataTable thead th.dt-head-center, +table.dataTable thead td.dt-head-center, +table.dataTable tfoot th.dt-head-center, +table.dataTable tfoot td.dt-head-center { + text-align: center; +} +table.dataTable thead th.dt-head-right, +table.dataTable thead td.dt-head-right, +table.dataTable tfoot th.dt-head-right, +table.dataTable tfoot td.dt-head-right { + text-align: right; +} +table.dataTable thead th.dt-head-justify, +table.dataTable thead td.dt-head-justify, +table.dataTable tfoot th.dt-head-justify, +table.dataTable tfoot td.dt-head-justify { + text-align: justify; +} +table.dataTable thead th.dt-head-nowrap, +table.dataTable thead td.dt-head-nowrap, +table.dataTable tfoot th.dt-head-nowrap, +table.dataTable tfoot td.dt-head-nowrap { + white-space: nowrap; +} +table.dataTable tbody th.dt-body-left, +table.dataTable tbody td.dt-body-left { + text-align: left; +} +table.dataTable tbody th.dt-body-center, +table.dataTable tbody td.dt-body-center { + text-align: center; +} +table.dataTable tbody th.dt-body-right, +table.dataTable tbody td.dt-body-right { + text-align: right; +} +table.dataTable tbody th.dt-body-justify, +table.dataTable tbody td.dt-body-justify { + text-align: justify; +} +table.dataTable tbody th.dt-body-nowrap, +table.dataTable tbody td.dt-body-nowrap { + white-space: nowrap; +} + +table.dataTable, +table.dataTable th, +table.dataTable td { + -webkit-box-sizing: content-box; + -moz-box-sizing: content-box; + box-sizing: content-box; +} + +/* + * Control feature layout + */ +.dataTables_wrapper { + position: relative; + clear: both; + *zoom: 1; + zoom: 1; +} +.dataTables_wrapper .dataTables_length { + float: left; +} +.dataTables_wrapper .dataTables_filter { + float: right; + text-align: right; +} +.dataTables_wrapper .dataTables_filter input { + margin-left: 0.5em; +} +.dataTables_wrapper .dataTables_info { + clear: both; + float: left; + padding-top: 0.755em; +} +.dataTables_wrapper .dataTables_paginate { + float: right; + text-align: right; + padding-top: 0.25em; +} +.dataTables_wrapper .dataTables_paginate .paginate_button { + box-sizing: border-box; + display: inline-block; + min-width: 1.5em; + padding: 0.5em 1em; + margin-left: 2px; + text-align: center; + text-decoration: none !important; + cursor: pointer; + *cursor: hand; + color: #333333 !important; + border: 1px solid transparent; +} +.dataTables_wrapper .dataTables_paginate .paginate_button.current, .dataTables_wrapper .dataTables_paginate .paginate_button.current:hover { + color: #333333 !important; + border: 1px solid #cacaca; + background-color: white; + background: -webkit-gradient(linear, left top, left bottom, color-stop(0%, white), color-stop(100%, gainsboro)); + /* Chrome,Safari4+ */ + background: -webkit-linear-gradient(top, white 0%, gainsboro 100%); + /* Chrome10+,Safari5.1+ */ + background: -moz-linear-gradient(top, white 0%, gainsboro 100%); + /* FF3.6+ */ + background: -ms-linear-gradient(top, white 0%, gainsboro 100%); + /* IE10+ */ + background: -o-linear-gradient(top, white 0%, gainsboro 100%); + /* Opera 11.10+ */ + background: linear-gradient(to bottom, white 0%, gainsboro 100%); + /* W3C */ +} +.dataTables_wrapper .dataTables_paginate .paginate_button.disabled, .dataTables_wrapper .dataTables_paginate .paginate_button.disabled:hover, .dataTables_wrapper .dataTables_paginate .paginate_button.disabled:active { + cursor: default; + color: #666 !important; + border: 1px solid transparent; + background: transparent; + box-shadow: none; +} +.dataTables_wrapper .dataTables_paginate .paginate_button:hover { + color: white !important; + border: 1px solid #111111; + background-color: #585858; + background: -webkit-gradient(linear, left top, left bottom, color-stop(0%, #585858), color-stop(100%, #111111)); + /* Chrome,Safari4+ */ + background: -webkit-linear-gradient(top, #585858 0%, #111111 100%); + /* Chrome10+,Safari5.1+ */ + background: -moz-linear-gradient(top, #585858 0%, #111111 100%); + /* FF3.6+ */ + background: -ms-linear-gradient(top, #585858 0%, #111111 100%); + /* IE10+ */ + background: -o-linear-gradient(top, #585858 0%, #111111 100%); + /* Opera 11.10+ */ + background: linear-gradient(to bottom, #585858 0%, #111111 100%); + /* W3C */ +} +.dataTables_wrapper .dataTables_paginate .paginate_button:active { + outline: none; + background-color: #2b2b2b; + background: -webkit-gradient(linear, left top, left bottom, color-stop(0%, #2b2b2b), color-stop(100%, #0c0c0c)); + /* Chrome,Safari4+ */ + background: -webkit-linear-gradient(top, #2b2b2b 0%, #0c0c0c 100%); + /* Chrome10+,Safari5.1+ */ + background: -moz-linear-gradient(top, #2b2b2b 0%, #0c0c0c 100%); + /* FF3.6+ */ + background: -ms-linear-gradient(top, #2b2b2b 0%, #0c0c0c 100%); + /* IE10+ */ + background: -o-linear-gradient(top, #2b2b2b 0%, #0c0c0c 100%); + /* Opera 11.10+ */ + background: linear-gradient(to bottom, #2b2b2b 0%, #0c0c0c 100%); + /* W3C */ + box-shadow: inset 0 0 3px #111; +} +.dataTables_wrapper .dataTables_processing { + position: absolute; + top: 50%; + left: 50%; + width: 100%; + height: 40px; + margin-left: -50%; + margin-top: -25px; + padding-top: 20px; + text-align: center; + font-size: 1.2em; + background-color: white; + background: -webkit-gradient(linear, left top, right top, color-stop(0%, rgba(255, 255, 255, 0)), color-stop(25%, rgba(255, 255, 255, 0.9)), color-stop(75%, rgba(255, 255, 255, 0.9)), color-stop(100%, rgba(255, 255, 255, 0))); + /* Chrome,Safari4+ */ + background: -webkit-linear-gradient(left, rgba(255, 255, 255, 0) 0%, rgba(255, 255, 255, 0.9) 25%, rgba(255, 255, 255, 0.9) 75%, rgba(255, 255, 255, 0) 100%); + /* Chrome10+,Safari5.1+ */ + background: -moz-linear-gradient(left, rgba(255, 255, 255, 0) 0%, rgba(255, 255, 255, 0.9) 25%, rgba(255, 255, 255, 0.9) 75%, rgba(255, 255, 255, 0) 100%); + /* FF3.6+ */ + background: -ms-linear-gradient(left, rgba(255, 255, 255, 0) 0%, rgba(255, 255, 255, 0.9) 25%, rgba(255, 255, 255, 0.9) 75%, rgba(255, 255, 255, 0) 100%); + /* IE10+ */ + background: -o-linear-gradient(left, rgba(255, 255, 255, 0) 0%, rgba(255, 255, 255, 0.9) 25%, rgba(255, 255, 255, 0.9) 75%, rgba(255, 255, 255, 0) 100%); + /* Opera 11.10+ */ + background: linear-gradient(to right, rgba(255, 255, 255, 0) 0%, rgba(255, 255, 255, 0.9) 25%, rgba(255, 255, 255, 0.9) 75%, rgba(255, 255, 255, 0) 100%); + /* W3C */ +} +.dataTables_wrapper .dataTables_length, +.dataTables_wrapper .dataTables_filter, +.dataTables_wrapper .dataTables_info, +.dataTables_wrapper .dataTables_processing, +.dataTables_wrapper .dataTables_paginate { + color: #333333; +} +.dataTables_wrapper .dataTables_scroll { + clear: both; +} +.dataTables_wrapper .dataTables_scroll div.dataTables_scrollBody { + *margin-top: -1px; + -webkit-overflow-scrolling: touch; +} +.dataTables_wrapper .dataTables_scroll div.dataTables_scrollBody th > div.dataTables_sizing, +.dataTables_wrapper .dataTables_scroll div.dataTables_scrollBody td > div.dataTables_sizing { + height: 0; + overflow: hidden; + margin: 0 !important; + padding: 0 !important; +} +.dataTables_wrapper.no-footer .dataTables_scrollBody { + border-bottom: 1px solid #111111; +} +.dataTables_wrapper.no-footer div.dataTables_scrollHead table, +.dataTables_wrapper.no-footer div.dataTables_scrollBody table { + border-bottom: none; +} +.dataTables_wrapper:after { + visibility: hidden; + display: block; + content: ""; + clear: both; + height: 0; +} + +@media screen and (max-width: 767px) { + .dataTables_wrapper .dataTables_info, + .dataTables_wrapper .dataTables_paginate { + float: none; + text-align: center; + } + .dataTables_wrapper .dataTables_paginate { + margin-top: 0.5em; + } +} +@media screen and (max-width: 640px) { + .dataTables_wrapper .dataTables_length, + .dataTables_wrapper .dataTables_filter { + float: none; + text-align: center; + } + .dataTables_wrapper .dataTables_filter { + margin-top: 0.5em; + } +} diff --git a/airflow/www/static/jquery.dataTables.min.js b/airflow/www/static/jquery.dataTables.min.js new file mode 100644 index 0000000000000..59d4dac21f531 --- /dev/null +++ b/airflow/www/static/jquery.dataTables.min.js @@ -0,0 +1,189 @@ +/* + * This combined file was created by the DataTables downloader builder: + * https://datatables.net/download + * + * To rebuild or modify this file with the latest versions of the included + * software please visit: + * https://datatables.net/download/#bs/dt-1.10.10 + * + * Included libraries: + * DataTables 1.10.10 + */ + +/*! + DataTables 1.10.10 + ©2008-2015 SpryMedia Ltd - datatables.net/license +*/ +(function(h){"function"===typeof define&&define.amd?define(["jquery"],function(E){return h(E,window,document)}):"object"===typeof exports?module.exports=function(E,H){E||(E=window);H||(H="undefined"!==typeof window?require("jquery"):require("jquery")(E));return h(H,E,E.document)}:h(jQuery,window,document)})(function(h,E,H,k){function Y(a){var b,c,d={};h.each(a,function(e){if((b=e.match(/^([^A-Z]+?)([A-Z])/))&&-1!=="a aa ai ao as b fn i m o s ".indexOf(b[1]+" "))c=e.replace(b[0],b[2].toLowerCase()), +d[c]=e,"o"===b[1]&&Y(a[e])});a._hungarianMap=d}function J(a,b,c){a._hungarianMap||Y(a);var d;h.each(b,function(e){d=a._hungarianMap[e];if(d!==k&&(c||b[d]===k))"o"===d.charAt(0)?(b[d]||(b[d]={}),h.extend(!0,b[d],b[e]),J(a[d],b[d],c)):b[d]=b[e]})}function Fa(a){var b=m.defaults.oLanguage,c=a.sZeroRecords;!a.sEmptyTable&&(c&&"No data available in table"===b.sEmptyTable)&&F(a,a,"sZeroRecords","sEmptyTable");!a.sLoadingRecords&&(c&&"Loading..."===b.sLoadingRecords)&&F(a,a,"sZeroRecords","sLoadingRecords"); +a.sInfoThousands&&(a.sThousands=a.sInfoThousands);(a=a.sDecimal)&&db(a)}function eb(a){A(a,"ordering","bSort");A(a,"orderMulti","bSortMulti");A(a,"orderClasses","bSortClasses");A(a,"orderCellsTop","bSortCellsTop");A(a,"order","aaSorting");A(a,"orderFixed","aaSortingFixed");A(a,"paging","bPaginate");A(a,"pagingType","sPaginationType");A(a,"pageLength","iDisplayLength");A(a,"searching","bFilter");"boolean"===typeof a.sScrollX&&(a.sScrollX=a.sScrollX?"100%":"");"boolean"===typeof a.scrollX&&(a.scrollX= +a.scrollX?"100%":"");if(a=a.aoSearchCols)for(var b=0,c=a.length;b").css({position:"fixed",top:0,left:0,height:1,width:1,overflow:"hidden"}).append(h("
    ").css({position:"absolute",top:1,left:1, +width:100,overflow:"scroll"}).append(h("
    ").css({width:"100%",height:10}))).appendTo("body"),d=c.children(),e=d.children();b.barWidth=d[0].offsetWidth-d[0].clientWidth;b.bScrollOversize=100===e[0].offsetWidth&&100!==d[0].clientWidth;b.bScrollbarLeft=1!==Math.round(e.offset().left);b.bBounding=c[0].getBoundingClientRect().width?!0:!1;c.remove()}h.extend(a.oBrowser,m.__browser);a.oScroll.iBarWidth=m.__browser.barWidth}function hb(a,b,c,d,e,f){var g,j=!1;c!==k&&(g=c,j=!0);for(;d!==e;)a.hasOwnProperty(d)&& +(g=j?b(g,a[d],d,a):a[d],j=!0,d+=f);return g}function Ga(a,b){var c=m.defaults.column,d=a.aoColumns.length,c=h.extend({},m.models.oColumn,c,{nTh:b?b:H.createElement("th"),sTitle:c.sTitle?c.sTitle:b?b.innerHTML:"",aDataSort:c.aDataSort?c.aDataSort:[d],mData:c.mData?c.mData:d,idx:d});a.aoColumns.push(c);c=a.aoPreSearchCols;c[d]=h.extend({},m.models.oSearch,c[d]);la(a,d,h(b).data())}function la(a,b,c){var b=a.aoColumns[b],d=a.oClasses,e=h(b.nTh);if(!b.sWidthOrig){b.sWidthOrig=e.attr("width")||null;var f= +(e.attr("style")||"").match(/width:\s*(\d+[pxem%]+)/);f&&(b.sWidthOrig=f[1])}c!==k&&null!==c&&(fb(c),J(m.defaults.column,c),c.mDataProp!==k&&!c.mData&&(c.mData=c.mDataProp),c.sType&&(b._sManualType=c.sType),c.className&&!c.sClass&&(c.sClass=c.className),h.extend(b,c),F(b,c,"sWidth","sWidthOrig"),c.iDataSort!==k&&(b.aDataSort=[c.iDataSort]),F(b,c,"aDataSort"));var g=b.mData,j=Q(g),i=b.mRender?Q(b.mRender):null,c=function(a){return"string"===typeof a&&-1!==a.indexOf("@")};b._bAttrSrc=h.isPlainObject(g)&& +(c(g.sort)||c(g.type)||c(g.filter));b.fnGetData=function(a,b,c){var d=j(a,b,k,c);return i&&b?i(d,b,a,c):d};b.fnSetData=function(a,b,c){return R(g)(a,b,c)};"number"!==typeof g&&(a._rowReadObject=!0);a.oFeatures.bSort||(b.bSortable=!1,e.addClass(d.sSortableNone));a=-1!==h.inArray("asc",b.asSorting);c=-1!==h.inArray("desc",b.asSorting);!b.bSortable||!a&&!c?(b.sSortingClass=d.sSortableNone,b.sSortingClassJUI=""):a&&!c?(b.sSortingClass=d.sSortableAsc,b.sSortingClassJUI=d.sSortJUIAscAllowed):!a&&c?(b.sSortingClass= +d.sSortableDesc,b.sSortingClassJUI=d.sSortJUIDescAllowed):(b.sSortingClass=d.sSortable,b.sSortingClassJUI=d.sSortJUI)}function U(a){if(!1!==a.oFeatures.bAutoWidth){var b=a.aoColumns;Ha(a);for(var c=0,d=b.length;cq[f])d(l.length+q[f],o);else if("string"===typeof q[f]){j=0;for(i=l.length;jb&&a[e]--; -1!=d&&c===k&&a.splice(d,1)}function ea(a,b,c,d){var e=a.aoData[b],f,g=function(c,d){for(;c.childNodes.length;)c.removeChild(c.firstChild);c.innerHTML=B(a,b,d,"display")};if("dom"===c||(!c||"auto"===c)&&"dom"===e.src)e._aData= +Ka(a,e,d,d===k?k:e._aData).data;else{var j=e.anCells;if(j)if(d!==k)g(j[d],d);else{c=0;for(f=j.length;c").appendTo(g));b=0;for(c=l.length;btr").attr("role","row");h(g).find(">tr>th, >tr>td").addClass(o.sHeaderTH);h(j).find(">tr>th, >tr>td").addClass(o.sFooterTH);if(null!==j){a=a.aoFooter[0];b=0;for(c=a.length;b=a.fnRecordsDisplay()?0:g,a.iInitDisplayStart=-1);var g=a._iDisplayStart,o=a.fnDisplayEnd();if(a.bDeferLoading)a.bDeferLoading=!1,a.iDraw++,C(a,!1);else if(j){if(!a.bDestroying&& +!lb(a))return}else a.iDraw++;if(0!==i.length){f=j?a.aoData.length:o;for(j=j?0:g;j",{"class":e?d[0]:""}).append(h("",{valign:"top",colSpan:ca(a), +"class":a.oClasses.sRowEmpty}).html(c))[0];v(a,"aoHeaderCallback","header",[h(a.nTHead).children("tr")[0],Ma(a),g,o,i]);v(a,"aoFooterCallback","footer",[h(a.nTFoot).children("tr")[0],Ma(a),g,o,i]);d=h(a.nTBody);d.children().detach();d.append(h(b));v(a,"aoDrawCallback","draw",[a]);a.bSorted=!1;a.bFiltered=!1;a.bDrawing=!1}}function T(a,b){var c=a.oFeatures,d=c.bFilter;c.bSort&&mb(a);d?ha(a,a.oPreviousSearch):a.aiDisplay=a.aiDisplayMaster.slice();!0!==b&&(a._iDisplayStart=0);a._drawHold=b;O(a);a._drawHold= +!1}function nb(a){var b=a.oClasses,c=h(a.nTable),c=h("
    ").insertBefore(c),d=a.oFeatures,e=h("
    ",{id:a.sTableId+"_wrapper","class":b.sWrapper+(a.nTFoot?"":" "+b.sNoFooter)});a.nHolding=c[0];a.nTableWrapper=e[0];a.nTableReinsertBefore=a.nTable.nextSibling;for(var f=a.sDom.split(""),g,j,i,o,l,q,u=0;u")[0];o=f[u+1];if("'"==o||'"'==o){l="";for(q=2;f[u+q]!=o;)l+=f[u+q],q++;"H"==l?l=b.sJUIHeader:"F"==l&&(l=b.sJUIFooter);-1!=l.indexOf(".")?(o=l.split("."), +i.id=o[0].substr(1,o[0].length-1),i.className=o[1]):"#"==l.charAt(0)?i.id=l.substr(1,l.length-1):i.className=l;u+=q}e.append(i);e=h(i)}else if(">"==j)e=e.parent();else if("l"==j&&d.bPaginate&&d.bLengthChange)g=ob(a);else if("f"==j&&d.bFilter)g=pb(a);else if("r"==j&&d.bProcessing)g=qb(a);else if("t"==j)g=rb(a);else if("i"==j&&d.bInfo)g=sb(a);else if("p"==j&&d.bPaginate)g=tb(a);else if(0!==m.ext.feature.length){i=m.ext.feature;q=0;for(o=i.length;q',j=d.sSearch,j=j.match(/_INPUT_/)?j.replace("_INPUT_",g):j+g,b=h("
    ",{id:!f.f?c+"_filter":null,"class":b.sFilter}).append(h("
    ").addClass(b.sLength); +a.aanFeatures.l||(i[0].id=c+"_length");i.children().append(a.oLanguage.sLengthMenu.replace("_MENU_",e[0].outerHTML));h("select",i).val(a._iDisplayLength).bind("change.DT",function(){Ra(a,h(this).val());O(a)});h(a.nTable).bind("length.dt.DT",function(b,c,d){a===c&&h("select",i).val(d)});return i[0]}function tb(a){var b=a.sPaginationType,c=m.ext.pager[b],d="function"===typeof c,e=function(a){O(a)},b=h("
    ").addClass(a.oClasses.sPaging+b)[0],f=a.aanFeatures;d||c.fnInit(a,b,e);f.p||(b.id=a.sTableId+ +"_paginate",a.aoDrawCallback.push({fn:function(a){if(d){var b=a._iDisplayStart,i=a._iDisplayLength,h=a.fnRecordsDisplay(),l=-1===i,b=l?0:Math.ceil(b/i),i=l?1:Math.ceil(h/i),h=c(b,i),k,l=0;for(k=f.p.length;lf&&(d=0)):"first"==b?d=0:"previous"==b?(d=0<=e?d-e:0,0>d&&(d=0)):"next"== +b?d+e",{id:!a.aanFeatures.r?a.sTableId+"_processing":null,"class":a.oClasses.sProcessing}).html(a.oLanguage.sProcessing).insertBefore(a.nTable)[0]}function C(a,b){a.oFeatures.bProcessing&&h(a.aanFeatures.r).css("display",b?"block":"none");v(a,null,"processing",[a,b])}function rb(a){var b=h(a.nTable);b.attr("role", +"grid");var c=a.oScroll;if(""===c.sX&&""===c.sY)return a.nTable;var d=c.sX,e=c.sY,f=a.oClasses,g=b.children("caption"),j=g.length?g[0]._captionSide:null,i=h(b[0].cloneNode(!1)),o=h(b[0].cloneNode(!1)),l=b.children("tfoot");l.length||(l=null);i=h("
    ",{"class":f.sScrollWrapper}).append(h("
    ",{"class":f.sScrollHead}).css({overflow:"hidden",position:"relative",border:0,width:d?!d?null:w(d):"100%"}).append(h("
    ",{"class":f.sScrollHeadInner}).css({"box-sizing":"content-box",width:c.sXInner|| +"100%"}).append(i.removeAttr("id").css("margin-left",0).append("top"===j?g:null).append(b.children("thead"))))).append(h("
    ",{"class":f.sScrollBody}).css({position:"relative",overflow:"auto",width:!d?null:w(d)}).append(b));l&&i.append(h("
    ",{"class":f.sScrollFoot}).css({overflow:"hidden",border:0,width:d?!d?null:w(d):"100%"}).append(h("
    ",{"class":f.sScrollFootInner}).append(o.removeAttr("id").css("margin-left",0).append("bottom"===j?g:null).append(b.children("tfoot")))));var b=i.children(), +k=b[0],f=b[1],u=l?b[2]:null;if(d)h(f).on("scroll.DT",function(){var a=this.scrollLeft;k.scrollLeft=a;l&&(u.scrollLeft=a)});h(f).css(e&&c.bCollapse?"max-height":"height",e);a.nScrollHead=k;a.nScrollBody=f;a.nScrollFoot=u;a.aoDrawCallback.push({fn:Z,sName:"scrolling"});return i[0]}function Z(a){var b=a.oScroll,c=b.sX,d=b.sXInner,e=b.sY,b=b.iBarWidth,f=h(a.nScrollHead),g=f[0].style,j=f.children("div"),i=j[0].style,o=j.children("table"),j=a.nScrollBody,l=h(j),q=j.style,u=h(a.nScrollFoot).children("div"), +m=u.children("table"),n=h(a.nTHead),p=h(a.nTable),t=p[0],v=t.style,r=a.nTFoot?h(a.nTFoot):null,Eb=a.oBrowser,Ua=Eb.bScrollOversize,s,L,P,x,y=[],z=[],A=[],B,C=function(a){a=a.style;a.paddingTop="0";a.paddingBottom="0";a.borderTopWidth="0";a.borderBottomWidth="0";a.height=0};L=j.scrollHeight>j.clientHeight;if(a.scrollBarVis!==L&&a.scrollBarVis!==k)a.scrollBarVis=L,U(a);else{a.scrollBarVis=L;p.children("thead, tfoot").remove();x=n.clone().prependTo(p);n=n.find("tr");L=x.find("tr");x.find("th, td").removeAttr("tabindex"); +r&&(P=r.clone().prependTo(p),s=r.find("tr"),P=P.find("tr"));c||(q.width="100%",f[0].style.width="100%");h.each(qa(a,x),function(b,c){B=$(a,b);c.style.width=a.aoColumns[B].sWidth});r&&I(function(a){a.style.width=""},P);f=p.outerWidth();if(""===c){v.width="100%";if(Ua&&(p.find("tbody").height()>j.offsetHeight||"scroll"==l.css("overflow-y")))v.width=w(p.outerWidth()-b);f=p.outerWidth()}else""!==d&&(v.width=w(d),f=p.outerWidth());I(C,L);I(function(a){A.push(a.innerHTML);y.push(w(h(a).css("width")))}, +L);I(function(a,b){a.style.width=y[b]},n);h(L).height(0);r&&(I(C,P),I(function(a){z.push(w(h(a).css("width")))},P),I(function(a,b){a.style.width=z[b]},s),h(P).height(0));I(function(a,b){a.innerHTML='
    '+A[b]+"
    ";a.style.width=y[b]},L);r&&I(function(a,b){a.innerHTML="";a.style.width=z[b]},P);if(p.outerWidth()j.offsetHeight||"scroll"==l.css("overflow-y")?f+b:f;if(Ua&&(j.scrollHeight>j.offsetHeight||"scroll"==l.css("overflow-y")))v.width= +w(s-b);(""===c||""!==d)&&K(a,1,"Possible column misalignment",6)}else s="100%";q.width=w(s);g.width=w(s);r&&(a.nScrollFoot.style.width=w(s));!e&&Ua&&(q.height=w(t.offsetHeight+b));c=p.outerWidth();o[0].style.width=w(c);i.width=w(c);d=p.height()>j.clientHeight||"scroll"==l.css("overflow-y");e="padding"+(Eb.bScrollbarLeft?"Left":"Right");i[e]=d?b+"px":"0px";r&&(m[0].style.width=w(c),u[0].style.width=w(c),u[0].style[e]=d?b+"px":"0px");l.scroll();if((a.bSorted||a.bFiltered)&&!a._drawHold)j.scrollTop= +0}}function I(a,b,c){for(var d=0,e=0,f=b.length,g,j;e").appendTo(j.find("tbody"));j.find("thead, tfoot").remove();j.append(h(a.nTHead).clone()).append(h(a.nTFoot).clone());j.find("tfoot th, tfoot td").css("width","");o=qa(a,j.find("thead")[0]);for(m=0;m").css({width:n.sWidthOrig,margin:0,padding:0,border:0,height:1}));if(a.aoData.length)for(m=0;m").css(f||e?{position:"absolute",top:0,left:0,height:1,right:0,overflow:"hidden"}:{}).append(j).appendTo(k);f&&g?j.width(g):f?(j.css("width","auto"),j.removeAttr("width"),j.width()").css("width",w(a)).appendTo(b||H.body),d=c[0].offsetWidth;c.remove();return d}function Gb(a,b){var c=Hb(a,b);if(0>c)return null;var d=a.aoData[c];return!d.nTr?h("").html(B(a,c,b,"display"))[0]:d.anCells[b]}function Hb(a,b){for(var c,d=-1,e=-1,f=0,g=a.aoData.length;fd&&(d=c.length,e=f);return e}function w(a){return null=== +a?"0px":"number"==typeof a?0>a?"0px":a+"px":a.match(/\d$/)?a+"px":a}function W(a){var b,c,d=[],e=a.aoColumns,f,g,j,i;b=a.aaSortingFixed;c=h.isPlainObject(b);var o=[];f=function(a){a.length&&!h.isArray(a[0])?o.push(a):h.merge(o,a)};h.isArray(b)&&f(b);c&&b.pre&&f(b.pre);f(a.aaSorting);c&&b.post&&f(b.post);for(a=0;ae?1:0,0!==c)return"asc"===j.dir?c:-c;c=d[a];e=d[b]; +return ce?1:0}):i.sort(function(a,b){var c,g,j,i,k=h.length,m=f[a]._aSortData,p=f[b]._aSortData;for(j=0;jg?1:0})}a.bSorted=!0}function Jb(a){for(var b,c,d=a.aoColumns,e=W(a),a=a.oLanguage.oAria,f=0,g=d.length;f/g,"");var i=c.nTh;i.removeAttribute("aria-sort");c.bSortable&&(0e?e+1:3));e=0;for(f=d.length;ee?e+1:3))}a.aLastSort=d}function Ib(a,b){var c=a.aoColumns[b],d=m.ext.order[c.sSortDataType],e;d&&(e=d.call(a.oInstance,a,b,ba(a,b)));for(var f,g=m.ext.type.order[c.sType+"-pre"],j=0,i=a.aoData.length;j=d.length?[0,c[1]]:c)}));e.search!==k&&h.extend(a.oPreviousSearch,Bb(e.search));b=0;for(c=e.columns.length;b=c&&(b=c-d);b-=b%d;if(-1===d||0>b)b=0;a._iDisplayStart=b}function Pa(a,b){var c= +a.renderer,d=m.ext.renderer[b];return h.isPlainObject(c)&&c[b]?d[c[b]]||d._:"string"===typeof c?d[c]||d._:d._}function y(a){return a.oFeatures.bServerSide?"ssp":a.ajax||a.sAjaxSource?"ajax":"dom"}function Aa(a,b){var c=[],c=Mb.numbers_length,d=Math.floor(c/2);b<=c?c=X(0,b):a<=d?(c=X(0,c-2),c.push("ellipsis"),c.push(b-1)):(a>=b-1-d?c=X(b-(c-2),b):(c=X(a-d+2,a+d-1),c.push("ellipsis"),c.push(b-1)),c.splice(0,0,"ellipsis"),c.splice(0,0,0));c.DT_el="span";return c}function db(a){h.each({num:function(b){return Ba(b, +a)},"num-fmt":function(b){return Ba(b,a,Xa)},"html-num":function(b){return Ba(b,a,Ca)},"html-num-fmt":function(b){return Ba(b,a,Ca,Xa)}},function(b,c){s.type.order[b+a+"-pre"]=c;b.match(/^html\-/)&&(s.type.search[b+a]=s.type.search.html)})}function Nb(a){return function(){var b=[za(this[m.ext.iApiIndex])].concat(Array.prototype.slice.call(arguments));return m.ext.internal[a].apply(this,b)}}var m,s,t,p,r,Ya={},Ob=/[\r\n]/g,Ca=/<.*?>/g,ac=/^[\w\+\-]/,bc=/[\w\+\-]$/,Yb=RegExp("(\\/|\\.|\\*|\\+|\\?|\\||\\(|\\)|\\[|\\]|\\{|\\}|\\\\|\\$|\\^|\\-)", +"g"),Xa=/[',$£€¥%\u2009\u202F\u20BD\u20a9\u20BArfk]/gi,M=function(a){return!a||!0===a||"-"===a?!0:!1},Pb=function(a){var b=parseInt(a,10);return!isNaN(b)&&isFinite(a)?b:null},Qb=function(a,b){Ya[b]||(Ya[b]=RegExp(va(b),"g"));return"string"===typeof a&&"."!==b?a.replace(/\./g,"").replace(Ya[b],"."):a},Za=function(a,b,c){var d="string"===typeof a;if(M(a))return!0;b&&d&&(a=Qb(a,b));c&&d&&(a=a.replace(Xa,""));return!isNaN(parseFloat(a))&&isFinite(a)},Rb=function(a,b,c){return M(a)?!0:!(M(a)||"string"=== +typeof a)?null:Za(a.replace(Ca,""),b,c)?!0:null},D=function(a,b,c){var d=[],e=0,f=a.length;if(c!==k)for(;e")[0],Zb=wa.textContent!==k,$b=/<.*?>/g;m=function(a){this.$=function(a,b){return this.api(!0).$(a,b)};this._=function(a,b){return this.api(!0).rows(a,b).data()};this.api=function(a){return a?new t(za(this[s.iApiIndex])):new t(this)};this.fnAddData=function(a,b){var c=this.api(!0), +d=h.isArray(a)&&(h.isArray(a[0])||h.isPlainObject(a[0]))?c.rows.add(a):c.row.add(a);(b===k||b)&&c.draw();return d.flatten().toArray()};this.fnAdjustColumnSizing=function(a){var b=this.api(!0).columns.adjust(),c=b.settings()[0],d=c.oScroll;a===k||a?b.draw(!1):(""!==d.sX||""!==d.sY)&&Z(c)};this.fnClearTable=function(a){var b=this.api(!0).clear();(a===k||a)&&b.draw()};this.fnClose=function(a){this.api(!0).row(a).child.hide()};this.fnDeleteRow=function(a,b,c){var d=this.api(!0),a=d.rows(a),e=a.settings()[0], +h=e.aoData[a[0][0]];a.remove();b&&b.call(this,e,h);(c===k||c)&&d.draw();return h};this.fnDestroy=function(a){this.api(!0).destroy(a)};this.fnDraw=function(a){this.api(!0).draw(a)};this.fnFilter=function(a,b,c,d,e,h){e=this.api(!0);null===b||b===k?e.search(a,c,d,h):e.column(b).search(a,c,d,h);e.draw()};this.fnGetData=function(a,b){var c=this.api(!0);if(a!==k){var d=a.nodeName?a.nodeName.toLowerCase():"";return b!==k||"td"==d||"th"==d?c.cell(a,b).data():c.row(a).data()||null}return c.data().toArray()}; +this.fnGetNodes=function(a){var b=this.api(!0);return a!==k?b.row(a).node():b.rows().nodes().flatten().toArray()};this.fnGetPosition=function(a){var b=this.api(!0),c=a.nodeName.toUpperCase();return"TR"==c?b.row(a).index():"TD"==c||"TH"==c?(a=b.cell(a).index(),[a.row,a.columnVisible,a.column]):null};this.fnIsOpen=function(a){return this.api(!0).row(a).child.isShown()};this.fnOpen=function(a,b,c){return this.api(!0).row(a).child(b,c).show().child()[0]};this.fnPageChange=function(a,b){var c=this.api(!0).page(a); +(b===k||b)&&c.draw(!1)};this.fnSetColumnVis=function(a,b,c){a=this.api(!0).column(a).visible(b);(c===k||c)&&a.columns.adjust().draw()};this.fnSettings=function(){return za(this[s.iApiIndex])};this.fnSort=function(a){this.api(!0).order(a).draw()};this.fnSortListener=function(a,b,c){this.api(!0).order.listener(a,b,c)};this.fnUpdate=function(a,b,c,d,e){var h=this.api(!0);c===k||null===c?h.row(b).data(a):h.cell(b,c).data(a);(e===k||e)&&h.columns.adjust();(d===k||d)&&h.draw();return 0};this.fnVersionCheck= +s.fnVersionCheck;var b=this,c=a===k,d=this.length;c&&(a={});this.oApi=this.internal=s.internal;for(var e in m.ext.internal)e&&(this[e]=Nb(e));this.each(function(){var e={},e=1t<"F"ip>'),n.renderer)?h.isPlainObject(n.renderer)&&!n.renderer.header&&(n.renderer.header="jqueryui"):n.renderer="jqueryui":h.extend(i,m.ext.classes,e.oClasses);q.addClass(i.sTable);n.iInitDisplayStart===k&&(n.iInitDisplayStart=e.iDisplayStart,n._iDisplayStart=e.iDisplayStart);null!==e.iDeferLoading&&(n.bDeferLoading=!0,g=h.isArray(e.iDeferLoading), +n._iRecordsDisplay=g?e.iDeferLoading[0]:e.iDeferLoading,n._iRecordsTotal=g?e.iDeferLoading[1]:e.iDeferLoading);var t=n.oLanguage;h.extend(!0,t,e.oLanguage);""!==t.sUrl&&(h.ajax({dataType:"json",url:t.sUrl,success:function(a){Fa(a);J(l.oLanguage,a);h.extend(true,t,a);ia(n)},error:function(){ia(n)}}),o=!0);null===e.asStripeClasses&&(n.asStripeClasses=[i.sStripeOdd,i.sStripeEven]);var g=n.asStripeClasses,r=q.children("tbody").find("tr").eq(0);-1!==h.inArray(!0,h.map(g,function(a){return r.hasClass(a)}))&& +(h("tbody tr",this).removeClass(g.join(" ")),n.asDestroyStripes=g.slice());u=[];g=this.getElementsByTagName("thead");0!==g.length&&(fa(n.aoHeader,g[0]),u=qa(n));if(null===e.aoColumns){p=[];g=0;for(j=u.length;g").appendTo(this));n.nTHead=j[0];j=q.children("tbody");0===j.length&&(j=h("").appendTo(this));n.nTBody=j[0];j=q.children("tfoot");if(0===j.length&&0").appendTo(this); +0===j.length||0===j.children().length?q.addClass(i.sNoFooter):0a?new t(b[a],this[a]):null},filter:function(a){var b=[];if(x.filter)b=x.filter.call(this,a,this);else for(var c=0,d=this.length;c").addClass(b),h("td",c).addClass(b).html(a)[0].colSpan=ca(d),e.push(c[0]))};f(a,b);c._details&&c._details.remove();c._details=h(e);c._detailsShow&&c._details.insertAfter(c.nTr)}return this});p(["row().child.show()","row().child().show()"],function(){Vb(this, +!0);return this});p(["row().child.hide()","row().child().hide()"],function(){Vb(this,!1);return this});p(["row().child.remove()","row().child().remove()"],function(){cb(this);return this});p("row().child.isShown()",function(){var a=this.context;return a.length&&this.length?a[0].aoData[this[0]]._detailsShow||!1:!1});var dc=/^(.+):(name|visIdx|visible)$/,Wb=function(a,b,c,d,e){for(var c=[],d=0,f=e.length;d=0?b:g.length+b];if(typeof a==="function"){var e=Da(c,f);return h.map(g,function(b,f){return a(f,Wb(c,f,0,0,e),i[f])?f:null})}var k=typeof a==="string"?a.match(dc):"";if(k)switch(k[2]){case "visIdx":case "visible":b=parseInt(k[1],10);if(b<0){var m=h.map(g,function(a,b){return a.bVisible?b:null}); +return[m[m.length+b]]}return[$(c,b)];case "name":return h.map(j,function(a,b){return a===k[1]?b:null})}else return h(i).filter(a).map(function(){return h.inArray(this,i)}).toArray()},c,f)},1);c.selector.cols=a;c.selector.opts=b;return c});r("columns().header()","column().header()",function(){return this.iterator("column",function(a,b){return a.aoColumns[b].nTh},1)});r("columns().footer()","column().footer()",function(){return this.iterator("column",function(a,b){return a.aoColumns[b].nTf},1)});r("columns().data()", +"column().data()",function(){return this.iterator("column-rows",Wb,1)});r("columns().dataSrc()","column().dataSrc()",function(){return this.iterator("column",function(a,b){return a.aoColumns[b].mData},1)});r("columns().cache()","column().cache()",function(a){return this.iterator("column-rows",function(b,c,d,e,f){return ja(b.aoData,f,"search"===a?"_aFilterData":"_aSortData",c)},1)});r("columns().nodes()","column().nodes()",function(){return this.iterator("column-rows",function(a,b,c,d,e){return ja(a.aoData, +e,"anCells",b)},1)});r("columns().visible()","column().visible()",function(a,b){return this.iterator("column",function(c,d){if(a===k)return c.aoColumns[d].bVisible;var e=c.aoColumns,f=e[d],g=c.aoData,j,i,m;if(a!==k&&f.bVisible!==a){if(a){var l=h.inArray(!0,D(e,"bVisible"),d+1);j=0;for(i=g.length;jd;return!0};m.isDataTable=m.fnIsDataTable=function(a){var b=h(a).get(0),c=!1;h.each(m.settings,function(a,e){var f=e.nScrollHead?h("table",e.nScrollHead)[0]:null,g=e.nScrollFoot?h("table",e.nScrollFoot)[0]: +null;if(e.nTable===b||f===b||g===b)c=!0});return c};m.tables=m.fnTables=function(a){var b=!1;h.isPlainObject(a)&&(b=a.api,a=a.visible);var c=h.map(m.settings,function(b){if(!a||a&&h(b.nTable).is(":visible"))return b.nTable});return b?new t(c):c};m.util={throttle:ua,escapeRegex:va};m.camelToHungarian=J;p("$()",function(a,b){var c=this.rows(b).nodes(),c=h(c);return h([].concat(c.filter(a).toArray(),c.find(a).toArray()))});h.each(["on","one","off"],function(a,b){p(b+"()",function(){var a=Array.prototype.slice.call(arguments); +a[0].match(/\.dt\b/)||(a[0]+=".dt");var d=h(this.tables().nodes());d[b].apply(d,a);return this})});p("clear()",function(){return this.iterator("table",function(a){na(a)})});p("settings()",function(){return new t(this.context,this.context)});p("init()",function(){var a=this.context;return a.length?a[0].oInit:null});p("data()",function(){return this.iterator("table",function(a){return D(a.aoData,"_aData")}).flatten()});p("destroy()",function(a){a=a||!1;return this.iterator("table",function(b){var c= +b.nTableWrapper.parentNode,d=b.oClasses,e=b.nTable,f=b.nTBody,g=b.nTHead,j=b.nTFoot,i=h(e),f=h(f),k=h(b.nTableWrapper),l=h.map(b.aoData,function(a){return a.nTr}),p;b.bDestroying=!0;v(b,"aoDestroyCallback","destroy",[b]);a||(new t(b)).columns().visible(!0);k.unbind(".DT").find(":not(tbody *)").unbind(".DT");h(E).unbind(".DT-"+b.sInstance);e!=g.parentNode&&(i.children("thead").detach(),i.append(g));j&&e!=j.parentNode&&(i.children("tfoot").detach(),i.append(j));b.aaSorting=[];b.aaSortingFixed=[];xa(b); +h(l).removeClass(b.asStripeClasses.join(" "));h("th, td",g).removeClass(d.sSortable+" "+d.sSortableAsc+" "+d.sSortableDesc+" "+d.sSortableNone);b.bJUI&&(h("th span."+d.sSortIcon+", td span."+d.sSortIcon,g).detach(),h("th, td",g).each(function(){var a=h("div."+d.sSortJUIWrapper,this);h(this).append(a.contents());a.detach()}));f.children().detach();f.append(l);g=a?"remove":"detach";i[g]();k[g]();!a&&c&&(c.insertBefore(e,b.nTableReinsertBefore),i.css("width",b.sDestroyWidth).removeClass(d.sTable),(p= +b.asDestroyStripes.length)&&f.children().each(function(a){h(this).addClass(b.asDestroyStripes[a%p])}));c=h.inArray(b,m.settings);-1!==c&&m.settings.splice(c,1)})});h.each(["column","row","cell"],function(a,b){p(b+"s().every()",function(a){var d=this.selector.opts,e=this;return this.iterator(b,function(f,g,h,i,m){a.call(e[b](g,"cell"===b?h:d,"cell"===b?d:k),g,h,i,m)})})});p("i18n()",function(a,b,c){var d=this.context[0],a=Q(a)(d.oLanguage);a===k&&(a=b);c!==k&&h.isPlainObject(a)&&(a=a[c]!==k?a[c]:a._); +return a.replace("%d",c)});m.version="1.10.10";m.settings=[];m.models={};m.models.oSearch={bCaseInsensitive:!0,sSearch:"",bRegex:!1,bSmart:!0};m.models.oRow={nTr:null,anCells:null,_aData:[],_aSortData:null,_aFilterData:null,_sFilterRow:null,_sRowStripe:"",src:null,idx:-1};m.models.oColumn={idx:null,aDataSort:null,asSorting:null,bSearchable:null,bSortable:null,bVisible:null,_sManualType:null,_bAttrSrc:!1,fnCreatedCell:null,fnGetData:null,fnSetData:null,mData:null,mRender:null,nTh:null,nTf:null,sClass:null, +sContentPadding:null,sDefaultContent:null,sName:null,sSortDataType:"std",sSortingClass:null,sSortingClassJUI:null,sTitle:null,sType:null,sWidth:null,sWidthOrig:null};m.defaults={aaData:null,aaSorting:[[0,"asc"]],aaSortingFixed:[],ajax:null,aLengthMenu:[10,25,50,100],aoColumns:null,aoColumnDefs:null,aoSearchCols:[],asStripeClasses:null,bAutoWidth:!0,bDeferRender:!1,bDestroy:!1,bFilter:!0,bInfo:!0,bJQueryUI:!1,bLengthChange:!0,bPaginate:!0,bProcessing:!1,bRetrieve:!1,bScrollCollapse:!1,bServerSide:!1, +bSort:!0,bSortMulti:!0,bSortCellsTop:!1,bSortClasses:!0,bStateSave:!1,fnCreatedRow:null,fnDrawCallback:null,fnFooterCallback:null,fnFormatNumber:function(a){return a.toString().replace(/\B(?=(\d{3})+(?!\d))/g,this.oLanguage.sThousands)},fnHeaderCallback:null,fnInfoCallback:null,fnInitComplete:null,fnPreDrawCallback:null,fnRowCallback:null,fnServerData:null,fnServerParams:null,fnStateLoadCallback:function(a){try{return JSON.parse((-1===a.iStateDuration?sessionStorage:localStorage).getItem("DataTables_"+ +a.sInstance+"_"+location.pathname))}catch(b){}},fnStateLoadParams:null,fnStateLoaded:null,fnStateSaveCallback:function(a,b){try{(-1===a.iStateDuration?sessionStorage:localStorage).setItem("DataTables_"+a.sInstance+"_"+location.pathname,JSON.stringify(b))}catch(c){}},fnStateSaveParams:null,iStateDuration:7200,iDeferLoading:null,iDisplayLength:10,iDisplayStart:0,iTabIndex:0,oClasses:{},oLanguage:{oAria:{sSortAscending:": activate to sort column ascending",sSortDescending:": activate to sort column descending"}, +oPaginate:{sFirst:"First",sLast:"Last",sNext:"Next",sPrevious:"Previous"},sEmptyTable:"No data available in table",sInfo:"Showing _START_ to _END_ of _TOTAL_ entries",sInfoEmpty:"Showing 0 to 0 of 0 entries",sInfoFiltered:"(filtered from _MAX_ total entries)",sInfoPostFix:"",sDecimal:"",sThousands:",",sLengthMenu:"Show _MENU_ entries",sLoadingRecords:"Loading...",sProcessing:"Processing...",sSearch:"Search:",sSearchPlaceholder:"",sUrl:"",sZeroRecords:"No matching records found"},oSearch:h.extend({}, +m.models.oSearch),sAjaxDataProp:"data",sAjaxSource:null,sDom:"lfrtip",searchDelay:null,sPaginationType:"simple_numbers",sScrollX:"",sScrollXInner:"",sScrollY:"",sServerMethod:"GET",renderer:null,rowId:"DT_RowId"};Y(m.defaults);m.defaults.column={aDataSort:null,iDataSort:-1,asSorting:["asc","desc"],bSearchable:!0,bSortable:!0,bVisible:!0,fnCreatedCell:null,mData:null,mRender:null,sCellType:"td",sClass:"",sContentPadding:"",sDefaultContent:null,sName:"",sSortDataType:"std",sTitle:null,sType:null,sWidth:null}; +Y(m.defaults.column);m.models.oSettings={oFeatures:{bAutoWidth:null,bDeferRender:null,bFilter:null,bInfo:null,bLengthChange:null,bPaginate:null,bProcessing:null,bServerSide:null,bSort:null,bSortMulti:null,bSortClasses:null,bStateSave:null},oScroll:{bCollapse:null,iBarWidth:0,sX:null,sXInner:null,sY:null},oLanguage:{fnInfoCallback:null},oBrowser:{bScrollOversize:!1,bScrollbarLeft:!1,bBounding:!1,barWidth:0},ajax:null,aanFeatures:[],aoData:[],aiDisplay:[],aiDisplayMaster:[],aIds:{},aoColumns:[],aoHeader:[], +aoFooter:[],oPreviousSearch:{},aoPreSearchCols:[],aaSorting:null,aaSortingFixed:[],asStripeClasses:null,asDestroyStripes:[],sDestroyWidth:0,aoRowCallback:[],aoHeaderCallback:[],aoFooterCallback:[],aoDrawCallback:[],aoRowCreatedCallback:[],aoPreDrawCallback:[],aoInitComplete:[],aoStateSaveParams:[],aoStateLoadParams:[],aoStateLoaded:[],sTableId:"",nTable:null,nTHead:null,nTFoot:null,nTBody:null,nTableWrapper:null,bDeferLoading:!1,bInitialised:!1,aoOpenRows:[],sDom:null,searchDelay:null,sPaginationType:"two_button", +iStateDuration:0,aoStateSave:[],aoStateLoad:[],oSavedState:null,oLoadedState:null,sAjaxSource:null,sAjaxDataProp:null,bAjaxDataGet:!0,jqXHR:null,json:k,oAjaxData:k,fnServerData:null,aoServerParams:[],sServerMethod:null,fnFormatNumber:null,aLengthMenu:null,iDraw:0,bDrawing:!1,iDrawError:-1,_iDisplayLength:10,_iDisplayStart:0,_iRecordsTotal:0,_iRecordsDisplay:0,bJUI:null,oClasses:{},bFiltered:!1,bSorted:!1,bSortCellsTop:null,oInit:null,aoDestroyCallback:[],fnRecordsTotal:function(){return"ssp"==y(this)? +1*this._iRecordsTotal:this.aiDisplayMaster.length},fnRecordsDisplay:function(){return"ssp"==y(this)?1*this._iRecordsDisplay:this.aiDisplay.length},fnDisplayEnd:function(){var a=this._iDisplayLength,b=this._iDisplayStart,c=b+a,d=this.aiDisplay.length,e=this.oFeatures,f=e.bPaginate;return e.bServerSide?!1===f||-1===a?b+d:Math.min(b+a,this._iRecordsDisplay):!f||c>d||-1===a?d:c},oInstance:null,sInstance:null,iTabIndex:0,nScrollHead:null,nScrollFoot:null,aLastSort:[],oPlugins:{},rowIdFn:null,rowId:null}; +m.ext=s={buttons:{},classes:{},build:"bs/dt-1.10.10",errMode:"alert",feature:[],search:[],selector:{cell:[],column:[],row:[]},internal:{},legacy:{ajax:null},pager:{},renderer:{pageButton:{},header:{}},order:{},type:{detect:[],search:{},order:{}},_unique:0,fnVersionCheck:m.fnVersionCheck,iApiIndex:0,oJUIClasses:{},sVersion:m.version};h.extend(s,{afnFiltering:s.search,aTypes:s.type.detect,ofnSearch:s.type.search,oSort:s.type.order,afnSortData:s.order,aoFeatures:s.feature,oApi:s.internal,oStdClasses:s.classes, +oPagination:s.pager});h.extend(m.ext.classes,{sTable:"dataTable",sNoFooter:"no-footer",sPageButton:"paginate_button",sPageButtonActive:"current",sPageButtonDisabled:"disabled",sStripeOdd:"odd",sStripeEven:"even",sRowEmpty:"dataTables_empty",sWrapper:"dataTables_wrapper",sFilter:"dataTables_filter",sInfo:"dataTables_info",sPaging:"dataTables_paginate paging_",sLength:"dataTables_length",sProcessing:"dataTables_processing",sSortAsc:"sorting_asc",sSortDesc:"sorting_desc",sSortable:"sorting",sSortableAsc:"sorting_asc_disabled", +sSortableDesc:"sorting_desc_disabled",sSortableNone:"sorting_disabled",sSortColumn:"sorting_",sFilterInput:"",sLengthSelect:"",sScrollWrapper:"dataTables_scroll",sScrollHead:"dataTables_scrollHead",sScrollHeadInner:"dataTables_scrollHeadInner",sScrollBody:"dataTables_scrollBody",sScrollFoot:"dataTables_scrollFoot",sScrollFootInner:"dataTables_scrollFootInner",sHeaderTH:"",sFooterTH:"",sSortJUIAsc:"",sSortJUIDesc:"",sSortJUI:"",sSortJUIAscAllowed:"",sSortJUIDescAllowed:"",sSortJUIWrapper:"",sSortIcon:"", +sJUIHeader:"",sJUIFooter:""});var Ea="",Ea="",G=Ea+"ui-state-default",ka=Ea+"css_right ui-icon ui-icon-",Xb=Ea+"fg-toolbar ui-toolbar ui-widget-header ui-helper-clearfix";h.extend(m.ext.oJUIClasses,m.ext.classes,{sPageButton:"fg-button ui-button "+G,sPageButtonActive:"ui-state-disabled",sPageButtonDisabled:"ui-state-disabled",sPaging:"dataTables_paginate fg-buttonset ui-buttonset fg-buttonset-multi ui-buttonset-multi paging_",sSortAsc:G+" sorting_asc",sSortDesc:G+" sorting_desc",sSortable:G+" sorting", +sSortableAsc:G+" sorting_asc_disabled",sSortableDesc:G+" sorting_desc_disabled",sSortableNone:G+" sorting_disabled",sSortJUIAsc:ka+"triangle-1-n",sSortJUIDesc:ka+"triangle-1-s",sSortJUI:ka+"carat-2-n-s",sSortJUIAscAllowed:ka+"carat-1-n",sSortJUIDescAllowed:ka+"carat-1-s",sSortJUIWrapper:"DataTables_sort_wrapper",sSortIcon:"DataTables_sort_icon",sScrollHead:"dataTables_scrollHead "+G,sScrollFoot:"dataTables_scrollFoot "+G,sHeaderTH:G,sFooterTH:G,sJUIHeader:Xb+" ui-corner-tl ui-corner-tr",sJUIFooter:Xb+ +" ui-corner-bl ui-corner-br"});var Mb=m.ext.pager;h.extend(Mb,{simple:function(){return["previous","next"]},full:function(){return["first","previous","next","last"]},numbers:function(a,b){return[Aa(a,b)]},simple_numbers:function(a,b){return["previous",Aa(a,b),"next"]},full_numbers:function(a,b){return["first","previous",Aa(a,b),"next","last"]},_numbers:Aa,numbers_length:7});h.extend(!0,m.ext.renderer,{pageButton:{_:function(a,b,c,d,e,f){var g=a.oClasses,j=a.oLanguage.oPaginate,i=a.oLanguage.oAria.paginate|| +{},k,l,m=0,p=function(b,d){var n,r,t,s,v=function(b){Ta(a,b.data.action,true)};n=0;for(r=d.length;n").appendTo(b);p(t,s)}else{k=null;l="";switch(s){case "ellipsis":b.append('');break;case "first":k=j.sFirst;l=s+(e>0?"":" "+g.sPageButtonDisabled);break;case "previous":k=j.sPrevious;l=s+(e>0?"":" "+g.sPageButtonDisabled);break;case "next":k=j.sNext;l=s+(e",{"class":g.sPageButton+" "+l,"aria-controls":a.sTableId,"aria-label":i[s],"data-dt-idx":m,tabindex:a.iTabIndex,id:c===0&&typeof s==="string"?a.sTableId+"_"+s:null}).html(k).appendTo(b);Wa(t,{action:s},v);m++}}}},r;try{r=h(b).find(H.activeElement).data("dt-idx")}catch(n){}p(h(b).empty(),d);r&&h(b).find("[data-dt-idx="+r+"]").focus()}}});h.extend(m.ext.type.detect,[function(a,b){var c= +b.oLanguage.sDecimal;return Za(a,c)?"num"+c:null},function(a){if(a&&!(a instanceof Date)&&(!ac.test(a)||!bc.test(a)))return null;var b=Date.parse(a);return null!==b&&!isNaN(b)||M(a)?"date":null},function(a,b){var c=b.oLanguage.sDecimal;return Za(a,c,!0)?"num-fmt"+c:null},function(a,b){var c=b.oLanguage.sDecimal;return Rb(a,c)?"html-num"+c:null},function(a,b){var c=b.oLanguage.sDecimal;return Rb(a,c,!0)?"html-num-fmt"+c:null},function(a){return M(a)||"string"===typeof a&&-1!==a.indexOf("<")?"html": +null}]);h.extend(m.ext.type.search,{html:function(a){return M(a)?a:"string"===typeof a?a.replace(Ob," ").replace(Ca,""):""},string:function(a){return M(a)?a:"string"===typeof a?a.replace(Ob," "):a}});var Ba=function(a,b,c,d){if(0!==a&&(!a||"-"===a))return-Infinity;b&&(a=Qb(a,b));a.replace&&(c&&(a=a.replace(c,"")),d&&(a=a.replace(d,"")));return 1*a};h.extend(s.type.order,{"date-pre":function(a){return Date.parse(a)||0},"html-pre":function(a){return M(a)?"":a.replace?a.replace(/<.*?>/g,"").toLowerCase(): +a+""},"string-pre":function(a){return M(a)?"":"string"===typeof a?a.toLowerCase():!a.toString?"":a.toString()},"string-asc":function(a,b){return ab?1:0},"string-desc":function(a,b){return ab?-1:0}});db("");h.extend(!0,m.ext.renderer,{header:{_:function(a,b,c,d){h(a.nTable).on("order.dt.DT",function(e,f,g,h){if(a===f){e=c.idx;b.removeClass(c.sSortingClass+" "+d.sSortAsc+" "+d.sSortDesc).addClass(h[e]=="asc"?d.sSortAsc:h[e]=="desc"?d.sSortDesc:c.sSortingClass)}})},jqueryui:function(a, +b,c,d){h("
    ").addClass(d.sSortJUIWrapper).append(b.contents()).append(h("").addClass(d.sSortIcon+" "+c.sSortingClassJUI)).appendTo(b);h(a.nTable).on("order.dt.DT",function(e,f,g,h){if(a===f){e=c.idx;b.removeClass(d.sSortAsc+" "+d.sSortDesc).addClass(h[e]=="asc"?d.sSortAsc:h[e]=="desc"?d.sSortDesc:c.sSortingClass);b.find("span."+d.sSortIcon).removeClass(d.sSortJUIAsc+" "+d.sSortJUIDesc+" "+d.sSortJUI+" "+d.sSortJUIAscAllowed+" "+d.sSortJUIDescAllowed).addClass(h[e]=="asc"?d.sSortJUIAsc: +h[e]=="desc"?d.sSortJUIDesc:c.sSortingClassJUI)}})}}});m.render={number:function(a,b,c,d,e){return{display:function(f){if("number"!==typeof f&&"string"!==typeof f)return f;var g=0>f?"-":"",h=parseFloat(f);if(isNaN(h))return f;f=Math.abs(h);h=parseInt(f,10);f=c?b+(f-h).toFixed(c).substring(2):"";return g+(d||"")+h.toString().replace(/\B(?=(\d{3})+(?!\d))/g,a)+f+(e||"")}}},text:function(){return{display:function(a){return"string"===typeof a?a.replace(//g,">").replace(/"/g,"""): +a}}}};h.extend(m.ext.internal,{_fnExternApiFunc:Nb,_fnBuildAjax:ra,_fnAjaxUpdate:lb,_fnAjaxParameters:ub,_fnAjaxUpdateDraw:vb,_fnAjaxDataSrc:sa,_fnAddColumn:Ga,_fnColumnOptions:la,_fnAdjustColumnSizing:U,_fnVisibleToColumnIndex:$,_fnColumnIndexToVisible:ba,_fnVisbleColumns:ca,_fnGetColumns:aa,_fnColumnTypes:Ia,_fnApplyColumnDefs:ib,_fnHungarianMap:Y,_fnCamelToHungarian:J,_fnLanguageCompat:Fa,_fnBrowserDetect:gb,_fnAddData:N,_fnAddTr:ma,_fnNodeToDataIndex:function(a,b){return b._DT_RowIndex!==k?b._DT_RowIndex: +null},_fnNodeToColumnIndex:function(a,b,c){return h.inArray(c,a.aoData[b].anCells)},_fnGetCellData:B,_fnSetCellData:jb,_fnSplitObjNotation:La,_fnGetObjectDataFn:Q,_fnSetObjectDataFn:R,_fnGetDataMaster:Ma,_fnClearTable:na,_fnDeleteIndex:oa,_fnInvalidate:ea,_fnGetRowElements:Ka,_fnCreateTr:Ja,_fnBuildHead:kb,_fnDrawHead:ga,_fnDraw:O,_fnReDraw:T,_fnAddOptionsHtml:nb,_fnDetectHeader:fa,_fnGetUniqueThs:qa,_fnFeatureHtmlFilter:pb,_fnFilterComplete:ha,_fnFilterCustom:yb,_fnFilterColumn:xb,_fnFilter:wb,_fnFilterCreateSearch:Qa, +_fnEscapeRegex:va,_fnFilterData:zb,_fnFeatureHtmlInfo:sb,_fnUpdateInfo:Cb,_fnInfoMacros:Db,_fnInitialise:ia,_fnInitComplete:ta,_fnLengthChange:Ra,_fnFeatureHtmlLength:ob,_fnFeatureHtmlPaginate:tb,_fnPageChange:Ta,_fnFeatureHtmlProcessing:qb,_fnProcessingDisplay:C,_fnFeatureHtmlTable:rb,_fnScrollDraw:Z,_fnApplyToChildren:I,_fnCalculateColumnWidths:Ha,_fnThrottle:ua,_fnConvertToWidth:Fb,_fnGetWidestNode:Gb,_fnGetMaxLenString:Hb,_fnStringToCss:w,_fnSortFlatten:W,_fnSort:mb,_fnSortAria:Jb,_fnSortListener:Va, +_fnSortAttachListener:Oa,_fnSortingClasses:xa,_fnSortData:Ib,_fnSaveState:ya,_fnLoadState:Kb,_fnSettingsFromNode:za,_fnLog:K,_fnMap:F,_fnBindAction:Wa,_fnCallbackReg:z,_fnCallbackFire:v,_fnLengthOverflow:Sa,_fnRenderer:Pa,_fnDataSource:y,_fnRowAttributes:Na,_fnCalculateEnd:function(){}});h.fn.dataTable=m;m.$=h;h.fn.dataTableSettings=m.settings;h.fn.dataTableExt=m.ext;h.fn.DataTable=function(a){return h(this).dataTable(a).api()};h.each(m,function(a,b){h.fn.DataTable[a]=b});return h.fn.dataTable}); + + +/*! + DataTables Bootstrap 3 integration + ©2011-2015 SpryMedia Ltd - datatables.net/license +*/ +(function(b){"function"===typeof define&&define.amd?define(["jquery","datatables.net"],function(a){return b(a,window,document)}):"object"===typeof exports?module.exports=function(a,e){a||(a=window);if(!e||!e.fn.dataTable)e=require("datatables.net")(a,e).$;return b(e,a,a.document)}:b(jQuery,window,document)})(function(b,a,e){var d=b.fn.dataTable;b.extend(!0,d.defaults,{dom:"<'row'<'col-sm-6'l><'col-sm-6'f>><'row'<'col-sm-12'tr>><'row'<'col-sm-5'i><'col-sm-7'p>>",renderer:"bootstrap"});b.extend(d.ext.classes, +{sWrapper:"dataTables_wrapper form-inline dt-bootstrap",sFilterInput:"form-control input-sm",sLengthSelect:"form-control input-sm",sProcessing:"dataTables_processing panel panel-default"});d.ext.renderer.pageButton.bootstrap=function(a,h,r,m,j,n){var o=new d.Api(a),s=a.oClasses,k=a.oLanguage.oPaginate,t=a.oLanguage.oAria.paginate||{},f,g,p=0,q=function(d,e){var l,h,i,c,m=function(a){a.preventDefault();!b(a.currentTarget).hasClass("disabled")&&o.page()!=a.data.action&&o.page(a.data.action).draw("page")}; +l=0;for(h=e.length;l",{"class":s.sPageButton+" "+g,id:0===r&&"string"===typeof c?a.sTableId+"_"+c:null}).append(b("",{href:"#", +"aria-controls":a.sTableId,"aria-label":t[c],"data-dt-idx":p,tabindex:a.iTabIndex}).html(f)).appendTo(d),a.oApi._fnBindAction(i,{action:c},m),p++)}},i;try{i=b(h).find(e.activeElement).data("dt-idx")}catch(u){}q(b(h).empty().html('

    v!!pFk)Z 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%0A^BQs4DCZW4B}kKO8NS9`Cqo^{8{ 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 h0_xt?&`~MGMfGXK1AWZq`$6SF5 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⋘9gR4W}x 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%0A5Q+e=Da|O*vL+3$ZiZIW}PM22E%x5T= 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