diff --git a/README.md b/README.md index 8c0cca9..898eefb 100644 --- a/README.md +++ b/README.md @@ -10,22 +10,23 @@ To install the trx library run the following command: pip install maltego-trx ``` -After installing you can create a new project by running the following command: +After installing, you can create a new project by running the following command: ``` bash maltego-trx start new_project ``` -This will create a folder new_project with the recommend project structure. +This will create a folder new_project with the recommended project structure. -Alternatively, you can copy either the `gunicorn` or `apache` example projects from the `demo` directory. -These also include Dockerfile and corresponding docker-compose configuration files for production deployment. +Alternatively, you can copy either the `gunicorn` or `apache` example projects from the `demo` directory. These also +include Dockerfile and corresponding docker-compose configuration files for production deployment. **Adding a Transform:** Add a new transform by creating a new python file in the "transforms" folder of your directory. -Any file in the folder where the **class name matches the filename** and the class inherits from Transform, will automatically be discovered and added to your server. +Any file in the folder where the **class name matches the filename**, and the class inherits from Transform, will +automatically be discovered and added to your server. A simple transform would look like the following: @@ -72,11 +73,13 @@ gunicorn --bind=0.0.0.0:8080 --threads=25 --workers=2 project:app ## Run a Docker Transform server -The `demo` folder provides an example project. The Docker files given can be used to setup and run your project in Docker. +The `demo` folder provides an example project. The Docker files given can be used to set up and run your project in +Docker. The Dockerfile and docker-compose file can be used to easily setup and run a development transform server. -If you have copied the `docker-compose.yml`, `Dockerfile` and `prod.yml` files into your project, then you can use the following commands to run the server in Docker. +If you have copied the `docker-compose.yml`, `Dockerfile` and `prod.yml` files into your project, then you can use the +following commands to run the server in Docker. Run the following to start the development server: @@ -115,12 +118,176 @@ The following values are not passed to local transforms, and will have dummy val - `slider`: 100 - `transformSettings`: {} +## Using the Transform Registry + +###### Added in 1.4.0 (July 2021) + +The Transform Registry enables you to annotate Transforms with metadata like display name, description, input and output +entities as well as settings. The Transform Registry will automatically generate CSV files that you can import into the +pTDS and/or your iTDS. + +### Configuring the Registry + +You can configure your registry with all the info you would normally add for every transform/seed on a TDS. We recommend +creating your registry in an extra file, traditionally called `extensions.py`, to avoid circular imports. + +```python +# extensions.py +from settings import api_key_setting + +from maltego_trx.decorator_registry import TransformRegistry + +registry = TransformRegistry( + owner="ACME Corporation", + author="John Doe ", + host_url="https://transforms.acme.org", + seed_ids=["demo"] +) + +# The rest of these attributes are optional + +# metadata +registry.version = "0.1" + +# global settings +registry.global_settings = [api_key_setting] + +# transform suffix to indicate datasource +registry.display_name_suffix = " [ACME]" + +# reference OAuth settings +registry.oauth_settings_id = ['github-oauth'] + +``` + +### Annotating Transforms + +```python +# transforms/GreetPerson.py +... +from maltego_trx.server import registry + + +@registry.register_transform(display_name='Greet Person', + input_entity='maltego.Phrase', + description='Returns a phrase greeting a person on the graph.', + output_entities=['maltego.Phrase'], + disclaimer='This disclaimer is optional and has to be accepted before this transform is run') +class GreetPerson(DiscoverableTransform): + + @classmethod + def create_entities(cls, request, response): + ... +``` + +**Pro Tip:** If the `display_name` is either `None` or `""`, the registry will try to create a display name from the class +name: + +- `DNSToIP` 'DNS To IP' +- `GreetPerson` 'Greet Person' + +### Transform Settings + +You can declare transform settings in a central location and add them to the registry. + +#### Configuring Global Settings + +These settings will apply to all transforms which can be very helpful for api keys. + +```python +# settings.py +from maltego_trx.decorator_registry import TransformSetting + +api_key_setting = TransformSetting(name='api_key', + display_name='API Key', + setting_type='string', + global_setting=True) +``` + +```python +# extensions.py +from maltego_trx.template_dir.settings import api_key_setting + +from maltego_trx.decorator_registry import TransformRegistry + +registry = TransformRegistry( + owner="ACME Corporation", + author="John Doe ", + host_url="https://transforms.acme.org", + seed_ids=["demo"] +) + +registry.global_settings = [api_key_setting] +``` + +#### Configuring Settings per Transform + +Settings that aren't required for every transform have to be added to the `register_transform` decorator explicitly. + +```python +# settings.py +... + +language_setting = TransformSetting(name='language', + display_name="Language", + setting_type='string', + default_value='en', + optional=True, + popup=True) +``` + +```python +# transforms/GreetPerson.py +... +from maltego_trx.template_dir.settings import language_setting + +from maltego_trx.transform import DiscoverableTransform + + +@registry.register_transform(display_name="Greet Person", + input_entity="maltego.Phrase", + description='Returns a phrase greeting a person on the graph.', + settings=[language_setting]) +class GreetPerson(DiscoverableTransform): + + @classmethod + def create_entities(cls, request: MaltegoMsg, response: MaltegoTransform): + language = request.getTransformSetting(language_setting.name) + ... +``` + +### Exporting the TDS Configuration + +To export the configurations, use the registry methods `write_transforms_config()` and `write_settings_config()`. These +methods have to executed after they have been registered with the TRX server. + +```python +# project.py + +import sys +import transforms + +from maltego_trx.registry import register_transform_function, register_transform_classes +from maltego_trx.server import app, application +from maltego_trx.handler import handle_run + +# register_transform_function(transform_func) +from maltego_trx.template_dir.extensions import registry + +register_transform_classes(transforms) + +registry.write_transforms_config() +registry.write_settings_config() + +handle_run(__name__, sys.argv, app) +``` + ## Legacy Transforms [Documentation](https://docs.maltego.com/support/solutions/articles/15000018299-porting-old-trx-transforms-to-the-latest-version) -If you have old TRX transforms that are written as functions, -they can be registered with the server using the `maltego_trx.registry.register_transform_function` method. +If you have old TRX transforms that are written as functions, they can be registered with the server using +the `maltego_trx.registry.register_transform_function` method. In order to port your old transforms, make two changes: @@ -143,6 +310,7 @@ To: from maltego_trx.maltego import MaltegoTransform + def old_transform(m): ``` @@ -214,6 +382,7 @@ You need to enable the `debug` filter option in the Desktop client Output window Overlays Enums are imported from `maltego_trx.overlays` *Overlay OverlayPosition:* + - `NORTH = "N"` - `SOUTH = "S"` - `WEST = "W"` @@ -222,6 +391,7 @@ Overlays Enums are imported from `maltego_trx.overlays` - `CENTER = "C"` *Overlay Type* + - `IMAGE = "image"` - `COLOUR = "colour"` - `TEXT = "text"` @@ -238,28 +408,28 @@ The request/maltego msg object given to the transform contains the information a - `Type: str`: The input entity type - `Properties: dict(str: str)`: A key-value dictionary of the input entity properties - `TransformSettings: dict(str: str)`: A key-value dictionary of the transform settings -- `Genealogy: list(dict(str: str))`: A key-value dictionary of the Entity genealogy, - this is only applicable for extended entities e.g. Website Entity +- `Genealogy: list(dict(str: str))`: A key-value dictionary of the Entity genealogy, this is only applicable for + extended entities e.g. Website Entity **Methods:** - `getProperty(name: str)`: Get a property value of the input entity - `getTransformSetting(name: str)`: Get a transform setting value -- `clearLegacyProperties()`: Delete (duplicate) legacy properties from the input entity. This will not result in -property information being lost, it will simply clear out some properties that the TRX library duplicates on all -incoming Transform requests. In older versions of TRX, these Entity properties would have a different internal ID when -sent the server than what the Maltego client would advertise in the Entity Manager UI. For a list of Entities with such -properties and their corresponding legacy and actual IDs, see `entity_property_map` in `maltego_trx/entities.py`. For -the majority of projects this distinction can be safely ignored. +- `clearLegacyProperties()`: Delete (duplicate) legacy properties from the input entity. This will not result in + property information being lost, it will simply clear out some properties that the TRX library duplicates on all + incoming Transform requests. In older versions of TRX, these Entity properties would have a different internal ID when + sent the server than what the Maltego client would advertise in the Entity Manager UI. For a list of Entities with + such properties and their corresponding legacy and actual IDs, see `entity_property_map` in `maltego_trx/entities.py`. + For the majority of projects this distinction can be safely ignored. ### Response/MaltegoTransform **Methods:** -- `addEntity(type: str, value: str) -> Entity`: Add an entity to the transform response. Returns an Entity object -created by the method. -- `addUIMessage(message: str, messageType='Inform')`: Return a UI message to the user. For message type, use a message -type constant. +- `addEntity(type: str, value: str) -> Entity`: Add an entity to the transform response. Returns an Entity object + created by the method. +- `addUIMessage(message: str, messageType='Inform')`: Return a UI message to the user. For message type, use a message + type constant. ### Entity @@ -269,10 +439,10 @@ type constant. - `setValue(value: str)`: Set the entity value - `setWeight(weight: int)`: Set the entity weight - `addDisplayInformation(content: str, title: str)`: Add display information for the entity. -- `addProperty(fieldName: str, displayName: str, matchingRule: str, value: str)`: Add a property to the entity. -Matching rule can be `strict` or `loose`. -- `addOverlay(propertyName: str, position: OverlayPosition, overlay_type: OverlayType)`: Add an overlay to the entity. -`OverlayPosition` and `OverlayType` are defined in the `maltego_tx.overlays` +- `addProperty(fieldName: str, displayName: str, matchingRule: str, value: str)`: Add a property to the entity. Matching + rule can be `strict` or `loose`. +- `addOverlay(propertyName: str, position: OverlayPosition, overlay_type: OverlayType)`: Add an overlay to the entity. + `OverlayPosition` and `OverlayType` are defined in the `maltego_tx.overlays` Overlay can be added as Text, Image or Color diff --git a/maltego_trx/__init__.py b/maltego_trx/__init__.py index 3ba22d0..12f356a 100644 --- a/maltego_trx/__init__.py +++ b/maltego_trx/__init__.py @@ -1 +1 @@ -VERSION = "1.3.8" +VERSION = "1.4.0" \ No newline at end of file diff --git a/maltego_trx/decorator_registry.py b/maltego_trx/decorator_registry.py new file mode 100644 index 0000000..fcf3b14 --- /dev/null +++ b/maltego_trx/decorator_registry.py @@ -0,0 +1,135 @@ +import os +from dataclasses import dataclass, field +from itertools import chain +from typing import List, Literal, Optional, Dict, Iterable + +from maltego_trx.utils import filter_unique, pascal_case_to_title, escape_csv_fields, export_as_csv, serialize_bool, \ + name_to_path + +TRANSFORMS_CSV_HEADER = "Owner,Author,Disclaimer,Description,Version," \ + "Name,UIName,URL,entityName," \ + "oAuthSettingId,transformSettingIDs,seedIDs" +SETTINGS_CSV_HEADER = "Name,Type,Display,DefaultValue,Optional,Popup" + + +@dataclass() +class TransformMeta: + class_name: str + display_name: str + input_entity: str + description: str + output_entities: List[str] + disclaimer: str + + +@dataclass() +class TransformSetting: + name: str + display_name: str + setting_type: Literal['string', 'boolean', 'date', 'datetime', 'daterange', 'url', 'double', 'int'] + + default_value: Optional[str] = "" + optional: bool = False + popup: bool = False + global_setting: bool = False + + @property + def id(self) -> str: + """this setting's full id for reference""" + if self.global_setting: + return "global#" + self.name + return self.name + + +@dataclass(eq=False) +class TransformRegistry: + owner: str + author: str + + host_url: str + seed_ids: List[str] + + version: str = '0.1' + display_name_suffix: str = "" + + global_settings: List[TransformSetting] = field(default_factory=list) + oauth_settings_id: Optional[str] = "" + + transform_metas: Dict[str, TransformMeta] = field(init=False, default_factory=dict) + transform_settings: Dict[str, List[TransformSetting]] = field(init=False, default_factory=dict) + + def register_transform(self, display_name: str, input_entity: str, description: str, + settings: List[TransformSetting] = None, output_entities: List[str] = None, + disclaimer: str = ""): + """ This method can be used as a decorator on transform classes. The data will be used to fill out csv config + files to be imported into a TDS. + """ + + def decorated(transform_callable: object): + cleaned_transform_name = name_to_path(transform_callable.__name__) + display = display_name or pascal_case_to_title(transform_callable.__name__) + + meta = TransformMeta(cleaned_transform_name, + display, input_entity, + description, + output_entities or [], + disclaimer) + self.transform_metas[cleaned_transform_name] = meta + + if settings: + self.transform_settings[cleaned_transform_name] = settings + + return transform_callable + + return decorated + + def write_transforms_config(self, config_path: str = "./transforms.csv", csv_line_limit: int = 100): + """Exports the collected transform meta data as a csv-file to config_path""" + global_settings_full_names = [gs.id for gs in self.global_settings] + + csv_lines = [] + for transform_name, transform_meta in self.transform_metas.items(): + meta_settings = [setting.id for setting in + self.transform_settings.get(transform_name, [])] + + transform_row = [ + self.owner, + self.author, + transform_meta.disclaimer, + transform_meta.description, + self.version, + transform_name, + transform_meta.display_name + self.display_name_suffix, + os.path.join(self.host_url, "run", transform_name), + transform_meta.input_entity, + ";".join(self.oauth_settings_id), + # combine global and transform scoped settings + ";".join(chain(meta_settings, global_settings_full_names)), + ";".join(self.seed_ids) + ] + + escaped_fields = escape_csv_fields(*transform_row) + csv_lines.append(",".join(escaped_fields)) + + export_as_csv(TRANSFORMS_CSV_HEADER, csv_lines, config_path, csv_line_limit) + + def write_settings_config(self, config_path: str = "./settings.csv", csv_line_limit: int = 100): + """Exports the collected settings meta data as a csv-file to config_path""" + chained_settings = chain(self.global_settings, *list(self.transform_settings.values())) + unique_settings: Iterable[TransformSetting] = filter_unique(lambda s: s.name, chained_settings) + + csv_lines = [] + for setting in unique_settings: + setting_row = [ + setting.id, + setting.setting_type, + setting.display_name, + setting.default_value or "", + serialize_bool(setting.optional, 'True', 'False'), + serialize_bool(setting.popup, 'Yes', 'No') + ] + + escaped_fields = escape_csv_fields(*setting_row) + csv_lines.append(",".join(escaped_fields)) + + export_as_csv(SETTINGS_CSV_HEADER, csv_lines, config_path, csv_line_limit) diff --git a/maltego_trx/template_dir/extensions.py b/maltego_trx/template_dir/extensions.py new file mode 100644 index 0000000..2ff99fa --- /dev/null +++ b/maltego_trx/template_dir/extensions.py @@ -0,0 +1,23 @@ +from maltego_trx.decorator_registry import TransformRegistry + +registry = TransformRegistry( + owner="ACME Corporation", + author="John Doe ", + host_url="https://transforms.acme.com", + seed_ids=["demo"] +) + +# The rest of these attributes are optional + +# metadata +registry.version = "0.1" + +# global settings +# from maltego_trx.template_dir.settings import api_key_setting +# registry.global_settings = [api_key_setting] + +# transform suffix to indicate datasource +# registry.display_name_suffix = " [ACME]" + +# reference OAuth settings +# registry.oauth_settings_id = ['github-oauth'] diff --git a/maltego_trx/template_dir/project.py b/maltego_trx/template_dir/project.py index 09ab5f6..fb841ca 100644 --- a/maltego_trx/template_dir/project.py +++ b/maltego_trx/template_dir/project.py @@ -4,8 +4,12 @@ from maltego_trx.registry import register_transform_function, register_transform_classes from maltego_trx.server import app, application from maltego_trx.handler import handle_run +from maltego_trx.template_dir.extensions import registry # register_transform_function(transform_func) register_transform_classes(transforms) +registry.write_transforms_config() +registry.write_settings_config() + handle_run(__name__, sys.argv, app) diff --git a/maltego_trx/template_dir/settings.py b/maltego_trx/template_dir/settings.py new file mode 100644 index 0000000..1373702 --- /dev/null +++ b/maltego_trx/template_dir/settings.py @@ -0,0 +1,13 @@ +from maltego_trx.decorator_registry import TransformSetting + +api_key_setting = TransformSetting(name='api_key', + display_name='API Key', + setting_type='string', + global_setting=True) + +language_setting = TransformSetting(name='language', + display_name="Language", + setting_type='string', + default_value='en', + optional=True, + popup=True) diff --git a/maltego_trx/template_dir/transforms/GreetPersonLocalized.py b/maltego_trx/template_dir/transforms/GreetPersonLocalized.py new file mode 100644 index 0000000..3be6df3 --- /dev/null +++ b/maltego_trx/template_dir/transforms/GreetPersonLocalized.py @@ -0,0 +1,28 @@ +from maltego_trx.entities import Phrase +from maltego_trx.maltego import MaltegoTransform, MaltegoMsg +from maltego_trx.template_dir.extensions import registry +from maltego_trx.template_dir.settings import language_setting + +from maltego_trx.transform import DiscoverableTransform + + +@registry.register_transform(display_name="Greet Person (localized)", input_entity="maltego.Phrase", + description='Returns a localized phrase greeting a person on the graph.', + settings=[language_setting], + output_entities=["maltego.Phrase"]) +class GreetPersonLocalized(DiscoverableTransform): + + @classmethod + def create_entities(cls, request: MaltegoMsg, response: MaltegoTransform): + person_name = request.Value + + language: str = request.getTransformSetting(language_setting.id).lower() + + if language == 'af': + greeting = f"Hallo {person_name}, lekker om jou te ontmoet!" + elif language == "de": + greeting = f"Moin {person_name}, schön dich kennen zu lernen!" + else: + greeting = f"Hello {person_name}, nice to meet you!" + + response.addEntity(Phrase, greeting) diff --git a/maltego_trx/utils.py b/maltego_trx/utils.py index e5b349a..272d87d 100644 --- a/maltego_trx/utils.py +++ b/maltego_trx/utils.py @@ -1,4 +1,7 @@ import re +from typing import TypeVar, Callable, Hashable, Iterable, Generator, List, Sequence + +import math from six import text_type, binary_type @@ -45,4 +48,68 @@ def remove_invalid_xml_chars(val): """ val = make_utf8(val) val = re.sub(u'[^\u0020-\uD7FF\u0009\u000A\u000D\uE000-\uFFFD\U00010000-\U0010FFFF]+', '?', val) - return val \ No newline at end of file + return val + + +T = TypeVar('T') + + +def filter_unique(get_identifier: Callable[[T], Hashable], collection: Iterable[T]) -> Generator[T, None, None]: + seen = set() + for item in collection: + identifier = get_identifier(item) + + if identifier in seen: + continue + + seen.add(identifier) + + yield item + + +def chunk_list(data: Sequence[T], max_chunk_size: int) -> Generator[Sequence[T], None, None]: + # math.ceil: + # number_of_chunks: decimal-places == 0 -> perfect split + # number_of_chunks: decimal-places > 0 -> need one more list to keep len(chunk) <= max_chunk_size + + number_of_chunks = math.ceil(len(data) / max_chunk_size) + chunk_size = math.ceil(len(data) / number_of_chunks) + + for idx in range(0, len(data), chunk_size): + yield data[idx:idx + chunk_size] + + +def pascal_case_to_title(name: str) -> str: + # https://stackoverflow.com/a/1176023 + name = re.sub('(.)([A-Z][a-z]+)', r'\1 \2', name) + return re.sub('([a-z0-9])([A-Z])', r'\1 \2', name) + + +def escape_csv_fields(*fields: str, separator: str = ',') -> Generator[str, None, None]: + """if a field contains the separator, it will be quoted""" + for f in fields: + yield f'"{f}"' if separator in f else f + + +def export_as_csv(header: str, lines: Sequence[str], export_file_path: str, csv_line_limit: int = -1): + """export a file in as many files as needed to stay below the csv_line_limit (plus header)""" + if csv_line_limit == -1 or len(lines) <= csv_line_limit: + with open(export_file_path, "w+") as csv_file: + csv_file.write(header + "\n") + csv_file.writelines(map(lambda x: x + "\n", lines)) + + return + + # split file to speed-up import into pTDS, iTDS + chunks = list(chunk_list(lines, csv_line_limit)) + for idx, chunk in enumerate(chunks, 1): + path, extension = export_file_path.rsplit(".", 1) + chunked_config_path = f"{path}_{idx}-{len(chunks)}.{extension}" + + with open(chunked_config_path, "w+") as csv_file: + csv_file.write(header + "\n") + csv_file.writelines(map(lambda x: x + "\n", chunk)) + + +def serialize_bool(boolean: bool, serialized_true: str, serialized_false: str) -> str: + return serialized_true if boolean else serialized_false diff --git a/tests/requirements.txt b/tests/requirements.txt new file mode 100644 index 0000000..76377c7 --- /dev/null +++ b/tests/requirements.txt @@ -0,0 +1,3 @@ +pytest==6.2.4 + +petname==2.6 \ No newline at end of file diff --git a/tests/test_registry.py b/tests/test_registry.py new file mode 100644 index 0000000..2200e97 --- /dev/null +++ b/tests/test_registry.py @@ -0,0 +1,165 @@ +import os +import random +from typing import NamedTuple, List + +import petname +import pytest as pytest + +from maltego_trx.decorator_registry import TransformSetting, TransformRegistry, TRANSFORMS_CSV_HEADER, \ + SETTINGS_CSV_HEADER +from maltego_trx.server import app +from maltego_trx.utils import name_to_path, serialize_bool + + +@pytest.fixture +def client(): + with app.test_client() as client: + yield client + + +@pytest.fixture +def registry(): + registry: TransformRegistry = TransformRegistry(owner="Maltego Technologies GmbH", + author="Maltego Support", + host_url="localhost", + seed_ids=["demo"]) + return registry + + +def make_transform_setting(): + name = petname.generate() + setting_type = random.choice(['string', 'boolean', 'date', 'datetime', 'daterange', 'url', 'double', 'int']) + + return TransformSetting(name=name, + display_name=name.title(), + setting_type=random.choice(setting_type), + default_value=petname.generate(), + optional=random.choice([True, False]), + popup=random.choice([True, False]), + global_setting=random.choice([True, False])) + + +def make_transform(registry: TransformRegistry, settings: List[TransformSetting] = None): + display_name = petname.generate(separator=" ") + input_entity = petname.generate(separator=".") + description = petname.generate(words=10, separator=" ").title() + "." + settings = settings or [make_transform_setting(), make_transform_setting()] + output_entities = petname.generate(3).split("-") + disclaimer = petname.generate(words=10, separator=" ").title() + "." + + @registry.register_transform(display_name, input_entity, description, settings, output_entities, disclaimer) + class TestClass: + pass + + return TestClass + + +def test_register_transform_decorator(registry): + test_settings = [make_transform_setting(), make_transform_setting()] + + display_name = petname.generate(separator=" ") + input_entity = petname.generate(separator=".") + description = petname.generate(words=10, separator=" ").title() + "." + output_entities = petname.generate(3).split("-") + disclaimer = petname.generate(words=10, separator=" ").title() + "." + + @registry.register_transform(display_name, input_entity, description, test_settings, output_entities, disclaimer) + class TestClass: + pass + + path_name = name_to_path(TestClass.__name__) + + tx_meta = registry.transform_metas.get(path_name) + + assert tx_meta + assert tx_meta.display_name == display_name + assert tx_meta.input_entity == input_entity + assert tx_meta.description == description + assert tx_meta.disclaimer == disclaimer + + assert test_settings == registry.transform_settings[path_name] + + +class TransformCsvLine(NamedTuple): + owner: str + author: str + disclaimer: str + description: str + version: str + name: str + display_name: str + host: str + input_entity: str + oauth_id: str + settings_ids: str + seed_ids: str + + +class SettingCsvLine(NamedTuple): + name: str + setting_type: str + display_name: str + default: str + optional: str + popup: str + + +def test_transform_to_csv(registry): + random_class = make_transform(registry) + + path_name = name_to_path(random_class.__name__) + + tx_meta = registry.transform_metas.get(path_name) + tx_settings = registry.transform_settings.get(path_name, []) + + registry.write_transforms_config() + + with open("./transforms.csv") as transforms_csv: + header = next(transforms_csv) + assert header.rstrip("\n") == TRANSFORMS_CSV_HEADER + + line = next(transforms_csv).rstrip("\n") + data: TransformCsvLine = TransformCsvLine(*line.split(',')) + + assert data.owner == registry.owner + assert data.author == registry.author + assert data.disclaimer == tx_meta.disclaimer + assert data.description == tx_meta.description + assert data.version == registry.version + assert data.name == tx_meta.class_name + assert data.display_name == tx_meta.display_name + assert data.host == os.path.join(registry.host_url, "run", path_name) + assert data.input_entity == tx_meta.input_entity + assert data.oauth_id == registry.oauth_settings_id + assert data.settings_ids.split(";") == [s.id for s in tx_settings] + assert data.seed_ids.split(";") == registry.seed_ids + + +def test_setting_to_csv(registry): + local_setting = make_transform_setting() + local_setting.global_setting = False + + global_setting = make_transform_setting() + global_setting.global_setting = True + + registry.global_settings.append(global_setting) + + @registry.register_transform("", "", "", settings=[local_setting]) + class TestClass: + pass + + registry.write_settings_config() + with open("./settings.csv") as settings_csv: + header = next(settings_csv) + assert header.rstrip("\n") == SETTINGS_CSV_HEADER + + for line, setting in zip(settings_csv.readlines(), [global_setting, local_setting]): + line = line.rstrip("\n") + data: SettingCsvLine = SettingCsvLine(*line.split(',')) + + assert data.name == setting.id + assert data.setting_type == setting.setting_type + assert data.display_name == setting.display_name + assert data.default == setting.default_value + assert data.optional == serialize_bool(setting.optional, 'True', 'False') + assert data.popup == serialize_bool(setting.popup, 'Yes', 'No')