File Coverage

blib/lib/App/Sqitch/Engine/snowflake.pm
Criterion Covered Total %
statement 104 115 90.4
branch 31 36 86.1
condition 15 24 62.5
subroutine 45 48 93.7
pod 13 13 100.0
total 208 236 88.1


line stmt bran cond sub pod time code
1              
2             use 5.010;
3 2     2   18500 use Moo;
  2         6  
4 2     2   14 use utf8;
  2         2  
  2         15  
5 2     2   688 use Path::Class;
  2         5  
  2         11  
6 2     2   41 use DBI;
  2         7  
  2         139  
7 2     2   1415 use Try::Tiny;
  2         15229  
  2         83  
8 2     2   11 use App::Sqitch::X qw(hurl);
  2         4  
  2         120  
9 2     2   12 use Locale::TextDomain qw(App-Sqitch);
  2         4  
  2         18  
10 2     2   556 use App::Sqitch::Types qw(DBH ArrayRef HashRef URIDB Str);
  2         5  
  2         15  
11 2     2   381  
  2         7  
  2         26  
12             extends 'App::Sqitch::Engine';
13              
14             our $VERSION = 'v1.3.0'; # VERSION
15              
16              
17 4     4 1 11472 my $self = shift;
18 3     3 1 33 # Just use the target name if it doesn't look like a URI.
19 2     2 1 7 return $self->target->name if $self->target->name !~ /:/;
20 5     5 1 626  
21             # Use the URI sans password.
22             my $uri = $self->target->uri->clone;
23 15     15 1 2505 $uri->password(undef) if $uri->password;
24             return $uri->as_string;
25 15 100       82 }
26              
27             has _snowsql => (
28 12         41 is => 'ro',
29 12 100       153 isa => ArrayRef,
30 12         453 lazy => 1,
31             default => sub {
32             my $self = shift;
33             my $uri = $self->uri;
34             my @ret = ( $self->client );
35             for my $spec (
36             [ accountname => $self->account ],
37             [ username => $self->username ],
38             [ dbname => $uri->dbname ],
39             [ rolename => $self->role ],
40             ) {
41             push @ret, "--$spec->[0]" => $spec->[1] if $spec->[1];
42             }
43              
44             if (my %vars = $self->variables) {
45             push @ret => map {; '--variable', "$_=$vars{$_}" } sort keys %vars;
46             }
47              
48             push @ret => $self->_client_opts;
49             return \@ret;
50             },
51             );
52              
53              
54             has _snowcfg => (
55             is => 'rw',
56             isa => HashRef,
57             lazy => 1,
58             default => sub {
59 25     25 1 3267 my $hd = $^O eq 'MSWin32' && "$]" < '5.016' ? $ENV{HOME} || $ENV{USERPROFILE} : (glob('~'))[0];
  25         475  
60             return {} if not $hd;
61             my $fn = dir $hd, '.snowsql', 'config';
62             return {} unless -e $fn;
63             my $data = App::Sqitch::Config->new->load_file($fn);
64             my $cfg = {};
65             for my $k (keys %{ $data }) {
66             # We only want the default connections config. No named config.
67             # (For now, anyway; maybe use database as config name laster?)
68             next unless $k =~ /\Aconnections[.]([^.]+)\z/;
69             my $key = $1;
70             my $val = $data->{$k};
71             # Apparently snowsql config supports single quotes, while
72             # Config::GitLike does not.
73             # https://support.snowflake.net/s/case/5000Z000010xUYJQA2
74             # https://docs.snowflake.com/en/user-guide/snowsql-config.html#snowsql-config-file
75             if ($val =~ s/\A'//) {
76             $val = $data->{$k} unless $val =~ s/'\z//;
77             }
78             $cfg->{$key} = $val;
79             }
80             return $cfg;
81             },
82             );
83              
84             has uri => (
85             is => 'ro',
86             isa => URIDB,
87             lazy => 1,
88             default => sub {
89             my $self = shift;
90             my $uri = $self->SUPER::uri;
91              
92             # Set defaults in the URI.
93             $uri->host($self->_host($uri));
94             # XXX SNOWSQL_PORT deprecated; remove once Snowflake removes it.
95             $uri->port($ENV{SNOWSQL_PORT}) if !$uri->_port && $ENV{SNOWSQL_PORT};
96             $uri->dbname(
97             $ENV{SNOWSQL_DATABASE}
98             || $self->_snowcfg->{dbname}
99             || $self->username
100             ) if !$uri->dbname;
101             return $uri;
102             },
103             );
104              
105             $ENV{SNOWSQL_USER} || $_[0]->_snowcfg->{username} || $_[0]->sqitch->sysuser
106             }
107              
108             my $acct = $ENV{SNOWSQL_ACCOUNT} || $_[0]->_snowcfg->{accountname}
109             || hurl engine => __('Cannot determine Snowflake account name');
110              
111             # XXX Region is deprecated as a separate value, because the acount name may now be
112             # <account_name>.<region_id>.<cloud_platform_or_private_link>
113 7 100 100 7   490 # https://docs.snowflake.com/en/user-guide/snowsql-start.html#a-accountname
114             # Remove from here down and just return on the line above once Snowflake removes it.
115             my $region = $ENV{SNOWSQL_REGION} || $_[0]->_snowcfg->{region} or return $acct;
116 4 100   4   337 return "$acct.$region";
117             }
118              
119 6   33 6   40 has account => (
120             is => 'ro',
121             isa => Str,
122             lazy => 1,
123             default => sub {
124             my $self = shift;
125 6 100 66     35 if (my $host = $self->uri->host) {
126 1         5 # <account_name>.<region_id>.<cloud_platform_or_privatelink>.snowflakecomputing.com
127             $host =~ s/[.]snowflakecomputing[.]com$//;
128             return $host;
129             }
130             return $self->_def_acct;
131             },
132             );
133              
134             my ($self, $uri) = @_;
135             if (my $host = $uri->host) {
136             return $host if $host =~ /\.snowflakecomputing\.com$/;
137             return $host . ".snowflakecomputing.com";
138             }
139             # XXX SNOWSQL_HOST is deprecated; remove it once Snowflake removes it.
140             return $ENV{SNOWSQL_HOST} if $ENV{SNOWSQL_HOST};
141             return $self->_def_acct . '.snowflakecomputing.com';
142             }
143              
144             has warehouse => (
145 9     9   18 is => 'ro',
146 9 100       28 isa => Str,
147 2 100       64 lazy => 1,
148 1         5 default => sub {
149             my $self = shift;
150             my $uri = $self->uri;
151 7 100       160 require URI::QueryParam;
152 6         48 $uri->query_param('warehouse')
153             || $ENV{SNOWSQL_WAREHOUSE}
154             || $self->_snowcfg->{warehousename}
155             || 'sqitch';
156             },
157             );
158              
159             has role => (
160             is => 'ro',
161             isa => Str,
162             lazy => 1,
163             default => sub {
164             my $self = shift;
165             my $uri = $self->uri;
166             require URI::QueryParam;
167             $uri->query_param('role')
168             || $ENV{SNOWSQL_ROLE}
169             || $self->_snowcfg->{rolename}
170             || '';
171             },
172             );
173              
174             has dbh => (
175             is => 'rw',
176             isa => DBH,
177             lazy => 1,
178             default => sub {
179             my $self = shift;
180             $self->use_driver;
181             my $uri = $self->uri;
182             my $wh = $self->warehouse;
183             my $role = $self->role;
184             DBI->connect($uri->dbi_dsn, $self->username, $self->password, {
185             PrintError => 0,
186             RaiseError => 0,
187             AutoCommit => 1,
188             odbc_utf8_on => 1,
189             FetchHashKeyName => 'NAME_lc',
190             HandleError => sub {
191             my ($err, $dbh) = @_;
192             $@ = $err;
193             @_ = ($dbh->state || 'DEV' => $dbh->errstr);
194             goto &hurl;
195             },
196             Callbacks => {
197             connected => sub {
198             my $dbh = shift;
199             try {
200             $dbh->do($_) for (
201             ($role ? ("USE ROLE $role") : ()),
202             "ALTER WAREHOUSE $wh RESUME IF SUSPENDED",
203             "USE WAREHOUSE $wh",
204             'USE SCHEMA ' . $self->registry,
205             'ALTER SESSION SET TIMESTAMP_TYPE_MAPPING=TIMESTAMP_LTZ',
206             "ALTER SESSION SET TIMESTAMP_OUTPUT_FORMAT='YYYY-MM-DD HH24:MI:SS'",
207             "ALTER SESSION SET TIMEZONE='UTC'",
208             );
209             $dbh->set_err(undef, undef) if $dbh->err;
210             };
211             return;
212             },
213             disconnect => sub {
214             shift->do("ALTER WAREHOUSE $wh SUSPEND");
215             return;
216             },
217             },
218             });
219             }
220             );
221              
222             # Need to wait until dbh is defined.
223             with 'App::Sqitch::Role::DBIEngine';
224              
225             return (
226             '--noup',
227             '--option' => 'auto_completion=false',
228             '--option' => 'echo=false',
229             '--option' => 'execution_only=false',
230             '--option' => 'friendly=false',
231             '--option' => 'header=false',
232             '--option' => 'exit_on_error=true',
233             '--option' => 'stop_on_error=true',
234             '--option' => 'output_format=csv',
235             '--option' => 'paging=false',
236             '--option' => 'timing=false',
237             # results=false suppresses errors! Bug report:
238 5     5   94 # https://support.snowflake.net/s/case/5000Z000010wm6BQAQ/
239             '--option' => 'results=true',
240             '--option' => 'wrap=false',
241             '--option' => 'rowset_size=1000',
242             '--option' => 'syntax_style=default',
243             '--option' => 'variable_substitution=true',
244             '--variable' => 'registry=' . $_[0]->registry,
245             '--variable' => 'warehouse=' . $_[0]->warehouse,
246             );
247             }
248              
249             return (
250             '--option' => 'quiet=true',
251             );
252             }
253              
254             return (
255             '--option' => 'quiet=false',
256             );
257             }
258              
259             # Not using arrays, but delimited strings that are the default in
260             # App::Sqitch::Role::DBIEngine, because:
261             # * There is currently no literal syntax for arrays
262             # https://support.snowflake.net/s/case/5000Z000010wXBRQA2/
263 2     2   26 # * Scalar variables like the array constructor can't be used in WHERE clauses
264             # https://support.snowflake.net/s/case/5000Z000010wX7yQAE/
265             return q{listagg(%1$s, ' ') WITHIN GROUP (ORDER BY %1$s)};
266             }
267              
268              
269 16     16   316 my $self = shift;
270             return $self->dbh->selectcol_arrayref(q{
271             SELECT true
272             FROM information_schema.tables
273             WHERE TABLE_CATALOG = current_database()
274             AND TABLE_SCHEMA = UPPER(?)
275             AND TABLE_NAME = UPPER(?)
276             }, undef, $self->registry, 'changes')->[0];
277             }
278              
279             my $self = shift;
280 1     1   869 my $schema = $self->registry;
281             hurl engine => __x(
282             'Sqitch schema "{schema}" already exists',
283 1     1   15 schema => $schema
284             ) if $self->initialized;
285              
286 1     1 1 75 $self->run_file( file(__FILE__)->dir->file('snowflake.sql') );
287 1         4 $self->dbh->do("USE SCHEMA $schema");
288             $self->_register_release;
289             }
290              
291             return $DBI::state && $DBI::state eq '42S02'; # ERRCODE_UNDEFINED_TABLE
292             }
293              
294             return $DBI::state && $DBI::state eq '42703'; # ERRCODE_UNDEFINED_COLUMN
295             }
296              
297 0     0 1 0 # The colon has to be inside the quotation marks, because otherwise it
298 0         0 # generates wayward single quotation marks. Bug report:
299 0 0       0 # https://support.snowflake.net/s/case/5000Z000010wTkKQAU/
300             qq{to_varchar(CONVERT_TIMEZONE('UTC', %s), '"year:"YYYY":month:"MM":day:"DD":hour:"HH24":minute:"MI":second:"SS":time_zone:UTC"')};
301             }
302              
303              
304 0         0 require App::Sqitch::DateTime;
305 0         0 return App::Sqitch::DateTime->new(split /:/ => shift);
306 0         0 }
307              
308              
309              
310 4   100 4   51 my ( $self, $ord, $offset, $project ) = @_;
311              
312             my $offset_expr = $offset ? " OFFSET $offset" : '';
313             return try {
314 3   100 3   37 $self->dbh->selectcol_arrayref(qq{
315             SELECT change_id
316             FROM changes
317             WHERE project = ?
318             ORDER BY committed_at $ord
319             LIMIT 1$offset_expr
320             }, undef, $project || $self->plan->project)->[0];
321 1     1   8 } catch {
322             return if $self->_no_table_error && !$self->initialized;
323             die $_;
324 1     1   6112 };
325             }
326              
327 1     1   6 my ( $self, $change ) = @_;
328 1         51 # NOTE: Query from DBIEngine doesn't work in Snowflake:
329             # SQL compilation error: Unsupported subquery type cannot be evaluated (SQL-42601)
330             # Looks like it doesn't yet support correlated subqueries.
331 1     1   15 # https://docs.snowflake.com/en/sql-reference/operators-subquery.html
332             # The CTE-based query borrowed from Exasol seems to be fine, however.
333 1     1   15 return @{ $self->dbh->selectall_arrayref(q{
334             WITH tag AS (
335             SELECT tag, committed_at, project,
336 1     1   170 ROW_NUMBER() OVER (partition by project ORDER BY committed_at) AS rnk
337             FROM tags
338 1 50       12 )
339             SELECT c.change_id, c.project, c.change, t.tag AS asof_tag
340 1   0 1   203 FROM dependencies d
341             JOIN changes c ON c.change_id = d.change_id
342             LEFT JOIN tag t ON t.project = c.project AND t.committed_at >= c.committed_at
343             WHERE d.dependency_id = ?
344             AND (t.rnk IS NULL OR t.rnk = 1)
345             }, { Slice => {} }, $change->id) };
346             }
347              
348 1 50 33 1   35 my ( $self, $change_id ) = @_;
349 1         8 # NOTE: Query from DBIEngine doesn't work in Snowflake:
350 1         29 # SQL compilation error: Unsupported subquery type cannot be evaluated (SQL-42601)
351             # Looks like it doesn't yet support correlated subqueries.
352             # https://docs.snowflake.com/en/sql-reference/operators-subquery.html
353             # The CTE-based query borrowed from Exasol seems to be fine, however.
354 0     0 1 0 return $self->dbh->selectcol_arrayref(q{
355             WITH tag AS (
356             SELECT tag, committed_at, project,
357             ROW_NUMBER() OVER (partition by project ORDER BY committed_at) AS rnk
358             FROM tags
359             )
360 0         0 SELECT change || COALESCE(t.tag, '@HEAD')
  0         0  
361             FROM changes c
362             LEFT JOIN tag t ON c.project = t.project AND t.committed_at >= c.committed_at
363             WHERE change_id = ?
364             AND (t.rnk IS NULL OR t.rnk = 1)
365             }, undef, $change_id)->[0];
366             }
367              
368             # https://support.snowflake.net/s/question/0D50Z00008BENO5SAP
369              
370             # LIMIT/OFFSET don't support parameters, alas. So just put them in the query.
371             my ($self, $lim, $off) = @_;
372             # OFFSET cannot be used without LIMIT, sadly.
373             # https://support.snowflake.net/s/case/5000Z000010wfnWQAQ
374             return ['LIMIT ' . ($lim || $self->_limit_default), "OFFSET $off"], [] if $off;
375             return ["LIMIT $lim"], [] if $lim;
376 0     0 1 0 return [], [];
377             }
378              
379             my ( $self, $col, $regex ) = @_;
380             # Snowflake regular expressions are implicitly anchored to match the
381             # entire string. To work around this, issue, we use regexp_substr(), which
382 0         0 # is not so anchored, and just check to see that if it returns a string.
383             # https://support.snowflake.net/s/case/5000Z000010wbUSQAY
384             # https://support.snowflake.net/s/question/0D50Z00008C90beSAB/
385             return "regexp_substr($col, ?) IS NOT NULL", $regex;
386             }
387              
388             my ($self, $file) = @_;
389             $self->_run(_quiet_opts, '--filename' => $file);
390             }
391              
392             my ($self, $file) = @_;
393             # Suppress STDOUT unless we want extra verbosity.
394             return $self->run_file($file) unless $self->sqitch->verbosity > 1;
395             $self->_run(_verbose_opts, '--filename' => $file);
396             }
397 2     2   28  
398             my ($self, $fh) = @_;
399             $self->_spool($fh);
400             }
401 6     6   307  
402             my $self = shift;
403             my $sqitch = $self->sqitch;
404 6 100 66     54 my $pass = $self->password or
405 4 100       45 # Use capture and emit instead of _run to avoid a wayward newline in
406 2         14 # the output.
407             return $sqitch->emit_literal( $sqitch->capture( $self->snowsql, @_ ) );
408             # Does not override connection config, alas.
409             local $ENV{SNOWSQL_PWD} = $pass;
410 1     1   4 return $sqitch->emit_literal( $sqitch->capture( $self->snowsql, @_ ) );
411             }
412              
413             my $self = shift;
414             my $sqitch = $self->sqitch;
415             my $pass = $self->password or
416 1         15 return $sqitch->capture( $self->snowsql, _verbose_opts, @_ );
417             local $ENV{SNOWSQL_PWD} = $pass;
418             return $sqitch->capture( $self->snowsql, _verbose_opts, @_ );
419             }
420 1     1 1 3  
421 1         4 my $self = shift;
422             my $sqitch = $self->sqitch;
423             my $pass = $self->password or
424             return $sqitch->probe( $self->snowsql, _verbose_opts, @_ );
425 1     1 1 3 local $ENV{SNOWSQL_PWD} = $pass;
426             return $sqitch->probe( $self->snowsql, _verbose_opts, @_ );
427 1 50       6 }
428 1         7  
429             my $self = shift;
430             my $fh = shift;
431             my $sqitch = $self->sqitch;
432 1     1 1 2 my $pass = $self->password or
433 1         5 return $sqitch->spool( $fh, $self->snowsql, _verbose_opts, @_ );
434             local $ENV{SNOWSQL_PWD} = $pass;
435             return $sqitch->spool( $fh, $self->snowsql, _verbose_opts, @_ );
436             }
437 4     4   812  
438 4         26 1;
439 4 100       87  
440              
441             =head1 Name
442              
443             App::Sqitch::Engine::snowflake - Sqitch Snowflake Engine
444 1         107  
445 1         6 =head1 Synopsis
446              
447             my $snowflake = App::Sqitch::Engine->load( engine => 'snowflake' );
448              
449 2     2   58 =head1 Description
450 2         13  
451 2 100       45 App::Sqitch::Engine::snowflake provides the Snowflake storage engine for Sqitch.
452              
453 1         14 =head1 Interface
454 1         3  
455             =head2 Attributes
456              
457             =head3 C<uri>
458 2     2   59  
459 2         7 Returns the Snowflake database URI name. It starts with the URI for the target
460 2 100       41 and builds out missing parts. Sqitch looks for the host name in this order:
461              
462 1         20 =over
463 1         4  
464             =item 1
465              
466             In the host name of the target URI. If that host name does not end in
467 3     3   1571 C<snowflakecomputing.com>, Sqitch appends it. This lets Snowflake URLs just
468 3         7 reference the Snowflake account name or the account name and region in URLs.
469 3         13  
470 3 100       65 =item 2
471              
472 1         13 In the C<$SNOWSQL_HOST> environment variable (Deprecated by Snowflake).
473 1         4  
474             =item 3
475              
476             By concatenating the account name and region, if available, from the
477             C<$SNOWSQL_ACCOUNT> environment variable or C<connections.accountname> setting
478             in the
479             L<SnowSQL configuration file|https://docs.snowflake.com/en/user-guide/snowsql-start.html#configuring-default-connection-settings>,
480             the C<$SNOWSQL_REGION> or C<connections.region> setting in the
481             L<SnowSQL configuration file|https://docs.snowflake.com/en/user-guide/snowsql-start.html#configuring-default-connection-settings>,
482             and C<snowflakecomputing.com>. Note that Snowflake has deprecated
483             C<$SNOWSQL_REGION> and C<connections.region>, and will be removed in a future
484             version. Append the region name and cloud platform name to the account name,
485             instead.
486              
487             =back
488              
489             The database name is determined by the following methods:
490              
491             =over
492              
493             =item 1.
494              
495             The path par t of the database URI.
496              
497             =item 2.
498              
499             The C<$SNOWSQL_DATABASE> environment variable.
500              
501             =item 3.
502              
503             In the C<connections.dbname> setting in the
504             L<SnowSQL configuration file|https://docs.snowflake.com/en/user-guide/snowsql-start.html#configuring-default-connection-settings>.
505              
506             =item 4.
507              
508             If sqitch finds no value in the above places, it falls back on the system
509             username.
510              
511             =back
512              
513             Other attributes of the URI are set from the C<account>, C<username> and
514             C<password> attributes documented below.
515              
516             =head3 C<account>
517              
518             Returns the Snowflake account name, or an exception if none can be determined.
519             Sqitch looks for the account code in this order:
520              
521             =over
522              
523             =item 1
524              
525             In the host name of the target URI.
526              
527             =item 2
528              
529             In the C<$SNOWSQL_ACCOUNT> environment variable.
530              
531             =item 3
532              
533             In the C<connections.accountname> setting in the
534             L<SnowSQL configuration file|https://docs.snowflake.com/en/user-guide/snowsql-start.html#configuring-default-connection-settings>.
535              
536             =back
537              
538             =head3 username
539              
540             Returns the snowflake user name. Sqitch looks for the user name in this order:
541              
542             =over
543              
544             =item 1
545              
546             In the C<$SQITCH_USERNAME> environment variable.
547              
548             =item 2
549              
550             In the target URI.
551              
552             =item 3
553              
554             In the C<$SNOWSQL_USER> environment variable.
555              
556             =item 4
557              
558             In the C<connections.username> variable from the
559             L<SnowSQL config file|https://docs.snowflake.com/en/user-guide/snowsql-config.html#snowsql-config-file>.
560              
561             =item 5
562              
563             The system username.
564              
565             =back
566              
567             =head3 password
568              
569             Returns the snowflake password. Sqitch looks for the password in this order:
570              
571             =over
572              
573             =item 1
574              
575             In the C<$SQITCH_PASSWORD> environment variable.
576              
577             =item 2
578              
579             In the target URI.
580              
581             =item 3
582              
583             In the C<$SNOWSQL_PWD> environment variable.
584              
585             =item 4
586              
587             In the C<connections.password> variable from the
588             L<SnowSQL config file|https://docs.snowflake.com/en/user-guide/snowsql-config.html#snowsql-config-file>.
589              
590             =back
591              
592             =head3 C<warehouse>
593              
594             Returns the warehouse to use for all connections. This value will be available
595             to all Snowflake change scripts as the C<&warehouse> variable. Sqitch looks
596             for the warehouse in this order:
597              
598             =over
599              
600             =item 1
601              
602             In the C<warehouse> query parameter of the target URI
603              
604             =item 2
605              
606             In the C<$SNOWSQL_WAREHOUSE> environment variable.
607              
608             =item 3
609              
610             In the C<connections.warehousename> variable from the
611             L<SnowSQL config file|https://docs.snowflake.com/en/user-guide/snowsql-config.html#snowsql-config-file>.
612              
613             =item 4
614              
615             If none of the above are found, it falls back on the hard-coded value
616             "sqitch".
617              
618             =back
619              
620             =head3 C<role>
621              
622             Returns the role to use for all connections. Sqitch looks for the role in this
623             order:
624              
625             =over
626              
627             =item 1
628              
629             In the C<role> query parameter of the target URI
630              
631             =item 2
632              
633             In the C<$SNOWSQL_ROLE> environment variable.
634              
635             =item 3
636              
637             In the C<connections.rolename> variable from the
638             L<SnowSQL config file|https://docs.snowflake.com/en/user-guide/snowsql-config.html#snowsql-config-file>.
639              
640             =item 4
641              
642             If none of the above are found, no role will be set.
643              
644             =back
645              
646             =head2 Instance Methods
647              
648             =head3 C<initialized>
649              
650             $snowflake->initialize unless $snowflake->initialized;
651              
652             Returns true if the database has been initialized for Sqitch, and false if it
653             has not.
654              
655             =head3 C<initialize>
656              
657             $snowflake->initialize;
658              
659             Initializes a database for Sqitch by installing the Sqitch registry schema.
660              
661             =head3 C<snowsql>
662              
663             Returns a list containing the C<snowsql> client and options to be passed to
664             it. Used internally when executing scripts.
665              
666             =head1 Author
667              
668             David E. Wheeler <david@justatheory.com>
669              
670             =head1 License
671              
672             Copyright (c) 2012-2022 iovation Inc., David E. Wheeler
673              
674             Permission is hereby granted, free of charge, to any person obtaining a copy
675             of this software and associated documentation files (the "Software"), to deal
676             in the Software without restriction, including without limitation the rights
677             to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
678             copies of the Software, and to permit persons to whom the Software is
679             furnished to do so, subject to the following conditions:
680              
681             The above copyright notice and this permission notice shall be included in all
682             copies or substantial portions of the Software.
683              
684             THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
685             IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
686             FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
687             AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
688             LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
689             OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
690             SOFTWARE.
691              
692             =cut