NAME

EV::Etcd - Async etcd v3 client using native gRPC and EV/libev

SYNOPSIS

use EV;
use EV::Etcd;

my $client = EV::Etcd->new(
    endpoints => ['127.0.0.1:2379'],
);

# Async put
$client->put('/my/key', 'value', sub {
    my ($resp, $err) = @_;
    die $err->{message} if $err;
    say "Put succeeded, revision: $resp->{header}{revision}";
});

# Async get
$client->get('/my/key', sub {
    my ($resp, $err) = @_;
    die $err->{message} if $err;
    say "Value: $resp->{kvs}[0]{value}";
});

# Watch
$client->watch('/my/key', sub {
    my ($resp, $err) = @_;
    for my $event (@{$resp->{events}}) {
        say "Event: $event->{type} on $event->{kv}{key}";
    }
});

EV::run;

DESCRIPTION

EV::Etcd provides a high-performance async client for etcd v3 using native gRPC Core C API integrated with the EV event loop.

METHODS

new

my $client = EV::Etcd->new(%options);

Options:

endpoints

ArrayRef of etcd endpoints (host:port).

timeout

RPC timeout in seconds. Default is 30 seconds. Minimum value is 1 second.

max_retries

Maximum number of retry attempts for transient failures. Default is 3. Set to 0 to disable retries.

health_interval

Interval in seconds for health monitoring. Default is 0 (disabled). When enabled, the client periodically checks the gRPC channel connectivity state and calls the on_health_change callback when the connection state changes.

on_health_change

Callback called when the connection health status changes. Receives two arguments: a boolean indicating health status (1=healthy, 0=unhealthy) and the current endpoint string.

my $client = EV::Etcd->new(
    endpoints => ['127.0.0.1:2379'],
    health_interval => 5,
    on_health_change => sub {
        my ($is_healthy, $endpoint) = @_;
        warn $is_healthy ? "Connected to $endpoint" : "Disconnected from $endpoint";
    },
);
auth_token

Pre-set authentication token. Use this to create an authenticated client without calling authenticate() first. Useful when you already have a valid token from a previous session.

my $client = EV::Etcd->new(
    endpoints => ['127.0.0.1:2379'],
    auth_token => $saved_token,
);

ERROR HANDLING

Errors are returned as hash references with the following structure:

{
    code      => 14,              # gRPC status code (integer)
    status    => "UNAVAILABLE",   # gRPC status name (string)
    message   => "Connection refused",  # Error message
    source    => "get",           # Which operation failed
    retryable => 1,               # Whether the error is retryable
}

Retryable status codes include: UNAVAILABLE, RESOURCE_EXHAUSTED, ABORTED, INTERNAL, and DEADLINE_EXCEEDED. The client will automatically retry operations with these status codes according to the retry configuration.

put

$client->put($key, $value, $callback);
$client->put($key, $value, \%opts, $callback);

Put a key-value pair into etcd.

Options:

lease

Lease ID to associate with the key.

prev_kv

If true, returns the previous key-value pair in the response.

ignore_value

If true, updates the lease without changing the value.

ignore_lease

If true, updates the value without changing the lease.

get

$client->get($key, $callback);
$client->get($key, \%opts, $callback);

Get key(s) from etcd.

Options:

prefix

If true, returns all keys with the given prefix.

range_end

End of the key range to query.

limit

Maximum number of keys to return.

revision

Get keys at a specific revision.

keys_only

If true, returns only keys without values.

count_only

If true, returns only the count of keys.

sort_order

Sort order: "ascend" or "descend".

sort_target

What to sort by: "key", "version", "create", "mod", or "value".

serializable

If true, use serializable (faster but possibly stale) reads.

min_mod_revision, max_mod_revision

Filter keys by modification revision.

min_create_revision, max_create_revision

Filter keys by creation revision.

delete

$client->delete($key, $callback);
$client->delete($key, \%opts, $callback);

Delete key(s) from etcd.

Options:

prefix

If true, deletes all keys with the given prefix.

range_end

End of the key range to delete.

prev_kv

If true, returns the deleted key-value pairs in the response.

watch

my $watch = $client->watch($key, $callback);
my $watch = $client->watch($key, \%opts, $callback);

Create a watch on a key or key range. Returns an EV::Etcd::Watch object.

The callback is called with ($response, $error) for each watch event. The response contains an events array with the watch events.

Options:

prefix

If true, watches all keys with the given prefix.

range_end

End of the key range to watch.

start_revision

Revision to start watching from. If not specified, watches from the current revision. Use this to resume watching after a reconnect.

prev_kv

If true, the response will include the previous key-value pair for UPDATE and DELETE events.

progress_notify

If true, the server will periodically send progress notifications even when there are no events, allowing the client to know the current revision.

watch_id

Optional explicit watch ID. If not specified, the server assigns one.

auto_reconnect

If true, the watch will automatically reconnect after a connection failure, resuming from the last seen revision. Default is true. This is useful for long-running watches that should survive network interruptions.

my $watch = $client->watch('/my/key', {
    auto_reconnect => 1,
    progress_notify => 1,  # Helps track revision even during inactivity
}, sub {
    my ($event, $err) = @_;
    if ($err) {
        warn "Watch error: $err->{message}";
        return;
    }
    # Process events...
});

EV::Etcd::Watch Methods

cancel

$watch->cancel($callback);

Cancel the watch. The callback receives ($response, $error) when cancellation is complete. The response is an empty hash reference on success.

$watch->cancel(sub {
    my ($resp, $err) = @_;
    if ($err) {
        warn "Cancel failed: $err->{message}";
    } else {
        print "Watch cancelled\n";
    }
});

lease_grant

$client->lease_grant($ttl, $callback);

Grant a lease with the specified TTL (time-to-live) in seconds.

The callback receives ($response, $error) where response contains:

id

The lease ID.

ttl

The actual TTL granted by the server.

lease_revoke

$client->lease_revoke($lease_id, $callback);

Revoke a lease. All keys attached to the lease will be deleted.

lease_keepalive

$client->lease_keepalive($lease_id, $callback);

Keep a lease alive. Call this periodically to prevent the lease from expiring.

lease_time_to_live

$client->lease_time_to_live($lease_id, $callback);
$client->lease_time_to_live($lease_id, \%opts, $callback);

Get the remaining TTL of a lease.

The callback receives ($response, $error) where response contains:

id

The lease ID.

ttl

Remaining TTL in seconds. Returns -1 if the lease has expired.

granted_ttl

The original TTL granted when the lease was created.

keys

Array of keys attached to this lease (only if keys option is true).

Options:

keys

If true, also return the list of keys attached to this lease.

lease_leases

$client->lease_leases($callback);

List all active leases.

The callback receives ($response, $error) where response contains:

leases

Array of lease objects, each containing:

id

The lease ID.

compact

$client->compact($revision, $callback);
$client->compact($revision, \%opts, $callback);

Compact the etcd key-value store up to the given revision. All keys with revisions less than the compaction revision will be removed.

Warning: This operation is irreversible. After compaction, you cannot retrieve data from revisions before the compacted revision.

Options:

physical

If true, the RPC will wait until the compaction is physically applied to the local database such that compacted entries are totally removed from the backend database. Default is false.

status

$client->status($callback);

Get the status of the etcd cluster member this client is connected to. Useful for health checks and cluster monitoring.

The callback receives ($response, $error) where response contains:

version

The etcd server version.

db_size

The size of the backend database in bytes.

leader

The member ID of the cluster leader.

raft_index

The current raft index of the member.

raft_term

The current raft term of the cluster.

raft_applied_index

The raft applied index of the member.

db_size_in_use

The actual size of the database in use (after compaction).

is_learner

True if this member is a learner (non-voting member).

errors

Array of any errors on this member (if any).

LOCK SERVICE

EV::Etcd provides distributed locking through the etcd Lock service. Locks are tied to leases - when the lease expires or is revoked, the lock is automatically released.

lock

$client->lock($name, $lease_id, $callback);

Acquire a distributed lock.

Arguments:

name

The name (identifier) of the lock to acquire. This is a byte string that identifies the resource being locked. Multiple clients attempting to lock the same name will block until the lock is available.

lease_id

The ID of a lease to attach to the lock. The lock will be held for the duration of the lease. If the lease expires or is revoked, the lock is automatically released. You must first create a lease with lease_grant and optionally keep it alive with lease_keepalive.

callback

Called with ($response, $error) when the lock is acquired (or fails).

The response contains:

key

The key that holds the lock. This key is used to unlock the lock and should be stored by the caller. The key is unique to this lock holder and contains the lock name as a prefix.

Standard response header with cluster_id, member_id, revision, and raft_term.

Example:

# First, create a lease for the lock
$client->lease_grant(30, sub {
    my ($lease_resp, $err) = @_;
    die $err->{message} if $err;

    my $lease_id = $lease_resp->{id};

    # Now acquire the lock
    $client->lock("my-resource", $lease_id, sub {
        my ($lock_resp, $err) = @_;
        die $err->{message} if $err;

        my $lock_key = $lock_resp->{key};
        print "Lock acquired with key: $lock_key\n";

        # ... do protected work ...

        # Release the lock when done
        $client->unlock($lock_key, sub {
            my ($unlock_resp, $err) = @_;
            warn "Unlock failed: $err->{message}" if $err;
        });
    });
});

unlock

$client->unlock($key, $callback);

Release a distributed lock.

Arguments:

key

The lock key returned from a successful lock call. This is the unique key that was created to hold the lock ownership.

callback

Called with ($response, $error) when the unlock completes.

The response contains:

header

Standard response header with cluster_id, member_id, revision, and raft_term.

Note: You can also release a lock by revoking its associated lease with lease_revoke. This is useful if you want to release all resources associated with a lease at once.

AUTHENTICATION SERVICE

EV::Etcd provides full support for etcd's authentication and authorization system. Authentication uses username/password credentials, and authorization is based on roles with key-range permissions.

authenticate

$client->authenticate($username, $password, $callback);

Authenticate with etcd using username and password. On success, the client automatically stores the auth token and uses it for subsequent requests.

Arguments:

username

The username to authenticate as.

password

The password for the user.

callback

Called with ($response, $error) when authentication completes.

The response contains:

token

The authentication token (also automatically stored in the client).

header

Standard response header.

Example:

$client->authenticate('admin', 'secret', sub {
    my ($resp, $err) = @_;
    if ($err) {
        die "Authentication failed: $err->{message}";
    }
    say "Authenticated successfully";
    # Client now automatically uses the token for all requests
});

auth_enable

$client->auth_enable($callback);

Enable authentication on the etcd cluster.

Warning: Before enabling auth, you must create at least one user with the root role, otherwise you will be locked out of the cluster.

Example:

# First create root user
$client->user_add('root', 'rootpassword', sub {
    my ($resp, $err) = @_;
    die $err->{message} if $err;

    $client->user_grant_role('root', 'root', sub {
        my ($resp, $err) = @_;
        die $err->{message} if $err;

        # Now safe to enable auth
        $client->auth_enable(sub {
            my ($resp, $err) = @_;
            say "Authentication enabled" unless $err;
        });
    });
});

auth_disable

$client->auth_disable($callback);

Disable authentication on the etcd cluster. Requires root privileges.

User Management

user_add

$client->user_add($username, $password, $callback);

Create a new user.

Arguments:

username

The username for the new user.

password

The password for the new user.

callback

Called with ($response, $error) when complete.

user_delete

$client->user_delete($username, $callback);

Delete a user.

user_change_password

$client->user_change_password($username, $password, $callback);

Change a user's password.

user_get

$client->user_get($username, $callback);

Get information about a user.

The response contains:

roles

Array of role names assigned to the user.

Example:

$client->user_get('myuser', sub {
    my ($resp, $err) = @_;
    say "User has roles: @{$resp->{roles}}";
});

user_list

$client->user_list($callback);

List all users.

The response contains:

users

Array of usernames.

user_grant_role

$client->user_grant_role($username, $role_name, $callback);

Grant a role to a user.

Example:

$client->user_grant_role('myuser', 'readwrite', sub {
    my ($resp, $err) = @_;
    say "Role granted" unless $err;
});

user_revoke_role

$client->user_revoke_role($username, $role_name, $callback);

Revoke a role from a user.

Role Management

role_add

$client->role_add($role_name, $callback);

Create a new role.

Example:

$client->role_add('readonly', sub {
    my ($resp, $err) = @_;
    say "Role created" unless $err;
});

role_delete

$client->role_delete($role_name, $callback);

Delete a role.

role_get

$client->role_get($role_name, $callback);

Get information about a role, including its permissions.

The response contains:

perm

Array of permission objects, each containing:

perm_type

Permission type: "READ", "WRITE", or "READWRITE".

key

The key or key prefix this permission applies to.

range_end

The end of the key range (if applicable).

Example:

$client->role_get('myrole', sub {
    my ($resp, $err) = @_;
    for my $perm (@{$resp->{perm}}) {
        say "Permission: $perm->{perm_type} on $perm->{key}";
    }
});

role_list

$client->role_list($callback);

List all roles.

The response contains:

roles

Array of role names.

role_grant_permission

$client->role_grant_permission($role_name, $perm_type, $key, $range_end, $callback);

Grant a permission to a role.

Arguments:

role_name

The role to grant the permission to.

perm_type

The permission type: "READ", "WRITE", or "READWRITE".

key

The key or key prefix to grant access to.

range_end

The end of the key range. Use undef for a single key, or use the special value "\x00" after the last byte of the prefix to match all keys with that prefix.

callback

Called with ($response, $error) when complete.

Example:

# Grant read access to a single key
$client->role_grant_permission('readonly', 'READ', '/config/setting', undef, sub {
    my ($resp, $err) = @_;
    say "Permission granted" unless $err;
});

# Grant read/write access to all keys under /app/
# Range end is /app0 (the byte after / is 0)
$client->role_grant_permission('readwrite', 'READWRITE', '/app/', '/app0', sub {
    my ($resp, $err) = @_;
    say "Permission granted" unless $err;
});

role_revoke_permission

$client->role_revoke_permission($role_name, $key, $range_end, $callback);

Revoke a permission from a role.

Arguments:

role_name

The role to revoke the permission from.

key

The key or key prefix of the permission to revoke.

range_end

The end of the key range of the permission to revoke.

callback

Called with ($response, $error) when complete.

Complete Authentication Example

use EV;
use EV::Etcd;

my $client = EV::Etcd->new(endpoints => ['127.0.0.1:2379']);

# Setup authentication (run once, as root)
sub setup_auth {
    # Create a role with permissions
    $client->role_add('app-role', sub {
        my ($resp, $err) = @_;

        # Grant read/write on /app/ prefix
        $client->role_grant_permission('app-role', 'READWRITE', '/app/', '/app0', sub {
            my ($resp, $err) = @_;

            # Create user
            $client->user_add('appuser', 'apppassword', sub {
                my ($resp, $err) = @_;

                # Assign role to user
                $client->user_grant_role('appuser', 'app-role', sub {
                    my ($resp, $err) = @_;
                    say "Auth setup complete";
                    EV::break;
                });
            });
        });
    });
}

# Normal usage with authentication
sub use_with_auth {
    $client->authenticate('appuser', 'apppassword', sub {
        my ($resp, $err) = @_;
        die "Auth failed: $err->{message}" if $err;

        # Now all operations use the auth token
        $client->put('/app/key', 'value', sub {
            my ($resp, $err) = @_;
            say "Put succeeded" unless $err;
            EV::break;
        });
    });
}

use_with_auth();
EV::run;

MAINTENANCE SERVICE

EV::Etcd provides access to etcd's maintenance operations for cluster administration and monitoring.

alarm

$client->alarm($action, $callback);
$client->alarm($action, \%opts, $callback);

Get, activate, or deactivate alarms on etcd cluster members.

Arguments:

action

The alarm action to perform. Must be one of:

GET

List all active alarms.

ACTIVATE

Activate an alarm on a member.

DEACTIVATE

Deactivate an alarm on a member.

callback

Called with ($response, $error) when the operation completes.

Options:

member_id

The member ID to operate on. Required for ACTIVATE/DEACTIVATE on a specific member. Use 0 for all members.

alarm

The alarm type. Can be "NOSPACE" (storage quota exceeded) or "CORRUPT" (data corruption detected). Default is "NONE" which means all alarms.

The response contains:

alarms

Array of alarm objects, each containing:

member_id

The member ID where the alarm is active.

alarm

The alarm type as an integer.

alarm_type

The alarm type as a string ("NONE", "NOSPACE", or "CORRUPT").

header

Standard response header.

Example:

# List all alarms
$client->alarm('GET', sub {
    my ($resp, $err) = @_;
    for my $alarm (@{$resp->{alarms}}) {
        warn "Alarm on member $alarm->{member_id}: $alarm->{alarm_type}";
    }
});

# Deactivate NOSPACE alarm on all members
$client->alarm('DEACTIVATE', { alarm => 'NOSPACE' }, sub {
    my ($resp, $err) = @_;
    warn "Alarm deactivated" unless $err;
});

defragment

$client->defragment($callback);

Defragment the storage backend on the etcd member this client is connected to. This reclaims storage space by removing deleted keys and compacted revisions.

Warning: Defragmentation is a blocking operation and may cause latency spikes. Run it during maintenance windows.

The response contains:

header

Standard response header.

Example:

$client->defragment(sub {
    my ($resp, $err) = @_;
    if ($err) {
        warn "Defragment failed: $err->{message}";
    } else {
        say "Defragmentation complete";
    }
});

hash_kv

$client->hash_kv($callback);
$client->hash_kv($revision, $callback);

Compute the hash of the KV store up to the given revision. Useful for verifying data consistency across cluster members.

Arguments:

revision (optional)

The revision to hash up to. If not specified, uses the current revision.

callback

Called with ($response, $error) when the operation completes.

The response contains:

hash

The hash value of the KV store.

compact_revision

The compaction revision of the KV store.

header

Standard response header.

Example:

$client->hash_kv(sub {
    my ($resp, $err) = @_;
    say "KV hash: $resp->{hash}";
    say "Compact revision: $resp->{compact_revision}";
});

move_leader

$client->move_leader($target_id, $callback);

Transfer leadership to another member. Only the current leader can transfer leadership.

Arguments:

target_id

The member ID of the new leader. Must be a voting member (not a learner).

callback

Called with ($response, $error) when the operation completes.

The response contains:

header

Standard response header.

Example:

# Get current cluster status to find member IDs
$client->member_list(sub {
    my ($resp, $err) = @_;
    my $target = $resp->{members}[1]{id};  # Pick a different member

    $client->move_leader($target, sub {
        my ($resp, $err) = @_;
        if ($err) {
            warn "Failed to move leader: $err->{message}";
        } else {
            say "Leadership transferred";
        }
    });
});

auth_status

$client->auth_status($callback);

Check whether authentication is enabled on the etcd cluster.

The response contains:

enabled

Boolean indicating whether authentication is enabled.

auth_revision

The current revision of the auth store.

header

Standard response header.

Example:

$client->auth_status(sub {
    my ($resp, $err) = @_;
    if ($resp->{enabled}) {
        say "Authentication is enabled (revision: $resp->{auth_revision})";
    } else {
        say "Authentication is disabled";
    }
});

ELECTION SERVICE

EV::Etcd provides leader election support through the etcd Election service. Elections use leases to ensure that leadership is automatically released when a leader fails.

election_campaign

$client->election_campaign($name, $lease_id, $value, $callback);

Campaign for leadership of an election.

This call blocks until the caller is elected as leader. Once elected, the caller should periodically keep the lease alive to maintain leadership.

Arguments:

name

The name of the election to campaign in.

lease_id

The lease ID to use for the campaign. The leadership is held for the duration of this lease.

value

The value to set when elected. Other clients can read this value to identify the current leader.

callback

Called with ($response, $error) when elected (or on failure).

The response contains:

leader

A hash containing the leader key information:

name

The election name.

key

The key in etcd that holds the leadership (use for proclaim/resign).

rev

The creation revision of the leader key.

lease

The lease ID attached to the leadership.

header

Standard response header.

Example:

$client->lease_grant(30, sub {
    my ($resp, $err) = @_;
    my $lease_id = $resp->{id};

    $client->election_campaign("my-election", $lease_id, "leader-1", sub {
        my ($resp, $err) = @_;
        if ($err) {
            warn "Failed to become leader: $err->{message}";
            return;
        }
        say "Elected as leader!";
        my $leader_key = $resp->{leader};
        # Store $leader_key for later use with proclaim/resign
    });
});

election_leader

$client->election_leader($name, $callback);

Get the current leader of an election.

Arguments:

name

The name of the election.

callback

Called with ($response, $error) when complete.

The response contains:

kv

The key-value pair of the current leader, containing the leader's value.

header

Standard response header.

Returns an error if there is no current leader.

Example:

$client->election_leader("my-election", sub {
    my ($resp, $err) = @_;
    if ($err) {
        warn "No leader: $err->{message}";
    } else {
        say "Current leader value: $resp->{kv}{value}";
    }
});

election_proclaim

$client->election_proclaim($leader_key, $value, $callback);

Update the leader's value. Only the current leader can proclaim.

Arguments:

leader_key

The leader key hash returned from election_campaign.

value

The new value to announce.

callback

Called with ($response, $error) when complete.

Example:

$client->election_proclaim($leader_key, "new-value", sub {
    my ($resp, $err) = @_;
    if ($err) {
        warn "Proclaim failed: $err->{message}";
    }
});

election_resign

$client->election_resign($leader_key, $callback);

Voluntarily give up leadership.

Arguments:

leader_key

The leader key hash returned from election_campaign.

callback

Called with ($response, $error) when complete.

Example:

$client->election_resign($leader_key, sub {
    my ($resp, $err) = @_;
    say "Resigned from leadership" unless $err;
});

election_observe

$client->election_observe($name, $callback);
$client->election_observe($name, $callback, \%opts);

Observe leader changes for an election. This creates a streaming connection that receives notifications whenever the leader changes.

Arguments:

name

The name of the election to observe.

callback

Called with ($response, $error) for each leader change.

Options:

auto_reconnect

If true, automatically reconnect after connection failures. Default is true.

The response contains:

kv

The key-value pair of the current leader.

header

Standard response header.

Example:

$client->election_observe("my-election", sub {
    my ($resp, $err) = @_;
    if ($err) {
        warn "Observe error: $err->{message}";
        return;
    }
    say "Leader changed: $resp->{kv}{value}";
});

CLUSTER SERVICE

EV::Etcd provides cluster membership management through the Cluster service.

member_list

$client->member_list($callback);

List all members in the etcd cluster.

The response contains:

members

Array of member objects, each containing:

id

The member ID.

name

The member name.

peer_urls

Array of peer URLs for cluster communication.

client_urls

Array of client URLs for client requests.

is_learner

Boolean indicating if the member is a learner.

header

Standard response header.

Example:

$client->member_list(sub {
    my ($resp, $err) = @_;
    for my $member (@{$resp->{members}}) {
        say "Member $member->{name} (ID: $member->{id})";
        say "  Client URLs: @{$member->{client_urls}}";
    }
});

member_add

$client->member_add(\@peer_urls, $callback);
$client->member_add(\@peer_urls, \%opts, $callback);

Add a new member to the cluster.

Arguments:

peer_urls

Array of peer URLs for the new member.

callback

Called with ($response, $error) when complete.

Options:

is_learner

If true, add the member as a non-voting learner.

member_remove

$client->member_remove($member_id, $callback);

Remove a member from the cluster.

member_update

$client->member_update($member_id, \@peer_urls, $callback);

Update a member's peer URLs.

member_promote

$client->member_promote($member_id, $callback);

Promote a learner member to a voting member.

TRANSACTIONS

EV::Etcd supports atomic transactions with compare-and-swap semantics.

txn

$client->txn(
    compare => \@compare_ops,
    success => \@success_ops,
    failure => \@failure_ops,
    callback => $callback,
);

# Or positional form:
$client->txn(\@compare, \@success, \@failure, $callback);

Execute an atomic transaction. If all compare operations succeed, the success operations are executed; otherwise, the failure operations are executed.

Compare operations:

{ key => $key, target => 'value', value => $expected }
{ key => $key, target => 'version', version => $expected }
{ key => $key, target => 'create', create_revision => $expected }
{ key => $key, target => 'mod', mod_revision => $expected }
{ key => $key, target => 'lease', lease => $expected }

Note: Specify exactly one target field (value/version/create_revision/mod_revision/lease) per compare operation. If multiple are provided, the last one processed takes precedence.

Request operations (for success/failure):

{ request_put => { key => $key, value => $value } }
{ request_delete_range => { key => $key } }
{ request_range => { key => $key } }

Example:

$client->txn(
    compare => [
        { key => '/counter', target => 'value', value => '0' }
    ],
    success => [
        { request_put => { key => '/counter', value => '1' } }
    ],
    failure => [],
    callback => sub {
        my ($resp, $err) = @_;
        say $resp->{succeeded} ? "Incremented" : "Already changed";
    },
);

AUTHOR

Yegor Korablev (egor@cpan.org)

LICENSE

This library is free software; you can redistribute it and/or modify it under the same terms as Perl itself.