Commit 2aaef7da authored by gmantele's avatar gmantele
Browse files

TAP: Add test for the previous commit about MaxRecController.java

+ Add the management of the Output Limit in TAPConfiguration
+ All the corresponding JUnit Tests
+ Replacing .properties test files for JUnit by a construction of Properties object
+ Update of the HTML documentation page and of the Full example file
parent fbff829a
Loading
Loading
Loading
Loading
+221 −32
Original line number Diff line number Diff line
package tap.config;

import static tap.config.TAPConfiguration.*;
/*
 * This file is part of TAPLibrary.
 * 
 * TAPLibrary is free software: you can redistribute it and/or modify
 * it under the terms of the GNU Lesser General Public License as published by
 * the Free Software Foundation, either version 3 of the License, or
 * (at your option) any later version.
 * 
 * TAPLibrary is distributed in the hope that it will be useful,
 * but WITHOUT ANY WARRANTY; without even the implied warranty of
 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
 * GNU Lesser General Public License for more details.
 * 
 * You should have received a copy of the GNU Lesser General Public License
 * along with TAPLibrary.  If not, see <http://www.gnu.org/licenses/>.
 * 
 * Copyright 2013 - Astronomisches Rechen Institute (ARI)
 */

import static tap.config.TAPConfiguration.DEFAULT_DIRECTORY_PER_USER;
import static tap.config.TAPConfiguration.DEFAULT_EXECUTION_DURATION;
import static tap.config.TAPConfiguration.DEFAULT_GROUP_USER_DIRECTORIES;
import static tap.config.TAPConfiguration.DEFAULT_IS_AVAILABLE;
import static tap.config.TAPConfiguration.DEFAULT_RETENTION_PERIOD;
import static tap.config.TAPConfiguration.KEY_DEFAULT_EXECUTION_DURATION;
import static tap.config.TAPConfiguration.KEY_DEFAULT_OUTPUT_LIMIT;
import static tap.config.TAPConfiguration.KEY_DEFAULT_RETENTION_PERIOD;
import static tap.config.TAPConfiguration.KEY_DIRECTORY_PER_USER;
import static tap.config.TAPConfiguration.KEY_DISABILITY_REASON;
import static tap.config.TAPConfiguration.KEY_FILE_MANAGER;
import static tap.config.TAPConfiguration.KEY_FILE_ROOT_PATH;
import static tap.config.TAPConfiguration.KEY_GROUP_USER_DIRECTORIES;
import static tap.config.TAPConfiguration.KEY_IS_AVAILABLE;
import static tap.config.TAPConfiguration.KEY_MAX_EXECUTION_DURATION;
import static tap.config.TAPConfiguration.KEY_MAX_OUTPUT_LIMIT;
import static tap.config.TAPConfiguration.KEY_MAX_RETENTION_PERIOD;
import static tap.config.TAPConfiguration.KEY_OUTPUT_FORMATS;
import static tap.config.TAPConfiguration.KEY_PROVIDER_NAME;
import static tap.config.TAPConfiguration.KEY_SERVICE_DESCRIPTION;
import static tap.config.TAPConfiguration.VALUE_CSV;
import static tap.config.TAPConfiguration.VALUE_JSON;
import static tap.config.TAPConfiguration.VALUE_LOCAL;
import static tap.config.TAPConfiguration.VALUE_SV;
import static tap.config.TAPConfiguration.VALUE_TSV;
import static tap.config.TAPConfiguration.fetchClass;
import static tap.config.TAPConfiguration.getProperty;
import static tap.config.TAPConfiguration.isClassPath;
import static tap.config.TAPConfiguration.parseLimit;

import java.io.File;
import java.lang.reflect.InvocationTargetException;
import java.sql.ResultSet;
import java.util.ArrayList;
import java.util.Collection;
import java.util.Iterator;
import java.util.Properties;
@@ -15,12 +63,20 @@ import tap.TAPFactory;
import tap.file.LocalTAPFileManager;
import tap.file.TAPFileManager;
import tap.formatter.OutputFormat;
import tap.formatter.ResultSet2JsonFormatter;
import tap.formatter.ResultSet2SVFormatter;
import tap.formatter.ResultSet2VotableFormatter;
import tap.log.DefaultTAPLog;
import tap.log.TAPLog;
import tap.metadata.TAPMetadata;
import uws.UWSException;
import uws.service.UserIdentifier;

/**
 * 
 * @author Gr&eacute;gory Mantelet (ARI) - gmantele@ari.uni-heidelberg.de
 * @version 1.1 (12/2013)
 */
public final class DefaultServiceConnection implements ServiceConnection<ResultSet> {

	private TAPFileManager fileManager;
@@ -38,6 +94,11 @@ public final class DefaultServiceConnection implements ServiceConnection<ResultS
	private int[] executionDuration = new int[2];
	private int[] retentionPeriod = new int[2];

	private final ArrayList<OutputFormat<ResultSet>> outputFormats;

	private int[] outputLimits = new int[2];
	private LimitUnit[] outputLimitTypes = new LimitUnit[2];

	public DefaultServiceConnection(final Properties tapConfig) throws NullPointerException, TAPException, UWSException{
		// 1. INITIALIZE THE FILE MANAGER:
		initFileManager(tapConfig);
@@ -55,6 +116,15 @@ public final class DefaultServiceConnection implements ServiceConnection<ResultS
		initRetentionPeriod(tapConfig);
		initExecutionDuration(tapConfig);

		// 5. CONFIGURE OUTPUT:
		// default output format = VOTable:
		outputFormats = new ArrayList<OutputFormat<ResultSet>>(1);
		outputFormats.add(new ResultSet2VotableFormatter(this));
		// set additional output formats:
		addOutputFormats(tapConfig);
		// set output limits:
		initOutputLimits(tapConfig);

		// 5. MAKE THE SERVICE AVAILABLE (or not, depending on the property value):
		String propValue = getProperty(tapConfig, KEY_IS_AVAILABLE);
		isAvailable = (propValue == null) ? DEFAULT_IS_AVAILABLE : Boolean.parseBoolean(propValue);
@@ -93,9 +163,9 @@ public final class DefaultServiceConnection implements ServiceConnection<ResultS
		}
		// CUSTOM file manager:
		else{
			Class<TAPFileManager> classObj = fetchClass(fileManagerType, KEY_FILE_MANAGER, TAPFileManager.class);
			Class<? extends TAPFileManager> classObj = fetchClass(fileManagerType, KEY_FILE_MANAGER, TAPFileManager.class);
			if (classObj == null)
				throw new TAPException("Unknown value for the propertie \"" + KEY_FILE_MANAGER + "\": \"" + fileManagerType + "\". Only two possible values: " + VALUE_LOCAL + " or a class path between {...}.");
				throw new TAPException("Unknown value for the property \"" + KEY_FILE_MANAGER + "\": \"" + fileManagerType + "\". Only two possible values: " + VALUE_LOCAL + " or a class path between {...}.");

			try{
				fileManager = classObj.getConstructor(Properties.class).newInstance(tapConfig);
@@ -155,6 +225,93 @@ public final class DefaultServiceConnection implements ServiceConnection<ResultS
			executionDuration[0] = executionDuration[1];
	}

	@SuppressWarnings({"unchecked","rawtypes"})
	private void addOutputFormats(final Properties tapConfig) throws TAPException{
		// Fetch the value of the property for additional output formats:
		String formats = TAPConfiguration.getProperty(tapConfig, KEY_OUTPUT_FORMATS);

		// Since it is a comma separated list of output formats, a loop will parse this list comma by comma:
		String f;
		int indexSep;
		while(formats != null && formats.length() > 0){
			// Get a format item from the list:
			indexSep = formats.indexOf(',');
			// no comma => only one format
			if (indexSep < 0){
				f = formats;
				formats = null;
			}
			// comma at the first position => empty list item => go to the next item
			else if (indexSep == 0){
				formats = formats.substring(1).trim();
				continue;
			}
			// else => get the first format item, and then remove it from the list for the next iteration
			else{
				f = formats.substring(0, indexSep).trim();
				formats = formats.substring(indexSep + 1).trim();
			}

			// Identify the format and append it to the output format list of the service:
			// JSON
			if (f.equalsIgnoreCase(VALUE_JSON))
				outputFormats.add(new ResultSet2JsonFormatter(this));
			// CSV
			else if (f.equalsIgnoreCase(VALUE_CSV))
				outputFormats.add(new ResultSet2SVFormatter(this, ",", true));
			// TSV
			else if (f.equalsIgnoreCase(VALUE_TSV))
				outputFormats.add(new ResultSet2SVFormatter(this, "\t", true));
			// any SV (separated value) format
			else if (f.toLowerCase().startsWith(VALUE_SV)){
				// get the separator:
				int endSep = f.indexOf(')');
				if (VALUE_SV.length() < f.length() && f.charAt(VALUE_SV.length()) == '(' && endSep > VALUE_SV.length() + 1){
					String separator = f.substring(VALUE_SV.length() + 1, f.length() - 1);
					// get the MIME type and its alias, if any of them is provided:
					String mimeType = null, shortMimeType = null;
					if (endSep + 1 < f.length() && f.charAt(endSep + 1) == ':'){
						int endMime = f.indexOf(':', endSep + 2);
						if (endMime < 0)
							mimeType = f.substring(endSep + 2, f.length());
						else if (endMime > 0){
							mimeType = f.substring(endSep + 2, endMime);
							shortMimeType = f.substring(endMime + 1);
						}
					}
					// add the defined SV(...) format:
					outputFormats.add(new ResultSet2SVFormatter(this, separator, true, mimeType, shortMimeType));
				}else
					throw new TAPException("Missing separator char/string for the SV output format: \"" + f + "\"!");
			}
			// custom OutputFormat
			else if (isClassPath(f)){
				Class<? extends OutputFormat> userOutputFormatClass = fetchClass(f, KEY_OUTPUT_FORMATS, OutputFormat.class);
				try{
					OutputFormat<ResultSet> userOutputFormat = userOutputFormatClass.getConstructor(ServiceConnection.class).newInstance(this);
					outputFormats.add(userOutputFormat);
				}catch(InstantiationException | IllegalAccessException | IllegalArgumentException | InvocationTargetException | NoSuchMethodException | SecurityException e){
					throw new TAPException("Impossible to create an OutputFormat<ResultSet> instance with the constructor (ServiceConnection<ResultSet>) of \"" + userOutputFormatClass.getName() + "\" (see the property output_add_format) for the following reason: " + e.getMessage());
				}
			}
			// unknown format
			else
				throw new TAPException("Unknown output format: " + f);
		}
	}

	private void initOutputLimits(final Properties tapConfig) throws TAPException{
		Object[] limit = parseLimit(getProperty(tapConfig, KEY_DEFAULT_OUTPUT_LIMIT), KEY_DEFAULT_OUTPUT_LIMIT, false);
		outputLimitTypes[0] = (LimitUnit)limit[1];
		setDefaultOutputLimit((int)limit[0]);

		limit = parseLimit(getProperty(tapConfig, KEY_MAX_OUTPUT_LIMIT), KEY_DEFAULT_OUTPUT_LIMIT, false);
		outputLimitTypes[1] = (LimitUnit)limit[1];

		if (!setMaxOutputLimit((int)limit[0]))
			throw new TAPException("The default output limit (here: " + outputLimits[0] + ") MUST be less or equal to the maximum output limit (here: " + limit[0] + ")!");
	}

	@Override
	public String getProviderName(){
		return providerName;
@@ -226,51 +383,59 @@ public final class DefaultServiceConnection implements ServiceConnection<ResultS
	}

	@Override
	public int[] getOutputLimit(){
		// TODO Auto-generated method stub
		return null;
	public Iterator<OutputFormat<ResultSet>> getOutputFormats(){
		return outputFormats.iterator();
	}

	@Override
	public tap.ServiceConnection.LimitUnit[] getOutputLimitType(){
		// TODO Auto-generated method stub
	public OutputFormat<ResultSet> getOutputFormat(final String mimeOrAlias){
		if (mimeOrAlias == null || mimeOrAlias.trim().isEmpty())
			return null;
	}

	@Override
	public UserIdentifier getUserIdentifier(){
		// TODO Auto-generated method stub
		for(OutputFormat<ResultSet> f : outputFormats){
			if ((f.getMimeType() != null && f.getMimeType().equalsIgnoreCase(mimeOrAlias)) || (f.getShortMimeType() != null && f.getShortMimeType().equalsIgnoreCase(mimeOrAlias)))
				return f;
		}
		return null;
	}

	@Override
	public boolean uploadEnabled(){
		// TODO Auto-generated method stub
	public void addOutputFormat(final OutputFormat<ResultSet> newOutputFormat){
		outputFormats.add(newOutputFormat);
	}

	public boolean removeOutputFormat(final String mimeOrAlias){
		OutputFormat<ResultSet> of = getOutputFormat(mimeOrAlias);
		if (of != null)
			return outputFormats.remove(of);
		else
			return false;
	}

	@Override
	public int[] getUploadLimit(){
		// TODO Auto-generated method stub
		return null;
	public int[] getOutputLimit(){
		return outputLimits;
	}

	@Override
	public tap.ServiceConnection.LimitUnit[] getUploadLimitType(){
		// TODO Auto-generated method stub
		return null;
	public boolean setDefaultOutputLimit(final int limit){
		if ((outputLimits[1] <= 0) || (limit > 0 && limit <= outputLimits[1])){
			outputLimits[0] = limit;
			return true;
		}else
			return false;
	}

	@Override
	public int getMaxUploadSize(){
		// TODO Auto-generated method stub
		return 0;
	public boolean setMaxOutputLimit(final int limit){
		if (limit > 0 && outputLimits[0] > 0 && limit < outputLimits[0])
			return false;
		else{
			outputLimits[1] = limit;
			return true;
		}
	}

	@Override
	public TAPMetadata getTAPMetadata(){
		// TODO Auto-generated method stub
		return null;
	public final LimitUnit[] getOutputLimitType(){
		return new LimitUnit[]{LimitUnit.rows,LimitUnit.rows};
	}

	@Override
@@ -294,13 +459,37 @@ public final class DefaultServiceConnection implements ServiceConnection<ResultS
	}

	@Override
	public Iterator<OutputFormat<ResultSet>> getOutputFormats(){
	public UserIdentifier getUserIdentifier(){
		// TODO Auto-generated method stub
		return null;
	}

	@Override
	public boolean uploadEnabled(){
		// TODO Auto-generated method stub
		return false;
	}

	@Override
	public int[] getUploadLimit(){
		// TODO Auto-generated method stub
		return null;
	}

	@Override
	public LimitUnit[] getUploadLimitType(){
		// TODO Auto-generated method stub
		return null;
	}

	@Override
	public OutputFormat<ResultSet> getOutputFormat(String mimeOrAlias){
	public int getMaxUploadSize(){
		// TODO Auto-generated method stub
		return 0;
	}

	@Override
	public TAPMetadata getTAPMetadata(){
		// TODO Auto-generated method stub
		return null;
	}
+129 −2
Original line number Diff line number Diff line
package tap.config;

/*
 * This file is part of TAPLibrary.
 * 
 * TAPLibrary is free software: you can redistribute it and/or modify
 * it under the terms of the GNU Lesser General Public License as published by
 * the Free Software Foundation, either version 3 of the License, or
 * (at your option) any later version.
 * 
 * TAPLibrary is distributed in the hope that it will be useful,
 * but WITHOUT ANY WARRANTY; without even the implied warranty of
 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
 * GNU Lesser General Public License for more details.
 * 
 * You should have received a copy of the GNU Lesser General Public License
 * along with TAPLibrary.  If not, see <http://www.gnu.org/licenses/>.
 * 
 * Copyright 2013 - Astronomisches Rechen Institute (ARI)
 */

import java.io.File;
import java.io.FileInputStream;
import java.util.Enumeration;
import java.util.HashMap;
import java.util.Properties;

import tap.ServiceConnection.LimitUnit;
import tap.TAPException;
import tap.backup.DefaultTAPBackupManager;

/**
 * 
 * @author Gr&eacute;gory Mantelet (ARI) - gmantele@ari.uni-heidelberg.de
 * @version 1.1 (12/2013)
 */
public final class TAPConfiguration {

	/* FILE MANAGER KEYS */
@@ -61,6 +86,15 @@ public final class TAPConfiguration {
	public final static boolean DEFAULT_IS_AVAILABLE = true;
	public final static String KEY_DISABILITY_REASON = "disability_reason";

	/* OUTPUT KEYS */
	public final static String KEY_OUTPUT_FORMATS = "output_add_formats";
	public final static String VALUE_JSON = "json";
	public final static String VALUE_CSV = "csv";
	public final static String VALUE_TSV = "tsv";
	public final static String VALUE_SV = "sv";
	public final static String KEY_DEFAULT_OUTPUT_LIMIT = "output_default_limit";
	public final static String KEY_MAX_OUTPUT_LIMIT = "output_max_limit";

	/**
	 * Read the asked property from the given Properties object.
	 * 	- The returned property value is trimmed (no space at the beginning and at the end of the string).
@@ -112,7 +146,7 @@ public final class TAPConfiguration {
	 * @see {@link #isClassPath(String)}
	 */
	@SuppressWarnings("unchecked")
	public final static < C > Class<C> fetchClass(final String value, final String propertyName, final Class<C> expectedType) throws TAPException{
	public final static < C > Class<? extends C> fetchClass(final String value, final String propertyName, final Class<C> expectedType) throws TAPException{
		if (!isClassPath(value))
			return null;

@@ -121,7 +155,7 @@ public final class TAPConfiguration {
			return null;

		try{
			Class<C> classObject = (Class<C>)ClassLoader.getSystemClassLoader().loadClass(classPath);
			Class<? extends C> classObject = (Class<? extends C>)ClassLoader.getSystemClassLoader().loadClass(classPath);
			if (!expectedType.isAssignableFrom(classObject))
				throw new TAPException("The class specified by the property " + propertyName + " (" + value + ") is not implementing " + expectedType.getName() + ".");
			else
@@ -133,6 +167,99 @@ public final class TAPConfiguration {
		}
	}

	/**
	 * <p>Lets parsing a limit (for output, upload, ...) with its numeric value and its unit.</p>
	 * <p>
	 * 	Here is the expected syntax: num_val[unit].
	 * 	Where unit is optional and should be one of the following values: r or R, B, kB, MB, GB.
	 * 	If the unit is not specified, it is set by default to ROWS.
	 * </p>
	 * 
	 * @param value				Property value (must follow the limit syntax: num_val[unit] ; ex: 20kB or 2000 (for 2000 rows)).
	 * @param propertyName		Name of the property which specify the limit.
	 * @param areBytesAllowed	Tells whether the unit BYTES is allowed. If not and a BYTES unit is encountered, then an exception is thrown.
	 * 
	 * @return	An array with always 2 items: [0]=numeric value (of type Integer), [1]=unit (of type {@link LimitUnit}).
	 * 
	 * @throws TAPException	If the syntax is incorrect or if a not allowed unit has been used.
	 */
	public final static Object[] parseLimit(String value, final String propertyName, final boolean areBytesAllowed) throws TAPException{
		// Remove any whitespace inside or outside the numeric value and its unit:
		if (value != null)
			value = value.replaceAll("\\s", "");

		// If empty value, return an infinite limit:
		if (value == null || value.length() == 0)
			return new Object[]{-1,LimitUnit.rows};

		// A. Parse the string from the end in order to extract the unit part.
		//    The final step of the loop is the extraction of the numeric value, when the first digit is encountered.
		int numValue = -1;
		LimitUnit unit;
		StringBuffer buf = new StringBuffer();
		for(int i = value.length() - 1; i >= 0; i--){
			// if a digit, extract the numeric value:
			if (value.charAt(i) >= '0' && value.charAt(i) <= '9'){
				try{
					numValue = Integer.parseInt(value.substring(0, i + 1));
					break;
				}catch(NumberFormatException nfe){
					throw new TAPException("Numeric value expected for the property " + propertyName + " for the substring \"" + value.substring(0, i + 1) + "\" of the whole value: \"" + value + "\"!");
				}
			}
			// if a character, store it for later processing:
			else
				buf.append(value.charAt(i));

		}

		// B. Parse the unit.
		// if no unit, set ROWS by default:
		if (buf.length() == 0)
			unit = LimitUnit.rows;
		// if the unit is too long, throw an exception:
		else if (buf.length() > 2)
			throw new TAPException("Unknown limit unit (" + buf.reverse().toString() + ") for the property " + propertyName + ": \"" + value + "\"!");
		// try to identify the unit:
		else{
			// the base unit: bytes or rows
			switch(buf.charAt(0)){
				case 'B':
					if (!areBytesAllowed)
						throw new TAPException("BYTES unit is not allowed for the property " + propertyName + " (" + value + ")!");
					unit = LimitUnit.bytes;
					break;
				case 'r':
				case 'R':
					unit = LimitUnit.rows;
					break;
				default:
					throw new TAPException("Unknown limit unit (" + buf.reverse().toString() + ") for the property " + propertyName + ": \"" + value + "\"!");
			}
			// the 10-power of the base unit, if any:
			if (buf.length() > 1){
				if (unit == LimitUnit.bytes){
					switch(buf.charAt(1)){
						case 'k':
							unit = LimitUnit.kilobytes;
							break;
						case 'M':
							unit = LimitUnit.megabytes;
							break;
						case 'G':
							unit = LimitUnit.gigabytes;
							break;
						default:
							throw new TAPException("Unknown limit unit (" + buf.reverse().toString() + ") for the property " + propertyName + ": \"" + value + "\"!");
					}
				}else
					throw new TAPException("Unknown limit unit (" + buf.reverse().toString() + ") for the property " + propertyName + ": \"" + value + "\"!");
			}
		}

		return new Object[]{((numValue <= 0) ? -1 : numValue),unit};
	}

	public final static void main(final String[] args) throws Throwable{

		FileInputStream configFileStream = null;
+57 −54

File changed.

Preview size limit exceeded, changes collapsed.

+7 −7
Original line number Diff line number Diff line
##########################################################
#             FULL TAP CONFIGURATION FILE                #
#                                                        #
# TAP Version: 1.0                                       #
# Date: 20 Nov. 2013                                     #
# TAP Version: 1.1                                       #
# Date: 20 Dec. 2013                                     #
# Author: Gregory Mantelet (ARI)                         #
#                                                        #
# See the TAP documentation for more details: ...TODO... #
@@ -63,7 +63,7 @@ jdbc_url =
# [MANDATORY]
# Mandatory if the username is not already provided in jdbc_url
# Username used to access to the database.
db_user = 
db_username = 

# [MANDATORY]
# Mandatory if the password is not already provided in jdbc_url
@@ -200,13 +200,13 @@ max_execution_duration = 0
##########

# [OPTIONAL]
# Output formats for query results, in addition to the VOTable.
# Allowed values are: json, csv, tsv, or a path (within brackets: {...}) toward a class which implements OutputFormat<ResultSet>.
# Comma separated list of output formats for query results, in addition to the VOTable.
# Allowed values are: json, csv, tsv, sv(<separator>), or a path (within brackets: {...}) to a class implementing OutputFormat<ResultSet> and having at least one constructor with only a tap.ServiceConnection<ResultSet> parameter.
output_add_formats = 

# [OPTIONAL]
# Default limit for the result output. The prefix "default" means here that this value will be set if the client does not provide one.
# This limit can be expressed with 2 types: rows or bytes. For rows, you just have to suffix the value by a "r" (upper- or lower-case), with nothing (by default, nothing will mean "rows"). For bytes, you have to suffix the numeric value by "b", "kb", "Mb" or "Gb". Here, unit is case sensitive (except for the last character: "b"). No other storage unit is allowed.
# This limit can be expressed in only one unit: rows.
# A negative or null value means there is no restriction over this limit. Float values are not allowed.
# Obviously this limit MUST be less or equal than output_max_limit.
# By default, there is no restriction: output_default_limit=0
@@ -214,7 +214,7 @@ output_default_limit = 0

# [OPTIONAL]
# Maximum limit for the result output. The prefix "max" means here that the client can not set a limit greater than this one.
# This limit can be expressed with 2 types: rows or bytes. For rows, you just have to suffix the value by a "r" (upper- or lower-case), with nothing (by default, nothing will mean "rows"). For bytes, you have to suffix the numeric value by "b", "kb", "Mb" or "Gb". Here, unit is case sensitive (except for the last character: "b"). No other storage unit is allowed.
# This limit can be expressed in only one unit: rows.
# A negative or null value means there is no restriction over this limit. Float values are not allowed.
# Obviously this limit MUST be greater or equal than output_default_limit.
# By default, there is no restriction: output_max_limit=0
+1 −1
Original line number Diff line number Diff line
##########################################################
#            MINIMUM TAP CONFIGURATION FILE              #
#                                                        #
# TAP Version: 1.0                                       #
# TAP Version: 1.1                                       #
# Date: 20 Nov. 2013                                     #
# Author: Gregory Mantelet (ARI)                         #
#                                                        #
Loading